tnnl 0.0.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.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +61 -0
- data/Rakefile +2 -0
- data/bin/tnnl +7 -0
- data/lib/tnnl.rb +6 -0
- data/lib/tnnl/cli.rb +145 -0
- data/lib/tnnl/process.rb +51 -0
- data/lib/tnnl/ssh.rb +83 -0
- data/lib/tnnl/version.rb +3 -0
- data/tnnl.gemspec +19 -0
- metadata +69 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 David Stamm
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# Tnnl
|
2
|
+
|
3
|
+
Tnnl is a command-line utility for wrangling SSH tunnels.
|
4
|
+
|
5
|
+
Unlike most SSH tunnel utilities I've come across, Tnnl will not clutter your
|
6
|
+
filesystem with a preferences YAML file. Instead it will try to make use of your
|
7
|
+
native SSH config (at `~/.ssh/config` for most folks).
|
8
|
+
|
9
|
+
__This project is still in an early/alpha state. You have been warned. :)__
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
### Open an SSH tunnel
|
14
|
+
|
15
|
+
Open an SSH tunnel between local port 1234 and port 5678 on the remote host
|
16
|
+
`mysql.spatula.grommet`. If local port 1234 is unavailable, Tnnl will increment
|
17
|
+
by 1 until it finds an open port.
|
18
|
+
|
19
|
+
$ tnnl 1234:admin@mysql.spatula.grommet:3306
|
20
|
+
|
21
|
+
You can omit the local port number, and Tnnl will try to use the same port for
|
22
|
+
the local and remote hosts.
|
23
|
+
|
24
|
+
$ tnnl admin@mysql.spatula.grommet:3306
|
25
|
+
|
26
|
+
If you have defined a host alias in your SSH config, you can save yourself some
|
27
|
+
keystrokes by referencing that alias.
|
28
|
+
|
29
|
+
$ tnnl db:3306
|
30
|
+
|
31
|
+
### Find and close open tunnels
|
32
|
+
|
33
|
+
Use `tnnl list` to list all open SSH tunnels that were created by Tnnl.
|
34
|
+
|
35
|
+
$ tnnl list
|
36
|
+
1. localhost:3307 ==> mysql.spatula.grommet:3306
|
37
|
+
2. localhost:3000 ==> 123.45.67.89:3000
|
38
|
+
3. localhost:666 ==> chunkybacon.gov:666
|
39
|
+
|
40
|
+
You can use the index numbers referenced in `tnnl list` to close 1 or more
|
41
|
+
tunnels.
|
42
|
+
|
43
|
+
$ tnnl close 2
|
44
|
+
$ tnnl close 1 3
|
45
|
+
|
46
|
+
Or close all tunnels created by Tnnl.
|
47
|
+
|
48
|
+
$ tnnl close all
|
49
|
+
|
50
|
+
## Known Issues
|
51
|
+
|
52
|
+
- The list feature relies on renaming processes via $0, which does not work
|
53
|
+
properly on Ruby 1.9.3-p0 on OS X. This appears to be an issue with this
|
54
|
+
particular build of Ruby on this platform
|
55
|
+
(https://groups.google.com/forum/#!topic/urug/zfmEGqjX47M). 1.9.3-p0 users on OS
|
56
|
+
X are encouraged to upgrade to a newer build.
|
57
|
+
- Tnnl uses Net::SSH under the hood, and Net::SSH currently supports only a
|
58
|
+
subset of OpenSSH configuration options. The `StrictHostKeyChecking` preference
|
59
|
+
is not supported, so Tnnl errs on the safe side and prompts you to update
|
60
|
+
~/.ssh/known_hosts when a modified host key is detected. Feel free to open an
|
61
|
+
issue and/or submit a pull request if this is ruining your day.
|
data/Rakefile
ADDED
data/bin/tnnl
ADDED
data/lib/tnnl.rb
ADDED
data/lib/tnnl/cli.rb
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
module Tnnl
|
2
|
+
module CLI
|
3
|
+
class << self
|
4
|
+
|
5
|
+
# This is the entry-point for all command-line operations. It parses
|
6
|
+
# arguments and delegates to the appropriate methods in Tnnl::CLI and
|
7
|
+
# Tnnl::SSH.
|
8
|
+
def run!(args)
|
9
|
+
command = args.shift || 'help'
|
10
|
+
|
11
|
+
if command == 'help'
|
12
|
+
help
|
13
|
+
elsif command == 'list'
|
14
|
+
list
|
15
|
+
elsif command == 'close'
|
16
|
+
close(args)
|
17
|
+
elsif command.include? ':'
|
18
|
+
open(command)
|
19
|
+
else
|
20
|
+
help
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def help
|
25
|
+
# Shamelessly stolen from https://github.com/holman/boom
|
26
|
+
puts %{
|
27
|
+
tnnl (v#{Tnnl::VERSION}) - a utility for managing SSH tunnels
|
28
|
+
|
29
|
+
tnnl [local-port]:[host]:[remote-port]
|
30
|
+
open an SSH tunnel between a port on localhost and port on a remote machine
|
31
|
+
|
32
|
+
tnnl list
|
33
|
+
print a numbered list of open SSH tunnels
|
34
|
+
|
35
|
+
tnnl close [num] [num...]
|
36
|
+
close 1 or more tunnels using the number(s) provided by list
|
37
|
+
|
38
|
+
tnnl close all
|
39
|
+
close all SSH tunnels opened by this program
|
40
|
+
|
41
|
+
For more detailed documentation, please see:
|
42
|
+
https://github.com/whylom/tnnl
|
43
|
+
}.gsub(/^ {10}/, '') # strip the first 10 spaces of every line
|
44
|
+
end
|
45
|
+
|
46
|
+
# Display a numbered list of SSH tunnels opened by this utility.
|
47
|
+
def list
|
48
|
+
processes = Tnnl::Process.list
|
49
|
+
abort 'There are no SSH tunnels open at this time.' if processes.empty?
|
50
|
+
|
51
|
+
processes.each_with_index do |process,i|
|
52
|
+
puts " #{i+1}. #{process}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def close(args)
|
57
|
+
if args.first == 'all' && args.size == 1
|
58
|
+
Tnnl::Process.kill_all
|
59
|
+
elsif args.any?
|
60
|
+
Tnnl::Process.kill_several(*args.map(&:to_i))
|
61
|
+
else
|
62
|
+
abort "Usage:\n tnnl close all\n tnnl close [index] [index...]"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Open an SSH tunnel. After parsing the user's command-input input, find
|
67
|
+
# the closest available local port. Rescue from common SSH errors, and
|
68
|
+
# provide appropriate feedback to the user.
|
69
|
+
def open(connection)
|
70
|
+
host, user, local_port, remote_port = parse_connection(connection)
|
71
|
+
local_port = Tnnl::SSH.find_open_port(local_port)
|
72
|
+
|
73
|
+
puts "Opening SSH tunnel... "
|
74
|
+
Tnnl::SSH.open(host, user, local_port, remote_port)
|
75
|
+
puts "localhost:#{local_port} ==> #{user}@#{host}:#{remote_port}"
|
76
|
+
|
77
|
+
rescue Tnnl::SSH::HostNotFound
|
78
|
+
puts 'ERROR: could not resolve host'
|
79
|
+
rescue Tnnl::SSH::TimeoutError
|
80
|
+
puts 'ERROR: connection timed out'
|
81
|
+
rescue Tnnl::SSH::AuthenticationFailed
|
82
|
+
puts 'ERROR: authentication failed'
|
83
|
+
rescue Tnnl::SSH::HostKeyMismatch => e
|
84
|
+
handle_host_key_mismatch(e)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Parses input from command-line and returns an array of SSH connection
|
88
|
+
# parameters suitable for passing to Tnnl::SSH.open. Raises ArgumentError
|
89
|
+
# if any of the required parameters are missing.
|
90
|
+
def parse_connection(connection)
|
91
|
+
parts = connection.split(':')
|
92
|
+
|
93
|
+
if parts.size == 3
|
94
|
+
# 1234:host:5678
|
95
|
+
local_port, host, remote_port = parts
|
96
|
+
elsif parts.size == 2
|
97
|
+
# host:5678
|
98
|
+
host, remote_port = parts
|
99
|
+
local_port = remote_port
|
100
|
+
end
|
101
|
+
|
102
|
+
if host.include? '@'
|
103
|
+
# user@host:5678 -> parse manually
|
104
|
+
user, host = host.split('@')
|
105
|
+
else
|
106
|
+
# host:5678 -> look up user & actual hostname in local SSH config
|
107
|
+
config = Net::SSH.configuration_for(host)
|
108
|
+
|
109
|
+
# TODO: Make this a custom exception handled by Tnnl::CLI.run!
|
110
|
+
abort "Could not find host '#{host}' in your SSH config." if config.empty?
|
111
|
+
|
112
|
+
user = config[:user]
|
113
|
+
host = config[:host_name]
|
114
|
+
end
|
115
|
+
|
116
|
+
parts = [host, user, local_port.to_i, remote_port.to_i]
|
117
|
+
raise ArgumentError if parts.any? { |a| a.nil? }
|
118
|
+
|
119
|
+
parts
|
120
|
+
end
|
121
|
+
|
122
|
+
# Prompt user to save the new host key in their known hosts file.
|
123
|
+
def handle_host_key_mismatch(error)
|
124
|
+
valid_responses = %w(y n)
|
125
|
+
response = nil
|
126
|
+
|
127
|
+
while !valid_responses.include?(response)
|
128
|
+
puts "\nWARNING! The remote host key has changed."
|
129
|
+
puts "Someone could be eavesdropping on your SSH connection."
|
130
|
+
puts "It is also possible that the RSA host key has just been changed."
|
131
|
+
|
132
|
+
puts "\nThe fingerprint for the RSA key sent by the remote host is:"
|
133
|
+
puts error.fingerprint
|
134
|
+
|
135
|
+
print "\nAccept this new key in the known hosts file? (y/n): "
|
136
|
+
response = gets.strip.downcase
|
137
|
+
end
|
138
|
+
|
139
|
+
# Record this host and key in the known hosts file if the user wishes.
|
140
|
+
error.remember_host! if response == 'y'
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
data/lib/tnnl/process.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
module Tnnl
|
2
|
+
class Process
|
3
|
+
attr_accessor :name, :pid
|
4
|
+
|
5
|
+
class << self
|
6
|
+
# Returns an array of instances of Tnnl::Process representing each of the
|
7
|
+
# currently open SSH tunnels created by Tnnl on this machine.
|
8
|
+
def list
|
9
|
+
# Grep the process list to get processes with "tnnl" somewhere in the
|
10
|
+
# name. Format the output from ps for easier parsing.
|
11
|
+
list = `ps -eo pid,comm | grep tnnl`.split(/\n/)
|
12
|
+
|
13
|
+
# Transform each string into an instance of Tnnl::Process.
|
14
|
+
processes = list.map do |line|
|
15
|
+
pid, name = line.strip.split(' ')
|
16
|
+
self.new(pid, name)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Remove any processes we might have found that don't conform to our
|
20
|
+
# naming convention.
|
21
|
+
processes.select { |p| p.name =~ /^tnnl\[.*\]$/ }
|
22
|
+
end
|
23
|
+
|
24
|
+
def kill_several(*to_kill)
|
25
|
+
list.each_with_index do |process, i|
|
26
|
+
process.kill if to_kill.include?(i+1)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def kill_all
|
31
|
+
list.each(&:kill)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
def initialize(pid, name)
|
37
|
+
@pid = pid.to_i
|
38
|
+
@name = name
|
39
|
+
end
|
40
|
+
|
41
|
+
def kill
|
42
|
+
::Process.kill('INT', pid)
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_s
|
46
|
+
metadata = name.scan(/\[(.*)\]/).last.first
|
47
|
+
local_port, host, remote_port = metadata.split(':')
|
48
|
+
"localhost:#{local_port} ==> #{host}:#{remote_port}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/tnnl/ssh.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
module Tnnl
|
2
|
+
module SSH
|
3
|
+
|
4
|
+
class HostNotFound < SocketError ; end
|
5
|
+
class TimeoutError < Timeout::Error ; end
|
6
|
+
class AuthenticationFailed < Net::SSH::AuthenticationFailed ; end
|
7
|
+
|
8
|
+
# Wraps & forwards method calls to an instance of Net::SSH::HostKeyMismatch
|
9
|
+
class HostKeyMismatch < Net::SSH::Exception
|
10
|
+
def initialize(error)
|
11
|
+
@error = error
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_missing(method, *args)
|
15
|
+
@error.send(method, *args)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
TIMEOUT = 15
|
22
|
+
|
23
|
+
class << self
|
24
|
+
|
25
|
+
# Returns an available port on localhost, starting with the provided port
|
26
|
+
# and incrementing by 1 until a port is found.
|
27
|
+
def find_open_port(port)
|
28
|
+
while true
|
29
|
+
begin
|
30
|
+
# Attempt to bind to the requested port.
|
31
|
+
socket = Socket.new(Socket::Constants::AF_INET, Socket::Constants::SOCK_STREAM, 0)
|
32
|
+
sockaddr = Socket.pack_sockaddr_in(port, '127.0.0.1')
|
33
|
+
socket.bind(sockaddr)
|
34
|
+
|
35
|
+
# If an exception wasn't raised, the current port is available.
|
36
|
+
# Close the socket now (so we can use the port for realsies)
|
37
|
+
# and exit the loop by returning the port.
|
38
|
+
socket.close
|
39
|
+
return port
|
40
|
+
rescue Errno::EADDRINUSE
|
41
|
+
# If the current port is in use, increment by 1 and try again.
|
42
|
+
port += 1
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Opens an SSH tunnel from the specified local port, to the requested
|
48
|
+
# remote host/port.
|
49
|
+
def open(host, user, local_port, remote_port)
|
50
|
+
# Impose an artificial timeout on establishing a connection to the
|
51
|
+
# remote host.
|
52
|
+
Timeout.timeout(TIMEOUT) do
|
53
|
+
Net::SSH.start(host, user) do |ssh|
|
54
|
+
# Open an SSH tunnel.
|
55
|
+
ssh.forward.local(local_port, '127.0.0.1', remote_port)
|
56
|
+
|
57
|
+
# All of the exceptions we're ready to handle will have already
|
58
|
+
# been caught prior to this. Now we can safely fork a new process
|
59
|
+
# to keep the tunnel open.
|
60
|
+
fork do
|
61
|
+
# Rename the forked process so it can be easily located later.
|
62
|
+
$0 = "tnnl[#{local_port}:#{user}@#{host}:#{remote_port}]"
|
63
|
+
|
64
|
+
run = true
|
65
|
+
trap('INT') { run = false }
|
66
|
+
ssh.loop(0.1) { run }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
rescue SocketError
|
71
|
+
raise Tnnl::SSH::HostNotFound
|
72
|
+
rescue Timeout::Error
|
73
|
+
raise Tnnl::SSH::TimeoutError
|
74
|
+
rescue Net::SSH::AuthenticationFailed
|
75
|
+
raise Tnnl::SSH::AuthenticationFailed
|
76
|
+
rescue Net::SSH::HostKeyMismatch => e
|
77
|
+
raise Tnnl::SSH::HostKeyMismatch.new(e)
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
data/lib/tnnl/version.rb
ADDED
data/tnnl.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/tnnl/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ['David Stamm']
|
6
|
+
gem.email = ['whylom@gmail.com']
|
7
|
+
gem.description = %q{A command-line utility for wrangling SSH tunnels.}
|
8
|
+
gem.summary = %q{Tnnl is a command-line utility for wrangling SSH tunnels.}
|
9
|
+
gem.homepage = 'https://github.com/whylom/tnnl'
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = 'tnnl'
|
15
|
+
gem.require_paths = ['lib']
|
16
|
+
gem.version = Tnnl::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency 'net-ssh', '~> 2.3.0'
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tnnl
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- David Stamm
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-04-24 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: net-ssh
|
16
|
+
requirement: &70332409473040 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 2.3.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70332409473040
|
25
|
+
description: A command-line utility for wrangling SSH tunnels.
|
26
|
+
email:
|
27
|
+
- whylom@gmail.com
|
28
|
+
executables:
|
29
|
+
- tnnl
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- .gitignore
|
34
|
+
- Gemfile
|
35
|
+
- LICENSE
|
36
|
+
- README.md
|
37
|
+
- Rakefile
|
38
|
+
- bin/tnnl
|
39
|
+
- lib/tnnl.rb
|
40
|
+
- lib/tnnl/cli.rb
|
41
|
+
- lib/tnnl/process.rb
|
42
|
+
- lib/tnnl/ssh.rb
|
43
|
+
- lib/tnnl/version.rb
|
44
|
+
- tnnl.gemspec
|
45
|
+
homepage: https://github.com/whylom/tnnl
|
46
|
+
licenses: []
|
47
|
+
post_install_message:
|
48
|
+
rdoc_options: []
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ! '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
58
|
+
none: false
|
59
|
+
requirements:
|
60
|
+
- - ! '>='
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0'
|
63
|
+
requirements: []
|
64
|
+
rubyforge_project:
|
65
|
+
rubygems_version: 1.8.11
|
66
|
+
signing_key:
|
67
|
+
specification_version: 3
|
68
|
+
summary: Tnnl is a command-line utility for wrangling SSH tunnels.
|
69
|
+
test_files: []
|