snxvpn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/bin/snxvpn +234 -0
  3. metadata +58 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ebfddca82d495bb99a638e2a8e0bb797cb99295b13ffacfd552254ff1480e93
4
+ data.tar.gz: 7f2d3fc406c4fead8ae9f0853d1141bcc45593b3ddaa0033d7be3b6502e5f08f
5
+ SHA512:
6
+ metadata.gz: 253421610acda74b6f0324fc49e5db3ded94d90f6a5b54809f654ef778a1f9dc67046e26f64799ce2e3e8159977207b74e95ec3ec793cb948f22828817d3daa5
7
+ data.tar.gz: c9eb6ce4e680b45fea302e24ec83ea0c56714963ea093187a0d1451c04ddcef76ad066a533b7f675e0493abe4a0a229ef2470999e5cec54c8955efa50bef2de5
data/bin/snxvpn ADDED
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $stderr.sync = true
4
+
5
+ require 'yaml'
6
+ require 'optparse'
7
+ require 'net/http'
8
+ require 'logger'
9
+ require 'openssl'
10
+ require 'base64'
11
+ require 'socket'
12
+ require 'ipaddr'
13
+ require 'open3'
14
+
15
+ class RetryableError < StandardError; end
16
+
17
+ class FunkyRSA < OpenSSL::PKey::RSA
18
+
19
+ def initialize(mod, exp)
20
+ pubkey = create_encoded_pubkey_from([mod].pack("H*"), [exp].pack("H*"))
21
+ hexkey = [pubkey].pack("H*")
22
+ pemkey = "-----BEGIN RSA PUBLIC KEY-----\n#{Base64.encode64 hexkey}-----END RSA PUBLIC KEY-----"
23
+ super(pemkey)
24
+ end
25
+
26
+ def hex_encrypt(s)
27
+ public_encrypt(s).reverse.unpack("H*").first
28
+ end
29
+
30
+ private
31
+
32
+ def create_encoded_pubkey_from(rawmod, rawexp)
33
+ modulus_hex = prepad_signed(rawmod.unpack('H*').first)
34
+ exponent_hex = prepad_signed(rawexp.unpack('H*').first)
35
+
36
+ modlen = modulus_hex.length / 2
37
+ explen = exponent_hex.length / 2
38
+ encoded_modlen = encode_length_hex(modlen)
39
+ encoded_explen = encode_length_hex(explen)
40
+
41
+ full_hex_len = modlen + explen + encoded_modlen.length/2 + encoded_explen.length/2 + 2
42
+ encoded_hex_len = encode_length_hex(full_hex_len)
43
+
44
+ "30#{encoded_hex_len}02#{encoded_modlen}#{modulus_hex}02#{encoded_explen}#{exponent_hex}"
45
+ end
46
+
47
+ def prepad_signed(hex)
48
+ msb = hex[0]
49
+ (msb < '0' || msb > '7') ? "00#{hex}" : hex
50
+ end
51
+
52
+ def encode_length_hex(der_len) # encode ASN.1 DER length field
53
+ return int_to_hex(der_len) if der_len <= 127
54
+
55
+ hex = int_to_hex(der_len)
56
+ size = 128 + hex.length/2
57
+ "#{int_to_hex(size)}#{hex}"
58
+ end
59
+
60
+ def int_to_hex(num)
61
+ hex = num.to_s(16)
62
+ hex.length.odd? ? "0#{hex}" : hex
63
+ end
64
+
65
+ end
66
+
67
+ class Runner
68
+ ENTRY_PATH = '/SNX/Portal/Main'.freeze
69
+ DEFAULTS = {
70
+ 'realm' => 'ssl_vpn',
71
+ 'login_type' => 'Standard',
72
+ 'username' => 'anonymous',
73
+ 'password' => 'secret',
74
+ 'snxpath' => 'snx',
75
+ }.freeze
76
+
77
+ def initialize(profile, config_path: File.join(ENV['HOME'], '.snxvpn'))
78
+ stat = File.stat(config_path)
79
+ raise ArgumentError, "#{config_path} is not owned by #{ENV['USER']}" unless stat.owned?
80
+ raise ArgumentError, "#{config_path} mode must be 600" if stat.world_readable? || stat.world_writable?
81
+
82
+ raw_config = YAML.load_file(config_path)
83
+ raise ArgumentError, "#{config_path} contains invalid config" unless raw_config.is_a?(Hash)
84
+
85
+ @config = if profile
86
+ raise ArgumentError, "Unable to find configuration for profile '#{profile}'" unless raw_config.key?(profile)
87
+ DEFAULTS.merge(raw_config[profile])
88
+ elsif raw_config.size == 1
89
+ DEFAULTS.merge(raw_config.values.first)
90
+ else
91
+ raise ArgumentError, 'No profile selected'
92
+ end
93
+
94
+ @http = Net::HTTP.new(@config['host'], 443)
95
+ @http.use_ssl = true
96
+ @cookies = ["selected_realm=#{@config['realm']}"]
97
+ end
98
+
99
+ def run(retries = 10)
100
+ # find RSA.js
101
+ resp = get(ENTRY_PATH)
102
+ rsa_path = resp.body.match(/<script .*?src *\= *["'](.*RSA.js)["']/).to_a[1]
103
+ raise RetryableError, "Unable to detect a RSA.js script reference on login page" unless rsa_path
104
+
105
+ # store paths
106
+ login_path = resp.uri
107
+ rsa_path = File.expand_path(rsa_path, File.dirname(login_path))
108
+
109
+ # fetch RSA.js and scan modulus and exponent
110
+ body = get(rsa_path).body
111
+ modulus = body.match(/var *modulus *\= *\'(\w+)\'/).to_a[1]
112
+ exponent = body.match(/var *exponent *\= *\'(\w+)\'/).to_a[1]
113
+ raise RetryableError "Unable to detect modulus/exponent in RSA.js script" unless modulus && exponent
114
+
115
+ # build RSA
116
+ rsa = FunkyRSA.new(modulus, exponent)
117
+
118
+ # post to login
119
+ resp = post(login_path,
120
+ password: rsa.hex_encrypt(@config['password']),
121
+ userName: @config['username'],
122
+ selectedRealm: @config['realm'],
123
+ loginType: @config['login_type'],
124
+ HeightData: '',
125
+ vpid_prefix: '',
126
+ )
127
+ raise RetryableError, "Expected redirect to multi-challenge, but got #{resp.uri}" unless resp.uri.include?('MultiChallenge')
128
+
129
+ # request OTP until successful
130
+ while resp.uri.include?('MultiChallenge')
131
+ inputs = resp.body.scan(/<input.*?type="hidden".*?name="(username|params|HeightData)"(?:.*?value="(.+?)")?/)
132
+ payload = Hash[inputs]
133
+
134
+ print " + Enter one-time password: "
135
+ otp = gets.strip
136
+ payload['password'] = rsa.hex_encrypt(otp)
137
+ resp = post(resp.uri, payload)
138
+ end
139
+
140
+ # request extender info
141
+ resp = get("/SNX/extender")
142
+ extinf = Hash[resp.body.scan(/Extender.(\w+) *= *"(.*?)" *;/)]
143
+ raise RetryableError, "Unable to retrieve extender information" if extinf.empty?
144
+
145
+ host_ip = IPAddr.new(IPSocket.getaddress(extinf['host_name']))
146
+ snxinf = [
147
+ "\x13\x11\x00\x00".encode('binary'), # 4-byte magic
148
+ [976].pack('L<'), # 4-byte data length
149
+ [host_ip.to_i].pack('L<'), # 4-byte host IP
150
+ extinf['host_name'].encode('binary').ljust(64, "\0"), # 64 bytes
151
+ [extinf['port'].to_i].pack('L<'), # 4-byte port
152
+ ''.encode('binary').ljust(6, "\0"), # 6-bytes blank
153
+ extinf['server_cn'].encode('binary').ljust(256, "\0"), # 256 bytes
154
+ extinf['user_name'].encode('binary').ljust(256, "\0"), # 256 bytes
155
+ extinf['password'].encode('binary').ljust(128, "\0"), # 128 bytes
156
+ extinf['server_fingerprint'].encode('binary').ljust(256, "\0"), # 256 bytes
157
+ "\x01\x00".encode('binary'), # 2 bytes
158
+ ].join
159
+
160
+ output, status = Open3.capture2('snx', '-Z')
161
+ raise RetryableError, "Unable to start snx: #{output}" unless status.success?
162
+
163
+ Socket.tcp("127.0.0.1", 7776) do |sock|
164
+ sock.write(snxinf)
165
+ sock.recv(4096) # read answer
166
+ puts " = Connected! Please leave this running to keep VPN open."
167
+ sock.recv(4096) # block until snx process dies
168
+ end
169
+
170
+ puts " ! Connection closed. Exiting..."
171
+ rescue RetryableError
172
+ raise if retries < 1
173
+
174
+ puts " ! #{e.message}. Retrying..."
175
+ sleep 1
176
+ run(retries - 1)
177
+ end
178
+
179
+ private
180
+
181
+ def post(path, payload)
182
+ resp = @http.post(path, URI.encode_www_form(payload), headers)
183
+ handle_response(path, resp)
184
+
185
+ case resp
186
+ when Net::HTTPSuccess then
187
+ resp
188
+ when Net::HTTPRedirection then
189
+ get(resp['location'])
190
+ else
191
+ raise "unexpected response from POST #{path} - #{resp.code} #{resp.message}"
192
+ end
193
+ end
194
+
195
+ def get(path, retries = 10)
196
+ raise ArgumentError, 'too many HTTP redirects' if retries.zero?
197
+
198
+ resp = @http.get(path, headers)
199
+ handle_response(path, resp)
200
+
201
+ case resp
202
+ when Net::HTTPSuccess then
203
+ resp
204
+ when Net::HTTPRedirection then
205
+ get(resp['location'], retries - 1)
206
+ when Net::HTTPNotFound then
207
+ raise "unexpected response from GET #{path} - #{resp.code} #{resp.message}" unless path.include?(ENTRY_PATH)
208
+ resp
209
+ else
210
+ raise "unexpected response from GET #{path} - #{resp.code} #{resp.message}"
211
+ end
212
+ end
213
+
214
+ def headers
215
+ {'Cookie' => @cookies.join('; ')} unless @cookies.empty?
216
+ end
217
+
218
+ def handle_response(path, resp)
219
+ resp.uri = path
220
+ @cookies += Array(resp.get_fields('set-cookie')).map do |str|
221
+ str.split('; ')[0]
222
+ end
223
+ @cookies.uniq!
224
+ end
225
+
226
+ end
227
+
228
+ begin
229
+ Runner.new(ARGV[0]).run
230
+ rescue OpenSSL::SSL::SSLError => e
231
+ abort " ! #{e.message}. Are you already connected to the VPN?"
232
+ rescue StandardError => e
233
+ abort " ! #{e.message}. Exiting..."
234
+ end if $0 == __FILE__
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: snxvpn
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dimitrij Denissenko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-07-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: ''
28
+ email: dimitrij@blacksquaremedia.com
29
+ executables:
30
+ - snxvpn
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - bin/snxvpn
35
+ homepage: https://bitbucket.org/bsm/snxvpn
36
+ licenses: []
37
+ metadata: {}
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.4.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubyforge_project:
54
+ rubygems_version: 2.7.6
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: SNX VPN connection helper
58
+ test_files: []