tbm 0.1.2 → 0.2.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +46 -3
- data/Rakefile +5 -5
- data/bin/tbm +1 -1
- data/lib/TBM/cli.rb +83 -0
- data/lib/TBM/config.rb +41 -0
- data/lib/TBM/config_parser.rb +221 -0
- data/lib/TBM/machine.rb +59 -0
- data/lib/TBM/meta.rb +6 -0
- data/lib/TBM/target.rb +54 -0
- data/lib/TBM/tunnel.rb +26 -0
- data/lib/tbm.rb +7 -4
- data/spec/cli_spec.rb +165 -0
- data/spec/config_spec.rb +348 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/target_spec.rb +40 -0
- metadata +19 -11
- data/lib/tunnel/cli.rb +0 -69
- data/lib/tunnel/config.rb +0 -94
- data/lib/tunnel/meta.rb +0 -5
- data/lib/tunnel/target.rb +0 -24
data/README.md
CHANGED
@@ -1,5 +1,48 @@
|
|
1
|
-
#
|
1
|
+
# Tunnel Boring Machine
|
2
2
|
|
3
|
-
|
3
|
+
Tunnel Boring Machine is a ruby application to manage SSH tunnels, which you can use to achieve something a little like a VPN, wherein SSH access to a server can give you access to the network beyond that server.
|
4
4
|
|
5
|
-
|
5
|
+
I use SSH tunnels on a regular basis to access resources at client sites that are not exposed directly to the internet as a whole. Managing those tunnels as a series of bash scripts or aliases became cumbersome. I wanted / needed something better, and the tunnel boring machine has evolved from that need.
|
6
|
+
|
7
|
+
## Current Status ##
|
8
|
+
It's pretty early days. I'm using this myself, but I haven't gone out of my way to share it with anyone because so much is still in flux. I imagine it will be starting to stabilize soon into something I might call a 'beta' product. At the moment, it's probably more of an 'alpha'.
|
9
|
+
|
10
|
+
## Installing ##
|
11
|
+
It is bundled as a ruby gem, so if you have Ruby and RubyGems installed, simply run:
|
12
|
+
|
13
|
+
gem install tbm
|
14
|
+
|
15
|
+
If you prefer, you can certainly download it and build it yourself, or simply invoke the ruby code from the command-line.
|
16
|
+
|
17
|
+
## Invocation ##
|
18
|
+
For the time being, TBM is a simple command you invoke to open the tunnels you need, then you cancel with `^C` to close the tunnels that you had opened. Something like this:
|
19
|
+
|
20
|
+
$ tbm dev-ngnix
|
21
|
+
|
22
|
+
Eventually, I expect that TBM will become a little more interactive, allowing you to open additional tunnels without closing the ones you already opened, close a tunnel without closing all of them, and so forth. Whether it does this as an interactive program, a shell command that interacts with a running process is all TBD.
|
23
|
+
|
24
|
+
## Configuration ##
|
25
|
+
You configure the tunnel boring machine by creating a configuration file in YAML form at `~/.tunnels`. At the moment, you can't have multiple configuration files, change the location of the configuration file or anything of that nature.
|
26
|
+
|
27
|
+
An example configuration file follows:
|
28
|
+
|
29
|
+
jira:
|
30
|
+
host: ssh.example.com
|
31
|
+
forward: 8080
|
32
|
+
teamcity:
|
33
|
+
host: ssh.example.com
|
34
|
+
forward: 8111
|
35
|
+
alias: tc
|
36
|
+
jdbc-as400:
|
37
|
+
host: ssh.example.com
|
38
|
+
forward:
|
39
|
+
greenmachine: [ 449, 8470, 8471, 8476 ]
|
40
|
+
alias: [ ja, j400 ]
|
41
|
+
|
42
|
+
This configuration file is still evolving -- I expect the format to continue to change, the above simply represents the current state.
|
43
|
+
|
44
|
+
## License ##
|
45
|
+
I've put it under the UNLICENSE. Basically, I don't care if you use it, bundle it inside commercial software, or otherwise make use of it, and I don't offer any kind of warranty or support guarantees, nor do I guarantee that any of the projects dependencies are suited for whatever purpose you have in mind. That's all up to you. That said, if you want to talk about it, see the next section.
|
46
|
+
|
47
|
+
## Contact ##
|
48
|
+
If you're using TBM and you want to talk about it or make suggestions, get in touch with me on [Twitter](http://twitter.com/geoffreywiseman) or send me an [email](mailto:geoffrey.wiseman@codiform.com). If there's enough interest, I'd be happy to set up a group, but for the time being that seems like overkill.
|
data/Rakefile
CHANGED
@@ -22,17 +22,17 @@ end
|
|
22
22
|
|
23
23
|
spec = Gem::Specification.new do |spec|
|
24
24
|
spec.name = 'tbm'
|
25
|
-
spec.version =
|
26
|
-
spec.date =
|
27
|
-
spec.summary = '
|
25
|
+
spec.version = TBM::VERSION
|
26
|
+
spec.date = TBM::RELEASE_DATE
|
27
|
+
spec.summary = 'Manages SSH Tunnels by creating an SSH connection and forwarding ports based on named targets defined in configuration.'
|
28
28
|
spec.description = 'The "Tunnel Boring Machine" is meant to bore ssh tunnels through the internet to your desired destination simply and repeatedly, as often as you need them. This is a tool for someone who needs SSH tunnels frequently.'
|
29
29
|
spec.author = 'Geoffrey Wiseman'
|
30
30
|
spec.email = 'geoffrey.wiseman@codiform.com'
|
31
31
|
spec.homepage = 'http://github.com/geoffreywiseman/tunnel-boring-machine'
|
32
32
|
spec.executables << 'tbm'
|
33
|
-
|
33
|
+
|
34
34
|
spec.files = Dir['{lib,spec}/**/*.rb', 'bin/*', 'Rakefile', 'README.md', 'UNLICENSE']
|
35
|
-
|
35
|
+
|
36
36
|
spec.add_dependency( 'net-ssh', '>= 2.6.2' )
|
37
37
|
end
|
38
38
|
|
data/bin/tbm
CHANGED
data/lib/TBM/cli.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
module TBM
|
2
|
+
|
3
|
+
# The command-line interface for TBM, this parses the supplied arguments and orchestrates the
|
4
|
+
# rest of the classes based on those supplied arguments.
|
5
|
+
class CommandLineInterface
|
6
|
+
attr_reader :targets
|
7
|
+
|
8
|
+
# Initialize the CLI with a parsed config.
|
9
|
+
#
|
10
|
+
# @param [Config] config the parsed configuration
|
11
|
+
def initialize( config )
|
12
|
+
@config = config
|
13
|
+
@targets = nil
|
14
|
+
@cancelled = false
|
15
|
+
end
|
16
|
+
|
17
|
+
# Parses the command-line arguments by seeking targets, and print errors/usage if necessary.
|
18
|
+
def parse
|
19
|
+
if ARGV.empty? then
|
20
|
+
print_targets "SYNTAX: tbm <targets>\n\nWhere <targets> is a comma-separated list of:"
|
21
|
+
else
|
22
|
+
parse_targets( ARGV )
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# The CLI is valid if no errors were found during parsing, and that are known target tunnels to bore.
|
27
|
+
#
|
28
|
+
# @return true if there are targets defined, which will only happen if there were no parsing errors
|
29
|
+
def valid?
|
30
|
+
!@targets.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
# Parse the configuration and command-line arguments and run the tunnel boring machine if both are valid.
|
34
|
+
def self.parse_and_run
|
35
|
+
config = ConfigParser.parse
|
36
|
+
if config.valid?
|
37
|
+
cli = CommandLineInterface.new( config )
|
38
|
+
cli.parse
|
39
|
+
if cli.valid?
|
40
|
+
machine = Machine.new( cli.targets )
|
41
|
+
machine.bore
|
42
|
+
end
|
43
|
+
else
|
44
|
+
puts "Cannot parse configuration:\n\t#{config.errors.join('\n\t')}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def parse_targets( targets )
|
51
|
+
found_targets = []
|
52
|
+
missing_targets = []
|
53
|
+
targets.each do |target_name|
|
54
|
+
target = @config.get_target( target_name )
|
55
|
+
if target.nil? then
|
56
|
+
missing_targets << target_name
|
57
|
+
else
|
58
|
+
found_targets << target
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
if missing_targets.any?
|
63
|
+
print_targets( "Cannot find target(s): #{missing_targets.join(', ')}\n\nThese are the targets currently defined:")
|
64
|
+
elsif invalid_combination?( found_targets )
|
65
|
+
puts "Can't combine targets: #{targets.join(', ')}."
|
66
|
+
else
|
67
|
+
@targets = found_targets
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def invalid_combination?( targets )
|
72
|
+
num_hosts = targets.map { |t| t.host }.uniq.size
|
73
|
+
num_usernames = targets.map { |t| t.username }.uniq.size
|
74
|
+
num_hosts != 1 || num_usernames != 1
|
75
|
+
end
|
76
|
+
|
77
|
+
def print_targets( message )
|
78
|
+
puts message
|
79
|
+
@config.each_target { |target| puts "\t#{target.to_s}" }
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
data/lib/TBM/config.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
module TBM
|
2
|
+
|
3
|
+
# Configuration for the Tunnel Boring Machine. This class is both the parser of the configuration
|
4
|
+
# data in YAML form, and the artifact that results from the parsing.
|
5
|
+
class Config
|
6
|
+
|
7
|
+
# Any errors discovered while parsing the configuration.
|
8
|
+
attr_reader :errors
|
9
|
+
|
10
|
+
# The targets defined in the configuration.
|
11
|
+
attr_reader :targets
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@errors = []
|
15
|
+
@targets = []
|
16
|
+
end
|
17
|
+
|
18
|
+
# The configuration is valid if there are no errors.
|
19
|
+
#
|
20
|
+
# @return true if the error collection is empty
|
21
|
+
def valid?
|
22
|
+
@errors.empty?
|
23
|
+
end
|
24
|
+
|
25
|
+
# Request a target having the specified name.
|
26
|
+
#
|
27
|
+
# @param [String] name the name of the target
|
28
|
+
def get_target( name )
|
29
|
+
@targets.find { |target| target.has_name?(name) }
|
30
|
+
end
|
31
|
+
|
32
|
+
# Iterate over each target and yield to the specified block.
|
33
|
+
#
|
34
|
+
# @yield [target] a block to which each target will be passed
|
35
|
+
def each_target( &block )
|
36
|
+
@targets.each { |target| yield target }
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
require 'etc'
|
2
|
+
require 'yaml'
|
3
|
+
require 'tbm'
|
4
|
+
|
5
|
+
module TBM
|
6
|
+
class ConfigParser
|
7
|
+
|
8
|
+
# The configuration file used for parsing config.
|
9
|
+
CONFIG_FILE = File.expand_path( '~/.tbm' )
|
10
|
+
GATEWAY_PATTERN = /^([^@]+)(@([^@]+))?$/
|
11
|
+
|
12
|
+
# Parses the tunnel boring machine configuration to get a list of targets which can
|
13
|
+
# be invoked to bore tunnels.
|
14
|
+
#
|
15
|
+
# @return [Config] the parsed configuration for TBM
|
16
|
+
def self.parse
|
17
|
+
config = Config.new
|
18
|
+
if File.file? CONFIG_FILE
|
19
|
+
config_data = YAML.load_file( CONFIG_FILE )
|
20
|
+
case config_data
|
21
|
+
when Hash
|
22
|
+
parse_gateways( config_data, config ) if config_data.is_a? Hash
|
23
|
+
else
|
24
|
+
config.errors << "Cannot parse TBM configuration of type: #{config_data.class}"
|
25
|
+
end
|
26
|
+
else
|
27
|
+
config.errors << "No configuration file found. Specify your tunnels in YAML form in: ~/.tunnels"
|
28
|
+
end
|
29
|
+
return config
|
30
|
+
rescue Psych::SyntaxError => pse
|
31
|
+
config.errors << "TBM config is invalid YAML: #{pse}"
|
32
|
+
return config
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def self.parse_gateways( gateways, config )
|
38
|
+
if gateways.empty?
|
39
|
+
config.errors << "No gateways specified."
|
40
|
+
else
|
41
|
+
gateways.each_key { |key| parse_gateway key, gateways[key], config }
|
42
|
+
end
|
43
|
+
return config
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def self.parse_gateway( gateway_name, targets, config )
|
49
|
+
if String === gateway_name
|
50
|
+
(gateway_host, gateway_username) = parse_gateway_name( gateway_name )
|
51
|
+
parse_targets( gateway_host, gateway_username, targets, config ) unless gateway_host.nil?
|
52
|
+
else
|
53
|
+
config.errors << "Cannot parse gateway name: #{gateway_name} (#{gateway_name.class})"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.parse_gateway_name( gateway_name )
|
58
|
+
if GATEWAY_PATTERN =~ gateway_name
|
59
|
+
if $3.nil?
|
60
|
+
[$1,Etc.getlogin]
|
61
|
+
else
|
62
|
+
[$3,$1]
|
63
|
+
end
|
64
|
+
else
|
65
|
+
config.errors << "Cannot parse gateway name: #{gateway_name}"
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.parse_targets( gateway_host, gateway_username, targets, config )
|
71
|
+
if Hash === targets
|
72
|
+
targets.each_key do |target_name|
|
73
|
+
target = Target.new( target_name.to_s, gateway_host, gateway_username )
|
74
|
+
config.targets << target
|
75
|
+
configure_target( target, targets[target_name], config )
|
76
|
+
end
|
77
|
+
else
|
78
|
+
config.errors << "Cannot parse targets, expected Hash, received: #{targets.class}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.configure_target( target, target_config, config )
|
83
|
+
case target_config
|
84
|
+
when Fixnum, String
|
85
|
+
tunnel( target, target_config, config )
|
86
|
+
when Array
|
87
|
+
target_config.each { |tunnel_config| tunnel(target, tunnel_config, config) }
|
88
|
+
config.errors << "Target #{target} contains no tunnels." if target_config.empty?
|
89
|
+
when Hash
|
90
|
+
target_config.each { |key,value| configure_target_attribute( target, key, value, config ) }
|
91
|
+
config.errors << "Target #{target} contains no tunnels." if target_config.empty?
|
92
|
+
when nil
|
93
|
+
config.errors << "No target config for #{target}."
|
94
|
+
else
|
95
|
+
config.errors << "Cannot parse target config: #{target_config} (#{target_config.class})"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def self.tunnel( target, tunnel_config, config )
|
100
|
+
case tunnel_config
|
101
|
+
when Fixnum
|
102
|
+
if valid_port?( tunnel_config )
|
103
|
+
target.add_tunnel( Tunnel.new( tunnel_config ) )
|
104
|
+
else
|
105
|
+
config.errors << "Invalid port number: #{tunnel_config}"
|
106
|
+
end
|
107
|
+
when String
|
108
|
+
case tunnel_config
|
109
|
+
when /^\d{1,5}$/
|
110
|
+
port = tunnel_config.to_i
|
111
|
+
if valid_port?( port )
|
112
|
+
target.add_tunnel( Tunnel.new( port ) )
|
113
|
+
else
|
114
|
+
config.errors << "Invalid port number: #{tunnel_config}"
|
115
|
+
end
|
116
|
+
when /^(\d{1,5}):(\d{1,5})$/
|
117
|
+
port = $1.to_i
|
118
|
+
remote_port = $2.to_i
|
119
|
+
if !valid_port?( port )
|
120
|
+
config.errors << "Invalid local port number #{port} from #{tunnel_config}"
|
121
|
+
elsif !valid_port?( remote_port )
|
122
|
+
config.errors << "Invalid remote port number #{remote_port} from #{tunnel_config}"
|
123
|
+
else
|
124
|
+
target.add_tunnel( Tunnel.new( port, :remote_port => remote_port ) )
|
125
|
+
end
|
126
|
+
when /^(\d{1,5}):([a-zA-Z0-9\.\-]+):(\d{1,5})$/
|
127
|
+
port = $1.to_i
|
128
|
+
remote_host = $2
|
129
|
+
remote_port = $3.to_i
|
130
|
+
if !valid_port?( port )
|
131
|
+
config.errors << "Invalid local port number #{port} from #{tunnel_config}"
|
132
|
+
elsif !valid_port?( remote_port )
|
133
|
+
config.errors << "Invalid remote port number #{remote_port} from #{tunnel_config}"
|
134
|
+
else
|
135
|
+
target.add_tunnel( Tunnel.new( port, :remote_host => remote_host, :remote_port => remote_port ) )
|
136
|
+
end
|
137
|
+
when /^([a-zA-Z0-9\.\-]+):(\d{1,5})$/
|
138
|
+
port = $2.to_i
|
139
|
+
remote_host = $1
|
140
|
+
if !valid_port?( port )
|
141
|
+
config.errors << "Invalid port number #{port} from #{tunnel_config}"
|
142
|
+
else
|
143
|
+
target.add_tunnel( Tunnel.new( port, :remote_host => remote_host ) )
|
144
|
+
end
|
145
|
+
else
|
146
|
+
config.errors << "Cannot parse tunnel: #{tunnel_config} (#{tunnel_config.class})"
|
147
|
+
end
|
148
|
+
else
|
149
|
+
config.errors << "Cannot parse tunnel: #{tunnel_config} (#{tunnel_config.class})"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def self.valid_port?( port )
|
154
|
+
port.between?( 1, 65535 )
|
155
|
+
end
|
156
|
+
|
157
|
+
def self.configure_target_attribute( target, attribute_name, attribute_value, config )
|
158
|
+
case attribute_name
|
159
|
+
when "tunnel"
|
160
|
+
tunnel( target, attribute_value, config )
|
161
|
+
when "alias"
|
162
|
+
case attribute_value
|
163
|
+
when Array
|
164
|
+
attribute_value.each { |name| target.add_alias( name.to_s ) }
|
165
|
+
else
|
166
|
+
target.add_alias( attribute_value.to_s )
|
167
|
+
end
|
168
|
+
else
|
169
|
+
remote_host = attribute_name.to_s
|
170
|
+
case attribute_value
|
171
|
+
when Fixnum, String
|
172
|
+
remote_tunnel( target, remote_host, attribute_value, config )
|
173
|
+
when Array
|
174
|
+
attribute_value.each { |tunnel_config| remote_tunnel(target, remote_host, tunnel_config, config) }
|
175
|
+
config.errors << "Host #{remote_host} on target #{target} contains no tunnels." if attribute_value.empty?
|
176
|
+
when nil
|
177
|
+
config.errors << "No target config for host #{remote_host} on target #{target}."
|
178
|
+
else
|
179
|
+
config.errors << "Cannot parse target config: #{attribute_name} = #{attribute_value} (#{attribute_value.class})"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def self.remote_tunnel( target, remote_host, tunnel_config, config )
|
185
|
+
case tunnel_config
|
186
|
+
when Fixnum
|
187
|
+
if valid_port?( tunnel_config )
|
188
|
+
target.add_tunnel( Tunnel.new( tunnel_config, :remote_host => remote_host ) )
|
189
|
+
else
|
190
|
+
config.errors << "Invalid port number: #{tunnel_config}"
|
191
|
+
end
|
192
|
+
when String
|
193
|
+
case tunnel_config
|
194
|
+
when /^\d{1,5}$/
|
195
|
+
port = tunnel_config.to_i
|
196
|
+
if valid_port?( port )
|
197
|
+
target.add_tunnel( Tunnel.new( port, :remote_host => remote_host ) )
|
198
|
+
else
|
199
|
+
config.errors << "Invalid port number: #{tunnel_config}"
|
200
|
+
end
|
201
|
+
when /^(\d{1,5}):(\d{1,5})$/
|
202
|
+
port = $1.to_i
|
203
|
+
remote_port = $2.to_i
|
204
|
+
if !valid_port?( port )
|
205
|
+
config.errors << "Invalid local port number #{port} from #{tunnel_config}"
|
206
|
+
elsif !valid_port?( remote_port )
|
207
|
+
config.errors << "Invalid remote port number #{remote_port} from #{tunnel_config}"
|
208
|
+
else
|
209
|
+
target.add_tunnel( Tunnel.new( port, :remote_port => remote_port, :remote_host => remote_host ) )
|
210
|
+
end
|
211
|
+
else
|
212
|
+
config.errors << "Cannot parse tunnel: #{tunnel_config} (#{tunnel_config.class})"
|
213
|
+
end
|
214
|
+
else
|
215
|
+
config.errors << "Cannot parse tunnel: #{tunnel_config} (#{tunnel_config.class})"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
|
220
|
+
end
|
221
|
+
end
|
data/lib/TBM/machine.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
|
3
|
+
module TBM
|
4
|
+
|
5
|
+
# The "Machine" class does all the actual interaction with SSH to perform the tunneling.
|
6
|
+
class Machine
|
7
|
+
|
8
|
+
# Initialize the Machine with a set of tunnels to bore
|
9
|
+
#
|
10
|
+
# @param [Array<Target>] tunnels the tunnels to be bored
|
11
|
+
def initialize( targets )
|
12
|
+
@targets = targets
|
13
|
+
end
|
14
|
+
|
15
|
+
# Open a connection to the gateway server, and then set up (bore) any
|
16
|
+
# tunnels specified in the initialize method.
|
17
|
+
def bore
|
18
|
+
puts "Starting #{APP_NAME} v#{VERSION}"
|
19
|
+
puts
|
20
|
+
|
21
|
+
trap("INT") { @cancelled = true }
|
22
|
+
host = @targets.first.host
|
23
|
+
username = @targets.first.username
|
24
|
+
Net::SSH.start( host, username ) do |session|
|
25
|
+
puts "Opened connection to #{username}@#{host}:"
|
26
|
+
forward_ports( session )
|
27
|
+
end
|
28
|
+
|
29
|
+
puts "Shutting down the machine."
|
30
|
+
rescue Errno::ECONNRESET
|
31
|
+
puts "\nConnection lost (reset). Shutting down the machine."
|
32
|
+
rescue Errno::ETIMEDOUT
|
33
|
+
puts "\nConnection lost (timed out). Shutting down the machine."
|
34
|
+
rescue Errno::EADDRINUSE
|
35
|
+
puts "\tPorts already in use, cannot forward.\n\nShutting down the machine."
|
36
|
+
rescue Errno::EACCES
|
37
|
+
puts "\tCould not open all ports; you may need to sudo if port < 1000."
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def forward_ports( session )
|
43
|
+
@targets.each do |target|
|
44
|
+
target.each_tunnel do |tunnel|
|
45
|
+
port = tunnel.port
|
46
|
+
remote_host = tunnel.remote_host || 'localhost'
|
47
|
+
remote_host_name = tunnel.remote_host || target.host
|
48
|
+
remote_port = tunnel.remote_port
|
49
|
+
session.forward.local( port, remote_host, remote_port )
|
50
|
+
puts "\ttunneled port #{port} to #{remote_host_name}:#{remote_port}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
puts "\twaiting for Ctrl-C..."
|
54
|
+
session.loop(0.1) { not @cancelled }
|
55
|
+
puts "\n\tCtrl-C pressed. Exiting."
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|