summaryrefslogtreecommitdiff
path: root/lib/net/ssh/gateway.rb
blob: 3c7fd1ea8ee996b4c12eab7b286cf3dcc2f35b0a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
require 'thread'
require 'net/ssh'
require 'net/ssh/version'

# A Gateway is an object that allows you to tunnel network connections through
# a publicly visible host to a host hidden behind it. This is particularly
# useful when dealing with hosts behind a firewall. One host will generally
# be visible (and accessible) outside the firewall, while the others will all
# be behind the firewall, and the only way to access those restricted hosts
# is by first logging into the publicly visible host, and from thence logging
# into the restricted ones.
#
# This class makes it easy to programmatically connect to these restricted
# hosts via SSH. You can either simply forward a port from the local host to
# the remote host, or you can open a new Net::SSH connection to the remote
# host via a forwarded port.
#
#   require 'net/ssh/gateway'
#
#   gateway = Net::SSH::Gateway.new('host.name', 'user')
#
#   gateway.open('hidden.host', 80) do |port|
#     Net::HTTP.get_print '127.0.0.1', '/path', port
#   end
#
#   gateway.ssh('hidden.host', 'user') do |ssh|
#     puts ssh.exec!("hostname")
#   end
#
#   gateway.shutdown!
#
# Port numbers are allocated automatically, beginning at MAX_PORT and
# decrementing on each request for a new port until MIN_PORT is reached. If
# a port is already in use, this is detected and a different port will be
# assigned.
class Net::SSH::Gateway
  # A trivial class for representing the version of this library.
  class Version < Net::SSH::Version
    # The major component of the library's version
    MAJOR = 1

    # The minor component of the library's version
    MINOR = 1

    # The tiny component of the library's version
    TINY  = 1

    # The library's version as a Version instance
    CURRENT = new(MAJOR, MINOR, TINY)

    # The library's version as a String instance
    STRING = CURRENT.to_s
  end

  # The maximum port number that the gateway will attempt to use to forward
  # connections from.
  MAX_PORT = 65535

  # The minimum port number that the gateway will attempt to use to forward
  # connections from.
  MIN_PORT = 1024

  # Instantiate a new Gateway object, using the given remote host as the
  # tunnel. The arguments here are identical to those for Net::SSH.start, and
  # are passed as given to that method to start up the gateway connection.
  #
  #   gateway = Net::SSH::Gateway.new('host', 'user', :password => "password")
  #
  # As of 1.1 there is an additional option to specify the wait time for
  # the gateway thread. The default is 0.001 seconds and can be changed
  # with the :loop_wait option.
  #
  def initialize(host, user, options={})
    @session = Net::SSH.start(host, user, options)
    @session_mutex = Mutex.new
    @port_mutex = Mutex.new
    @next_port = MAX_PORT
    @loop_wait = options.delete(:loop_wait) || 0.001
    initiate_event_loop!
  end

  # Returns +true+ if the gateway is currently open and accepting connections.
  # This will be the case unless #shutdown! has been invoked.
  def active?
    @active
  end

  # Shuts down the gateway by closing all forwarded ports and then closing
  # the gateway's SSH session.
  def shutdown!
    return unless active?

    @session_mutex.synchronize do
      # cancel all active forward channels
      @session.forward.active_locals.each do |lport, host, port|
        @session.forward.cancel_local(lport)
      end
    end

    @active = false

    @thread.join
    @session.close
  end

  # Opens a new port on the local host and forwards it to the given host/port
  # via the gateway host. If a block is given, the newly allocated port
  # number will be yielded to the block, and the port automatically closed
  # (see #close) when the block finishes. Otherwise, the port number will be
  # returned, and the caller is responsible for closing the port (#close).
  #
  #   gateway.open('host', 80) do |port|
  #     # ...
  #   end
  #
  #   port = gateway.open('host', 80)
  #   # ...
  #   gateway.close(port)
  #
  # If +local_port+ is not specified, the next available port will be used.
  def open(host, port, local_port=nil)
    ensure_open!

    actual_local_port = local_port || next_port

    @session_mutex.synchronize do
      @session.forward.local(actual_local_port, host, port)
    end

    if block_given?
      begin
        yield actual_local_port
      ensure
        close(actual_local_port)
      end
    else
      return actual_local_port
    end
  rescue Errno::EADDRINUSE
    raise if local_port # if a local port was explicitly requested, bubble the error up
    retry
  end

  # Cancels port-forwarding over an open port that was previously opened via
  # #open.
  def close(port)
    ensure_open!

    @session_mutex.synchronize do
      @session.forward.cancel_local(port)
    end
  end

  # Forwards a new connection to the given +host+ and opens a new Net::SSH
  # connection to that host over the forwarded port. If a block is given,
  # the new SSH connection will be yielded to the block, and autoclosed
  # when the block terminates. The forwarded port will be autoclosed as well.
  # If no block was given, the new SSH connection will be returned, and it
  # is up to the caller to terminate both the connection and the forwarded
  # port when done.
  #
  #   gateway.ssh('host', 'user') do |ssh|
  #     # ...
  #   end
  #
  #   ssh = gateway.ssh('host', 'user')
  #   # ...
  #   ssh.close
  #   gateway.close(ssh.transport.port)
  def ssh(host, user, options={}, &block)
    local_port = open(host, options[:port] || 22)

    begin
      Net::SSH.start("127.0.0.1", user, options.merge(:port => local_port), &block)
    ensure
      close(local_port) if block || $!
    end
  end

  private

    # Raises a RuntimeError if the gateway is not active. This is used as a
    # sanity check to make sure a client doesn't try to call any methods on
    # a closed gateway.
    def ensure_open!
      raise "attempt to use a closed gateway" unless active?
    end

    # Fires up the gateway session's event loop within a thread, so that it
    # can run in the background. The loop will run for as long as the gateway
    # remains active.
    def initiate_event_loop!
      @active = true

      @thread = Thread.new do
        while @active
          @session_mutex.synchronize do
            @session.process(@loop_wait)
          end
          Thread.pass
        end
      end
    end

    # Grabs the next available port number and returns it.
    def next_port
      @port_mutex.synchronize do
        port = @next_port
        @next_port -= 1
        @next_port = MAX_PORT if @next_port < MIN_PORT
        port
      end
    end
end