snxvpn 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ebfddca82d495bb99a638e2a8e0bb797cb99295b13ffacfd552254ff1480e93
4
- data.tar.gz: 7f2d3fc406c4fead8ae9f0853d1141bcc45593b3ddaa0033d7be3b6502e5f08f
3
+ metadata.gz: c5a236667c5ac56a6d62ce31f2b7542fa191bc02b5da9d128ce2ef34f960f936
4
+ data.tar.gz: 2d21393ea95878e147fd7f7716f791ae0987e9e3074589084440e8fabb980019
5
5
  SHA512:
6
- metadata.gz: 253421610acda74b6f0324fc49e5db3ded94d90f6a5b54809f654ef778a1f9dc67046e26f64799ce2e3e8159977207b74e95ec3ec793cb948f22828817d3daa5
7
- data.tar.gz: c9eb6ce4e680b45fea302e24ec83ea0c56714963ea093187a0d1451c04ddcef76ad066a533b7f675e0493abe4a0a229ef2470999e5cec54c8955efa50bef2de5
6
+ metadata.gz: 1d377b517ca04c53eaed0baa5d9109d36775e1bd6e907ae6c8fa38a59d208fcfbdbd4bf78cad840d4e003ec00755c78c122250ad1d96894db34311b8a35f58d4
7
+ data.tar.gz: 2f6f07413208739239ae042e3b25d0b5080b71cc6e5623b1ea5609dd91a612cd5994b63680310d041d83ad5808fc81161839c1af937bda57333be94310036bc7
data/bin/snxvpn CHANGED
@@ -2,233 +2,14 @@
2
2
 
3
3
  $stderr.sync = true
4
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
5
+ require 'snxvpn'
227
6
 
228
7
  begin
229
- Runner.new(ARGV[0]).run
8
+ Snxvpn::CLI.new(ARGV[0]).run
230
9
  rescue OpenSSL::SSL::SSLError => e
231
10
  abort " ! #{e.message}. Are you already connected to the VPN?"
232
11
  rescue StandardError => e
233
12
  abort " ! #{e.message}. Exiting..."
13
+ rescue SignalException => e
14
+ abort " ! Received #{e.class}. Exiting..."
234
15
  end if $0 == __FILE__
data/lib/snxvpn.rb ADDED
@@ -0,0 +1,17 @@
1
+ require 'yaml'
2
+ require 'optparse'
3
+ require 'net/http'
4
+ require 'logger'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'socket'
8
+ require 'ipaddr'
9
+ require 'open3'
10
+
11
+ module Snxvpn
12
+ class RetryableError < ::StandardError; end
13
+ end
14
+
15
+ %w|config ext_info rsa cli|.each do |name|
16
+ require "snxvpn/#{name}"
17
+ end
data/lib/snxvpn/cli.rb ADDED
@@ -0,0 +1,129 @@
1
+ require 'yaml'
2
+ require 'optparse'
3
+ require 'net/http'
4
+ require 'logger'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'socket'
8
+ require 'ipaddr'
9
+ require 'open3'
10
+
11
+ module Snxvpn
12
+ class CLI
13
+
14
+ attr_reader :config, :http
15
+
16
+ def initialize(profile, config_path: File.join(ENV['HOME'], '.snxvpn'))
17
+ @config = Config.new(config_path, profile)
18
+ @http = Net::HTTP.new(@config[:host], 443)
19
+ @http.use_ssl = true
20
+ @cookies = ["selected_realm=#{@config[:realm]}"]
21
+ end
22
+
23
+ def run(retries = 10)
24
+ # find RSA.js
25
+ resp = get(config[:entry_path])
26
+ rsa_path = resp.body.match(/<script .*?src *\= *["'](.*RSA.js)["']/).to_a[1]
27
+ raise RetryableError, "Unable to detect a RSA.js script reference on login page" unless rsa_path
28
+
29
+ # store paths
30
+ login_path = resp.uri
31
+ rsa_path = File.expand_path(rsa_path, File.dirname(login_path))
32
+
33
+ # fetch RSA.js and parse RSA
34
+ rsa = RSA.parse(get(rsa_path).body)
35
+ raise RetryableError "Unable to detect modulus/exponent in RSA.js script" unless rsa
36
+
37
+ # post to login
38
+ resp = post(login_path,
39
+ password: rsa.hex_encrypt(config[:password]),
40
+ userName: config[:username],
41
+ selectedRealm: config[:realm],
42
+ loginType: config[:login_type],
43
+ HeightData: '',
44
+ vpid_prefix: '',
45
+ )
46
+ raise RetryableError, "Expected redirect to multi-challenge, but got #{resp.uri}" unless resp.uri.include?('MultiChallenge')
47
+
48
+ # request OTP until successful
49
+ inputs = resp.body.scan(/<input.*?type="hidden".*?name="(username|params|HeightData)"(?:.*?value="(.+?)")?/)
50
+ payload = Hash[inputs]
51
+ while resp.uri.include?('MultiChallenge')
52
+ print " + Enter one-time password: "
53
+ otp = gets.strip
54
+ payload['password'] = rsa.hex_encrypt(otp)
55
+ resp = post(resp.uri, payload)
56
+ end
57
+
58
+ # request extender info
59
+ ext_info = ExtInfo.new get("/SNX/extender").body
60
+ raise RetryableError, "Unable to retrieve extender information" if ext_info.empty?
61
+
62
+ output, status = Open3.capture2(config[:snx_path], '-Z')
63
+ raise RetryableError, "Unable to start snx: #{output}" unless status.success?
64
+
65
+ Socket.tcp('127.0.0.1', 7776) do |sock|
66
+ sock.write(ext_info.payload)
67
+ sock.recv(4096) # read answer
68
+ puts ' = Connected! Please leave this running to keep VPN open.'
69
+ sock.recv(4096) # block until snx process dies
70
+ end
71
+
72
+ puts ' ! Connection closed. Exiting...'
73
+ rescue RetryableError
74
+ raise if retries < 1
75
+
76
+ puts ' ! #{e.message}. Retrying...'
77
+ sleep 1
78
+ run(retries - 1)
79
+ end
80
+
81
+ private
82
+
83
+ def get(path, retries = 10)
84
+ raise ArgumentError, 'too many HTTP redirects' if retries.zero?
85
+
86
+ resp = @http.get(path, headers)
87
+ handle_response(path, resp)
88
+
89
+ case resp
90
+ when Net::HTTPSuccess then
91
+ resp
92
+ when Net::HTTPRedirection then
93
+ get(resp['location'], retries - 1)
94
+ when Net::HTTPNotFound then
95
+ raise "unexpected response from GET #{path} - #{resp.code} #{resp.message}" unless path.include?(config[:entry_path])
96
+ resp
97
+ else
98
+ raise "unexpected response from GET #{path} - #{resp.code} #{resp.message}"
99
+ end
100
+ end
101
+
102
+ def post(path, payload)
103
+ resp = @http.post(path, URI.encode_www_form(payload), headers)
104
+ handle_response(path, resp)
105
+
106
+ case resp
107
+ when Net::HTTPSuccess then
108
+ resp
109
+ when Net::HTTPRedirection then
110
+ get(resp['location'])
111
+ else
112
+ raise "unexpected response from POST #{path} - #{resp.code} #{resp.message}"
113
+ end
114
+ end
115
+
116
+ def headers
117
+ {'Cookie' => @cookies.join('; ')} unless @cookies.empty?
118
+ end
119
+
120
+ def handle_response(path, resp)
121
+ resp.uri = path
122
+ @cookies += Array(resp.get_fields('set-cookie')).map do |str|
123
+ str.split('; ')[0]
124
+ end
125
+ @cookies.uniq!
126
+ end
127
+
128
+ end
129
+ end
@@ -0,0 +1,37 @@
1
+ module Snxvpn
2
+ class Config < Hash
3
+ DEFAULTS = {
4
+ 'realm' => 'ssl_vpn',
5
+ 'login_type' => 'Standard',
6
+ 'username' => 'anonymous',
7
+ 'password' => 'secret',
8
+ 'snx_path' => 'snx',
9
+ 'entry_path' => '/SNX/Portal/Main',
10
+ }.freeze
11
+
12
+ def initialize(path, profile = nil)
13
+ stat = File.stat(path)
14
+
15
+ raise ArgumentError, "#{path} is not owned by the current user" unless stat.owned?
16
+ raise ArgumentError, "#{path} mode must be 600" if stat.world_readable? || stat.world_writable?
17
+
18
+ raw = YAML.load_file(path)
19
+ raise ArgumentError, "#{path} contains invalid config" unless raw.is_a?(Hash)
20
+
21
+ update DEFAULTS
22
+ if profile
23
+ raise ArgumentError, "Unable to find configuration for profile '#{profile}'" unless raw.key?(profile)
24
+ update raw[profile]
25
+ elsif raw.size == 1
26
+ update raw.values.first
27
+ else
28
+ raise ArgumentError, 'No profile selected'
29
+ end
30
+ end
31
+
32
+ def [](key)
33
+ super(key.to_s)
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ module Snxvpn
2
+
3
+ class ExtInfo < Hash
4
+
5
+ def initialize(body)
6
+ update Hash[body.scan(/Extender.(\w+) *= *"(.*?)" *;/)]
7
+ end
8
+
9
+ def payload
10
+ host_ip = IPAddr.new(IPSocket.getaddress(self['host_name']))
11
+ snxinf = [
12
+ "\x13\x11\x00\x00".encode('binary'), # 4-byte magic
13
+ [976].pack('L<'), # 4-byte data length
14
+ [host_ip.to_i].pack('L<'), # 4-byte host IP
15
+ self['host_name'].encode('binary').ljust(64, "\0"), # 64 bytes
16
+ [self['port'].to_i].pack('L<'), # 4-byte port
17
+ ''.encode('binary').ljust(6, "\0"), # 6-bytes blank
18
+ self['server_cn'].encode('binary').ljust(256, "\0"), # 256 bytes
19
+ self['user_name'].encode('binary').ljust(256, "\0"), # 256 bytes
20
+ self['password'].encode('binary').ljust(128, "\0"), # 128 bytes
21
+ self['server_fingerprint'].encode('binary').ljust(256, "\0"), # 256 bytes
22
+ "\x01\x00".encode('binary'), # 2 bytes
23
+ ].join
24
+ end
25
+
26
+ end
27
+
28
+ end
data/lib/snxvpn/rsa.rb ADDED
@@ -0,0 +1,57 @@
1
+ module Snxvpn
2
+ class RSA < OpenSSL::PKey::RSA
3
+
4
+ def self.parse(body)
5
+ mod = body.match(/var *modulus *\= *\'(\w+)\'/).to_a[1]
6
+ exp = body.match(/var *exponent *\= *\'(\w+)\'/).to_a[1]
7
+ new(mod, exp) if mod && exp
8
+ end
9
+
10
+ def initialize(mod, exp)
11
+ pubkey = create_encoded_pubkey_from([mod].pack("H*"), [exp].pack("H*"))
12
+ hexkey = [pubkey].pack("H*")
13
+ pemkey = "-----BEGIN RSA PUBLIC KEY-----\n#{Base64.encode64 hexkey}-----END RSA PUBLIC KEY-----"
14
+ super(pemkey)
15
+ end
16
+
17
+ def hex_encrypt(s)
18
+ public_encrypt(s).reverse.unpack("H*").first
19
+ end
20
+
21
+ private
22
+
23
+ def create_encoded_pubkey_from(rawmod, rawexp)
24
+ modulus_hex = prepad_signed(rawmod.unpack('H*').first)
25
+ exponent_hex = prepad_signed(rawexp.unpack('H*').first)
26
+
27
+ modlen = modulus_hex.length / 2
28
+ explen = exponent_hex.length / 2
29
+ encoded_modlen = encode_length_hex(modlen)
30
+ encoded_explen = encode_length_hex(explen)
31
+
32
+ full_hex_len = modlen + explen + encoded_modlen.length/2 + encoded_explen.length/2 + 2
33
+ encoded_hex_len = encode_length_hex(full_hex_len)
34
+
35
+ "30#{encoded_hex_len}02#{encoded_modlen}#{modulus_hex}02#{encoded_explen}#{exponent_hex}"
36
+ end
37
+
38
+ def prepad_signed(hex)
39
+ msb = hex[0]
40
+ (msb < '0' || msb > '7') ? "00#{hex}" : hex
41
+ end
42
+
43
+ def encode_length_hex(der_len) # encode ASN.1 DER length field
44
+ return int_to_hex(der_len) if der_len <= 127
45
+
46
+ hex = int_to_hex(der_len)
47
+ size = 128 + hex.length/2
48
+ "#{int_to_hex(size)}#{hex}"
49
+ end
50
+
51
+ def int_to_hex(num)
52
+ hex = num.to_s(16)
53
+ hex.length.odd? ? "0#{hex}" : hex
54
+ end
55
+
56
+ end
57
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: snxvpn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dimitrij Denissenko
@@ -32,6 +32,11 @@ extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
34
  - bin/snxvpn
35
+ - lib/snxvpn.rb
36
+ - lib/snxvpn/cli.rb
37
+ - lib/snxvpn/config.rb
38
+ - lib/snxvpn/ext_info.rb
39
+ - lib/snxvpn/rsa.rb
35
40
  homepage: https://bitbucket.org/bsm/snxvpn
36
41
  licenses: []
37
42
  metadata: {}