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.
- checksums.yaml +7 -0
- data/bin/snxvpn +234 -0
- 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: []
|