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 CHANGED
@@ -1,5 +1,48 @@
1
- # Tunnels
1
+ # Tunnel Boring Machine
2
2
 
3
- A simple Ruby script to manage named SSH tunnels because I need these regularly and it's a bit of a pain to manage this in some other way.
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
- At the moment, this will be a command that you invoke, and leave running. At some point, it might make sense to make this two processes -- one that stays alive while there are active tunnels, and a command-line that interacts with that process. For now, that's more work than I intend to bite off -- more of a v2 feature.
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 = Tunnel::VERSION
26
- spec.date = Tunnel::DATE
27
- spec.summary = 'Named SSH tunnels, like bookmarks.'
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
@@ -2,4 +2,4 @@
2
2
 
3
3
  require 'tbm'
4
4
 
5
- Tunnel::CommandLineInterface.parse_and_run
5
+ TBM::CommandLineInterface.parse_and_run
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
@@ -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