diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d65e2a6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'http://rubygems.org' + +gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5d5f64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2010 Ben Burkert + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ccac83 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +rack/tunnel +=========== + +Automatic port forwading via SSH tunneling. + +example +------- + +config.ru + + require 'rack/tunnel' + + use Rack::Tunnel, 'http://root@localhost' + run lambda {|env| [200, {'Content-Type' => 'text/plain'}, [env['X-Tunnel-Uri']]] } + +rackup + + % rackup config.ru -P 9292 + +irb + + >> require 'rack/client' + => true + >> uri = Rack::Client.new("http://localhost:9292/").get("/").body + => + >> Rack::Client.new(uri).get('/').status + => 200 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..54afbfe --- /dev/null +++ b/Rakefile @@ -0,0 +1,11 @@ +$:.unshift(File.join(File.dirname(__FILE__), 'lib')) +require 'rack/tunnel' + +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new do |t| + t.rspec_opts = %w[ -c -f documentation -r ./spec/spec_helper.rb ] + t.pattern = 'spec/**/*_spec.rb' +end + +task :default => :spec diff --git a/lib/rack/tunnel.rb b/lib/rack/tunnel.rb new file mode 100644 index 0000000..90f72ca --- /dev/null +++ b/lib/rack/tunnel.rb @@ -0,0 +1,63 @@ +require 'socket' +require 'uri' +require 'open4' + +module Rack + class Tunnel + VERSION = '0.1.0' + HEADER = 'X-Tunnel-Uri' + + def initialize(app, uri) + @app, @uri = app, URI.parse(uri) + + if @uri.port <= 1024 && @uri.user != 'root' + raise Error, "root user required for port #{@uri.port}" + end + end + + def call(env) + establish_tunnel(env['SERVER_PORT']) unless @tunnel_established + @app.call(env.merge(HEADER => @uri.to_s)) + end + + def establish_tunnel(local_port) + pid, _ = Open4.popen4(command(local_port)) + + at_exit do + Process.kill(9, pid) + Process.wait + end + + wait_for_tunnel + @tunnel_established = true + end + + def command(local_port) + cmd = %w"ssh" + cmd << "-R 0.0.0.0:#{@uri.port}:localhost:#{local_port}" + cmd << @uri.host + cmd << "-l #{@uri.user}" + cmd << "-o TCPKeepAlive=yes" + cmd << "-o ServerAliveInterval=30" + cmd << "-o ServerAliveCountMax=10" + cmd << "-o GatewayPorts=yes" + cmd << "-N" + + cmd.join(' ') + end + + def wait_for_tunnel + Timeout::timeout(30) do + begin + return TCPSocket.new(@uri.host, @uri.port) + rescue Errno::ECONNREFUSED + retry + rescue => e + raise Error, e.message + end + end + end + + class Error < StandardError ; end + end +end diff --git a/rack-tunnel.gemspec b/rack-tunnel.gemspec new file mode 100644 index 0000000..16b9e99 --- /dev/null +++ b/rack-tunnel.gemspec @@ -0,0 +1,29 @@ +dir = File.dirname(__FILE__) +$:.unshift dir + '/lib' + +require 'rack/tunnel' + +Gem::Specification.new do |s| + s.name = 'rack-tunnel' + s.version = Rack::Tunnel::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ['Ben Burkert'] + s.email = ['ben@benburkert.com'] + s.homepage = 'http://github.com/benburkert/rack-tunnel' + s.summary = "Automatic port forwading via SSH tunneling." + s.description = s.summary + + s.add_dependency 'rack' + s.add_dependency 'open4' + + s.add_development_dependency 'rack-client', '>=0.3.1.pre.b' + s.add_development_dependency 'json' + s.add_development_dependency 'rspec' + s.add_development_dependency 'realweb' + s.add_development_dependency 'rake' + + s.files = Dir["#{dir}/lib/**/*.rb"] + s.require_paths = ["lib"] + + s.test_files = Dir["#{dir}/spec/**/*.rb"] +end diff --git a/spec/config.ru b/spec/config.ru new file mode 100644 index 0000000..925aa64 --- /dev/null +++ b/spec/config.ru @@ -0,0 +1,5 @@ +use Rack::Lint +use Rack::Tunnel, 'http://root@localhost' +use Rack::Lint + +run lambda {|env| [200, {'Content-Type' => 'application/json'}, [env.to_json]] } diff --git a/spec/rack_tunnel_spec.rb b/spec/rack_tunnel_spec.rb new file mode 100644 index 0000000..5f86ae3 --- /dev/null +++ b/spec/rack_tunnel_spec.rb @@ -0,0 +1,24 @@ +require File.join(File.dirname(__FILE__), 'spec_helper') + +describe Rack::Tunnel do + it 'adds the "X-Tunnel-Uri" header' do + env = JSON.parse(@client.get('/').body) + env['X-Tunnel-Uri'].should =~ %r{^http://} + end + + it 'tunnels over ssh' do + env = JSON.parse(@client.get('/').body) + uri = env['X-Tunnel-Uri'] + + Rack::Client.new(uri).get('/').status.should == 200 + end + + it 'raise an exception if the port is restricted and the user is not root' do + lambda { + Rack::Client.new do + use Rack::Tunnel, "http://#{ENV['USER']}@localhost" + run lambda { [200, {}, []] } + end + }.should raise_error(Rack::Tunnel::Error) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..201cb12 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,16 @@ +$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) + +require 'rack/tunnel' +require 'realweb' +require 'rack/client' +require 'json' + +RSpec.configure do |config| + config.color_enabled = true + + config.before(:all) do + @server = RealWeb.start_server_in_thread(File.join(File.dirname(__FILE__), 'config.ru')) + @client = Rack::Client.new("http://127.0.0.1:#{@server.port}") + end +end +