snxvpn 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []