snxvpn 0.1.0 → 0.1.1

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.
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: {}