forward 0.0.1 → 0.0.11

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.
@@ -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