forward 0.0.1 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,91 @@
1
+ module Forward
2
+ module Api
3
+ class Tunnel < Resource
4
+
5
+ def self.create(options = {})
6
+ resource = Tunnel.new(:create)
7
+ resource.uri = '/api/tunnels'
8
+ params = {
9
+ :hostport => options[:port],
10
+ :client => Forward.client_string,
11
+ }
12
+
13
+ [ :subdomain, :cname, :username, :password ].each do |param|
14
+ params[param] = options[param] unless options[param].nil?
15
+ end
16
+
17
+ resource.post(params)
18
+ end
19
+
20
+ def self.index
21
+ resource = Tunnel.new(:index)
22
+ resource.uri = "/api/tunnels"
23
+
24
+ resource.get
25
+ end
26
+
27
+ def self.destroy(id)
28
+ resource = Tunnel.new(:destroy)
29
+ resource.uri = "/api/tunnels/#{id}"
30
+
31
+ resource.delete
32
+ end
33
+
34
+ def self.show(id)
35
+ resource = Tunnel.new(:show)
36
+ resource.uri = "/api/tunnels/#{id}"
37
+
38
+ resource.get
39
+ rescue Forward::Api::ResourceNotFound
40
+ nil
41
+ end
42
+
43
+ private
44
+
45
+ def self.ask_to_destroy(message)
46
+ tunnels = index
47
+
48
+ puts message
49
+ choose do |menu|
50
+ menu.prompt = "Choose a tunnel from the list to close or `q' to exit forward "
51
+
52
+ tunnels.each do |tunnel|
53
+ text = "tunnel forwarding port #{tunnel['hostport']}"
54
+ menu.choice(text) { destroy_and_create(tunnel['_id']) }
55
+ end
56
+ menu.hidden('quit') { Forward::Client.cleanup_and_exit! }
57
+ menu.hidden('exit') { Forward::Client.cleanup_and_exit! }
58
+ end
59
+ end
60
+
61
+ def self.destroy_and_create(id)
62
+ Forward.log(:debug, "Destroying tunnel: #{id}")
63
+ destroy(id)
64
+ puts "tunnel removed, creating your new forward"
65
+ create
66
+ end
67
+
68
+ def self.create_error(errors)
69
+ base_errors = errors['base']
70
+
71
+ if base_errors && base_errors.any? { |e| e.include? 'limit' }
72
+ message = base_errors.select { |e| e.include? 'limit' }.first
73
+ Forward.log(:debug, 'Tunnel limit reached')
74
+ ask_to_destroy(message)
75
+ else
76
+ message = "An error occured creating your tunnel: \n"
77
+ errors.each do |key, value|
78
+ message << " #{key} #{value.join(', ')}" if key != 'base'
79
+ end
80
+ Forward::Client.cleanup_and_exit!(message)
81
+ end
82
+ end
83
+
84
+ def self.destroy_error(errors)
85
+ # TODO: this is where we will tie into the logger
86
+ nil
87
+ end
88
+
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ module Forward
2
+ module Api
3
+ class User < Resource
4
+
5
+ def self.api_token(email, password)
6
+ resource = User.new(:api_token)
7
+ resource.uri = '/api/users/api_token'
8
+ params = { :email => email, :password => password }
9
+
10
+ resource.post(params)
11
+ end
12
+
13
+ def self.api_token_error(errors)
14
+ Forward::Client.cleanup_and_exit!('Unable to authenticate with email and password')
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,238 @@
1
+ module Forward
2
+ class CLI
3
+ CNAME_REGEX = /\A[a-z0-9]+(?:[\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/i
4
+ SUBDOMAIN_REGEX = /\A[a-z0-9]{1}[a-z0-9\-]+\z/i
5
+ BANNER = <<-BANNER
6
+ Usage: forward <port> [options]
7
+ forward <host> [options]
8
+ forward <host:port> [options]
9
+
10
+ Description:
11
+
12
+ Share a server running on localhost:port over the web by tunneling
13
+ through Forward. A URL is created for each tunnel.
14
+
15
+ Simple example:
16
+
17
+ # You are developing a Rails site.
18
+
19
+ > rails server &
20
+ > forward 3000
21
+ Forward created at https://4ad3f-mycompany.fwd.wf
22
+
23
+ Assigning a subdomain:
24
+
25
+ > rails server &
26
+ > forward 3000 myapp
27
+ Forward created at https://myapp-mycompany.fwd.wf
28
+
29
+ Virtual Host example:
30
+
31
+ # You are already running something on port 80 that uses
32
+ # virtual host names.
33
+
34
+ > forward mysite.dev
35
+ Forward created at https://dh43a-mycompany.fwd.wf
36
+
37
+ BANNER
38
+
39
+ # Parse non-published options and remove them from ARGV, then
40
+ # parse published options and update the @options Hash with provided
41
+ # options and removes switches from ARGV.
42
+ def self.parse_options
43
+ Forward.log(:debug, "Parsing options")
44
+ @options = {
45
+ :host => '0.0.0.0',
46
+ :port => 80,
47
+ :random => true
48
+ }
49
+
50
+ if ARGV.include?('--debug')
51
+ Forward.debug!
52
+ ARGV.delete('--debug')
53
+ elsif ARGV.include?('--debug-remotely')
54
+ Forward.debug_remotely!
55
+ ARGV.delete('--debug-remotely')
56
+ end
57
+
58
+ @opts = OptionParser.new do |opts|
59
+ opts.banner = BANNER.gsub(/^ {6}/, '')
60
+
61
+ opts.separator ''
62
+ opts.separator 'Options:'
63
+
64
+ opts.on('-a', '--auth [USER:PASS]', 'Protect this tunnel with HTTP Basic Auth.') do |credentials|
65
+ username, password = parse_basic_auth(credentials)
66
+ @options[:username] = username
67
+ @options[:password] = password
68
+ end
69
+
70
+ opts.on('-c', '--cname [CNAME]', 'Allow access to this tunnel as NAME.') do |cname|
71
+ validate_cname(cname)
72
+ @options[:cname] = cname
73
+ end
74
+
75
+ opts.on( '-h', '--help', 'Display this help.' ) do
76
+ puts opts
77
+ exit
78
+ end
79
+
80
+ opts.on('-v', '--version', 'Display version number.') do
81
+ puts "forward #{VERSION}"
82
+ exit
83
+ end
84
+ end
85
+
86
+ @opts.parse!
87
+ end
88
+
89
+ # Attempts to validate the basic auth credentials, if successful updates
90
+ # the @options Hash with given credentials.
91
+ #
92
+ # credentials - A String containing a username and password separated
93
+ # by a colon.
94
+ #
95
+ # Returns an Array containing the username and password
96
+ def self.parse_basic_auth(credentials)
97
+ validate_basic_auth(credentials)
98
+ username, password = credentials.split(':')
99
+
100
+ [ username, password ]
101
+ end
102
+
103
+ # Parses the arguments to determine if we're forwarding a port or host
104
+ # and validates the port or host and updates @options if valid.
105
+ #
106
+ # arg - A String representing the port or host.
107
+ #
108
+ # Returns a Hash containing the forwarded host or port
109
+ def self.parse_forwarded(arg)
110
+ Forward.log(:debug, "Forwarded: `#{arg}'")
111
+ forwarded = {}
112
+
113
+ if arg =~ /\A\d{1,5}\z/
114
+ port = arg.to_i
115
+ validate_port(port)
116
+
117
+ forwarded[:port] = port
118
+ elsif arg =~ /\A[-a-z0-9\.\-]+\z/i
119
+ forwarded[:host] = arg
120
+ elsif arg =~ /\A[-a-z0-9\.\-]+:\d{1,5}\z/i
121
+ host, port = arg.split(':')
122
+ port = port.to_i
123
+ validate_port(port)
124
+
125
+ forwarded[:host] = host
126
+ forwarded[:port] = port
127
+ end
128
+
129
+ forwarded
130
+ end
131
+
132
+ # Checks to make sure the port being set is a number between 1 and 65535
133
+ # and exits with an error message if it's not.
134
+ #
135
+ # port - A String containing the port number.
136
+ def self.validate_port(port)
137
+ Forward.log(:debug, "Validating Port: `#{port}'")
138
+ unless port.between?(1, 65535)
139
+ exit_with_error "Invalid Port: #{port} is an invalid port number"
140
+ end
141
+ end
142
+
143
+ # Checks to make sure the basic auth credentials are in the correct format
144
+ # and exits with an error message if they're not.
145
+ #
146
+ # credentials - A String with the username and password for basic auth.
147
+ def self.validate_basic_auth(credentials)
148
+ Forward.log(:debug, "Validating Basic Auth: `#{credentials}'")
149
+ if credentials !~ /\A[^\s:]+:[^\s:]+\z/
150
+ exit_with_error "Basic Auth: bad format, expecting USER:PASS"
151
+ end
152
+ end
153
+
154
+ # Checks to make sure the cname is in the correct format and exits with an
155
+ # error message if it isn't.
156
+ #
157
+ # cname - A String containing the cname.
158
+ def self.validate_cname(cname)
159
+ Forward.log(:debug, "Validating CNAME: `#{cname}'")
160
+ exit_with_error("`#{cname}' is an invalid domain format") unless cname =~ CNAME_REGEX
161
+ end
162
+
163
+ # Validates the subdomain and returns a Hash containing it and also
164
+ # sets random to false, if validation passes.
165
+ #
166
+ # subdomain - A String containing the subdomain.
167
+ #
168
+ # Returns a Hash containing the subdomain
169
+ def self.parse_subdomain(subdomain)
170
+ validate_subdomain(subdomain)
171
+ { :subdomain => subdomain, :random => false }
172
+ end
173
+
174
+ # Checks to make sure the subdomain is in the correct format and exits with an
175
+ # error message if it isn't.
176
+ #
177
+ # cname - A String containing the subdomain.
178
+ def self.validate_subdomain(subdomain)
179
+ Forward.log(:debug, "Validating Subdomain: `#{subdomain}'")
180
+ exit_with_error("`#{subdomain}' is an invalid subdomain format") unless subdomain =~ SUBDOMAIN_REGEX
181
+ end
182
+
183
+ # Checks to see if the cname and random options are both set at the same.
184
+ # time and exits with an error message if they are.
185
+ def self.validate_options
186
+ Forward.log(:debug, "Validating options: `#{@options.inspect}'")
187
+ if !@options[:cname].nil? && @options[:random]
188
+ exit_with_error "You can't use a cname and a random url"
189
+ end
190
+ end
191
+
192
+ # Asks for the user's email and password and puts them in a Hash.
193
+ #
194
+ # Returns a Hash with the email and password.
195
+ def self.authenticate
196
+ if ask('Already have an account with Forward? ').chomp =~ /\An/i
197
+ Client.cleanup_and_exit!("You'll need a Forward account first. You can create one at \033[04mhttps://forwardhq.com\033[04m")
198
+ else
199
+ puts 'Enter your email and password'
200
+ email = ask('email: ').chomp
201
+ password = ask('password: ') { |q| q.echo = false }.chomp
202
+ Forward.log(:debug, "Authenticating User: `#{email}:#{password.gsub(/./, 'x')}'")
203
+
204
+ { :email => email, :password => password }
205
+ end
206
+ end
207
+
208
+ # Parses various options and arguments, validates everything to ensure
209
+ # we're safe to proceed, and finally passes @options to the Client.
210
+ def self.run(args)
211
+ Forward.log(:debug, "Starting forward v#{Forward::VERSION}")
212
+ parse_options
213
+ @options.merge!(parse_forwarded(args[0]))
214
+ @options.merge!(parse_subdomain(args[1])) if args.length > 1
215
+ validate_options
216
+
217
+ print_usage_and_exit if args.empty?
218
+
219
+ Client.start(@options)
220
+ end
221
+
222
+ # Colors an error message red and displays it.
223
+ #
224
+ # message - A String containing an error message.
225
+ def self.exit_with_error(message)
226
+ Forward.log(:fatal, message)
227
+ puts "\033[31m#{message}\033[0m"
228
+ exit 1
229
+ end
230
+
231
+ # Print the usage banner and Exit Code 0.
232
+ def self.print_usage_and_exit
233
+ puts @opts
234
+ exit
235
+ end
236
+
237
+ end
238
+ end
@@ -0,0 +1,92 @@
1
+ module Forward
2
+ class Client
3
+ attr_reader :config
4
+ attr_reader :options
5
+ attr_accessor :tunnel
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ @config = Config.create_or_load
10
+ end
11
+
12
+ def basic_auth?
13
+ @options[:username] && @options[:password]
14
+ end
15
+
16
+ # Sets up a Tunnel instance and adds it to the Client.
17
+ def setup_tunnel
18
+ Forward.log(:debug, 'Setting up tunnel')
19
+ @tunnel = Forward::Tunnel.new(self.options)
20
+ if @tunnel.id
21
+ @tunnel.poll_status
22
+ else
23
+ cleanup_and_exit!('Unable to create a tunnel. If this continues contact support@forwardhq.com')
24
+ end
25
+ end
26
+
27
+ # The options Hash used by Net::SSH.
28
+ #
29
+ # Returns a Hash of options.
30
+ def ssh_options
31
+ {
32
+ :port => Forward.ssh_port,
33
+ :keys_only => true,
34
+ :keys => [],
35
+ :key_data => [ @config.private_key ],
36
+ :encryption => 'blowfish-cbc'
37
+ }
38
+ end
39
+
40
+ def self.current
41
+ @client
42
+ end
43
+
44
+ def self.watch_session(session)
45
+ @client.tunnel.inactive_for = 0 if session.busy?(true)
46
+ true
47
+ end
48
+
49
+ def self.start(options = {})
50
+ Forward.log(:debug, 'Starting client')
51
+ trap(:INT) { cleanup_and_exit!('closing tunnel and exiting...') }
52
+
53
+ @client = Client.new(options)
54
+
55
+ @client.setup_tunnel
56
+
57
+ Forward.log(:debug, "Starting remote forward on https://#{@client.tunnel.subdomain}.fwd.wf on port #{@client.tunnel.port}")
58
+ puts "Forwarding port #{@client.options[:port]} at \033[04mhttps://#{@client.tunnel.subdomain}.fwd.wf\033[m\nCtrl-C to stop forwarding"
59
+ Net::SSH.start(@client.tunnel.tunneler, Forward.ssh_user, @client.ssh_options) do |session|
60
+ session.forward.remote(@client.options[:port], @client.options[:host], @client.tunnel.port)
61
+ session.loop { watch_session(session) }
62
+ end
63
+
64
+ # cleanup_and_exit!
65
+ rescue Net::SSH::AuthenticationFailed => e
66
+ Forward.log(:fatal, "SSH Auth failed `#{e}'")
67
+ cleanup_and_exit!("Authentication failed, try deleting `#{Forward::Config::CONFIG_PATH}' and giving it another go. If the problem continues, contact support@forwardhq.com")
68
+ rescue => e
69
+ Forward.log(:fatal, "#{e.message}\n#{e.backtrace.join("\n")}")
70
+ cleanup_and_exit!("You've been disconnected...")
71
+ end
72
+
73
+ def self.cleanup_and_exit!(message = 'exiting...')
74
+ puts message
75
+ if @client && @client.tunnel && @client.tunnel.id
76
+ Forward.log(:debug, "Cleaning up tunnel: `#{@client.tunnel.id}'")
77
+ @client.tunnel.cleanup
78
+ @client.tunnel = nil
79
+ end
80
+
81
+ Forward.log(:debug, 'Exiting')
82
+ send_debug_log if Forward.debug_remotely?
83
+ ensure
84
+ Thread.main.exit
85
+ end
86
+
87
+ def self.send_debug_log
88
+ log = Forward.stringio_log.string
89
+ Forward::Api::ClientLog.create(log)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,163 @@
1
+ module Forward
2
+ class Config
3
+ CONFIG_PATH = File.join(ENV['HOME'], '.forward')
4
+ CIPHER_LENGTH = 128
5
+ CIPHER_MODE = :CBC
6
+ DELIMETER = '-------M-------'
7
+
8
+ attr_accessor :id
9
+ attr_accessor :api_token
10
+ attr_accessor :private_key
11
+
12
+ # Initializes a Config object with the given attributes.
13
+ #
14
+ # attributes - A Hash of attributes
15
+ #
16
+ # Returns the new Config object.
17
+ def initialize(attributes = {})
18
+ attributes.each do |key, value|
19
+ self.send(:"#{key}=", value)
20
+ end
21
+ end
22
+
23
+ # Updates an existing Config object.
24
+ #
25
+ # Returns the updated Config object.
26
+ def update(attributes)
27
+ Forward.log(:debug, 'Updating Config')
28
+ attributes.each do |key, value|
29
+ self.send(:"#{key}=", value)
30
+ end
31
+
32
+ self
33
+ end
34
+
35
+ # Converts a Config object to a Hash.
36
+ #
37
+ # Returns a Hash representation of the object
38
+ def to_hash
39
+ Hash[instance_variables.map { |var| [var[1..-1].to_sym, instance_variable_get(var)] }]
40
+ end
41
+
42
+ #
43
+ #
44
+ #
45
+ def validate
46
+ Forward.log(:debug, 'Validating Config')
47
+ attributes = [:id, :api_token, :private_key]
48
+ errors = []
49
+
50
+ attributes.each do |attribute|
51
+ value = instance_variable_get("@#{attribute}")
52
+
53
+ errors << attribute if value.nil? || value.to_s.empty?
54
+ end
55
+
56
+ if errors.length == 1
57
+ raise ConfigError, "#{errors.first} is a required field"
58
+ elsif errors.length >= 2
59
+ raise ConfigError, "#{errors.join(', ')} are required fields"
60
+ end
61
+
62
+ end
63
+
64
+ #
65
+ #
66
+ #
67
+ def write
68
+ Forward.log(:debug, 'Writing Config')
69
+ self.validate
70
+
71
+ data = JSON.dump(self.to_hash)
72
+ cipher = OpenSSL::Cipher::AES.new(CIPHER_LENGTH, CIPHER_MODE)
73
+ cipher.encrypt
74
+
75
+ key = cipher.random_key
76
+ iv = cipher.random_iv
77
+
78
+ encrypted_data = cipher.update(data) + cipher.final
79
+ config_data = Base64.encode64(encrypted_data)
80
+ config_data << DELIMETER
81
+ config_data << Base64.encode64("#{key}::#{iv}")
82
+
83
+ File.open(CONFIG_PATH, 'w') { |f| f.write(config_data) }
84
+
85
+ self
86
+ end
87
+
88
+ #
89
+ #
90
+ #
91
+ def self.read
92
+ Forward.log(:debug, 'Reading Config')
93
+ config_data = File.read(CONFIG_PATH)
94
+ encrypted_data, keys = config_data.split(DELIMETER).map { |d| Base64.decode64(d) }
95
+ key, iv = keys.split('::')
96
+ decipher = OpenSSL::Cipher::AES.new(CIPHER_LENGTH, CIPHER_MODE)
97
+
98
+ decipher.decrypt
99
+
100
+ decipher.key = key
101
+ decipher.iv = iv
102
+
103
+ unencrypted = decipher.update(encrypted_data) + decipher.final
104
+
105
+ JSON.parse(unencrypted).symbolize_keys
106
+ rescue
107
+ raise ConfigError, 'Unable to read config file'
108
+ end
109
+
110
+ # Checks to see if a .forward config file exists.
111
+ #
112
+ # Returns true or false based on the existence of the config file.
113
+ def self.present?
114
+ File.exist? CONFIG_PATH
115
+ end
116
+
117
+ def self.create_or_load
118
+ if Config.present?
119
+ Config.load
120
+ else
121
+ Config.create
122
+ end
123
+ end
124
+
125
+ def self.create
126
+ Forward.log(:debug, 'Creating Config')
127
+ config = Config.new
128
+ email, password = CLI.authenticate.values_at(:email, :password)
129
+ config.update(Forward::Api::User.api_token(email, password))
130
+ Forward::Api.token = config.api_token
131
+ config.private_key = Forward::Api::PublicKey.create
132
+
133
+ config.write
134
+ end
135
+
136
+ # It initializes a new Config instance, updates it with the config values
137
+ # from the config file, and raises an error if there's not a config or if
138
+ # the config options aren't valid.
139
+ #
140
+ # Returns the Config instance.
141
+ def self.load
142
+ Forward.log(:debug, 'Loading Config')
143
+ config = Config.new
144
+
145
+ if Config.present?
146
+ config.update(Config.read)
147
+ end
148
+
149
+ config.validate
150
+
151
+ Forward::Api.token = config.api_token;
152
+
153
+ config
154
+ end
155
+
156
+ # Deletes the .forward config file if it exists.
157
+ def self.clear
158
+ Forward.log(:debug, 'Clearning Config')
159
+ File.delete(CONFIG_PATH) if Config.present?
160
+ end
161
+
162
+ end
163
+ end