forward 0.3.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +2 -0
  3. data/Gemfile +0 -2
  4. data/README.md +24 -0
  5. data/Rakefile +3 -1
  6. data/bin/forward +1 -1
  7. data/forward.gemspec +17 -11
  8. data/lib/forward/api/resource.rb +51 -83
  9. data/lib/forward/api/tunnel.rb +41 -68
  10. data/lib/forward/api/user.rb +14 -11
  11. data/lib/forward/api.rb +7 -26
  12. data/lib/forward/cli.rb +55 -253
  13. data/lib/forward/command/account.rb +69 -0
  14. data/lib/forward/command/base.rb +62 -0
  15. data/lib/forward/command/config.rb +64 -0
  16. data/lib/forward/command/tunnel.rb +178 -0
  17. data/lib/forward/common.rb +44 -0
  18. data/lib/forward/config.rb +75 -118
  19. data/lib/forward/request.rb +72 -0
  20. data/lib/forward/socket.rb +125 -0
  21. data/lib/forward/static/app.rb +157 -0
  22. data/lib/forward/static/directory.erb +142 -0
  23. data/lib/forward/tunnel.rb +102 -40
  24. data/lib/forward/version.rb +1 -1
  25. data/lib/forward.rb +80 -63
  26. data/test/api/resource_test.rb +70 -54
  27. data/test/api/tunnel_test.rb +50 -51
  28. data/test/api/user_test.rb +33 -20
  29. data/test/cli_test.rb +0 -126
  30. data/test/command/account_test.rb +26 -0
  31. data/test/command/tunnel_test.rb +133 -0
  32. data/test/config_test.rb +103 -54
  33. data/test/forward_test.rb +47 -0
  34. data/test/test_helper.rb +35 -26
  35. data/test/tunnel_test.rb +50 -22
  36. metadata +210 -169
  37. data/forwardhq.crt +0 -112
  38. data/lib/forward/api/client_log.rb +0 -20
  39. data/lib/forward/api/tunnel_key.rb +0 -18
  40. data/lib/forward/client.rb +0 -110
  41. data/lib/forward/error.rb +0 -12
  42. data/test/api/tunnel_key_test.rb +0 -28
  43. data/test/api_test.rb +0 -0
  44. data/test/client_test.rb +0 -8
@@ -0,0 +1,178 @@
1
+ module Forward
2
+ module Command
3
+ class Tunnel < Base
4
+ BASIC_AUTH_REGEX = /\A[^\s:]+:[^\s:]+\z/i.freeze
5
+ CNAME_REGEX = /\A[a-z0-9]+(?:[\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/i.freeze
6
+ SUBDOMAIN_PREFIX_REGEX = /\A[a-z0-9]{1}[a-z0-9\-]+\z/i.freeze
7
+ STATIC_HOST = '127.0.0.1'.freeze
8
+ STATIC_PORT = 32645.freeze
9
+ DEFAULT_OPTIONS = {
10
+ host: '127.0.0.1',
11
+ port: 80
12
+ }
13
+
14
+ def start
15
+ print_help_and_exit! if !forwardfile? && @args.empty?
16
+
17
+ config.create_or_load
18
+ @options.merge!(DEFAULT_OPTIONS)
19
+ parse_forwardfile
20
+ parse_forwarded
21
+
22
+ @options[:subdomain_prefix] = @args[1] if @args.length > 1
23
+ @options[:no_auth] = @options.fetch(:'no-auth', nil)
24
+
25
+ validate :port, :auth, :cname, :subdomain_prefix
26
+
27
+ client do
28
+ authenticate_user do
29
+ start_static_server if @options[:static_path]
30
+ open_tunnel
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def authenticate_user(&block)
38
+ return block.call if logged_in?
39
+
40
+ email, password = ask_for_credentials(email)
41
+
42
+ logger.debug("[API] authenticating user: `#{email}:#{password.gsub(/./, 'x')}'")
43
+
44
+ API::User.authenticate(email, password) do |subdomain, token|
45
+ config.add_account(subdomain, token)
46
+ block.call
47
+ end
48
+ end
49
+
50
+ def open_tunnel
51
+ API::Tunnel.create(@options) do |attributes|
52
+ Forward.tunnel = Forward::Tunnel.new(@options.merge(attributes))
53
+ end
54
+ end
55
+
56
+ def start_static_server
57
+ require 'thin'
58
+ require 'forward/static/app'
59
+ static_path = @options[:static_path]
60
+ Thin::Logging.silent = true
61
+ logger.debug "[static] starting server on port: #{STATIC_PORT}"
62
+ Thin::Server.start(STATIC_HOST, STATIC_PORT) { run Static::App.new(static_path) }
63
+ end
64
+
65
+ def forwardfile?
66
+ @has_forwardfile ||= File.exist?(forwardfile_path)
67
+ end
68
+
69
+ # Returns a String file path for PWD/Forwardfile
70
+ def forwardfile_path
71
+ @forwardfile_path ||= File.join(Dir.pwd, 'Forwardfile')
72
+ end
73
+
74
+ def parse_forwardfile
75
+ return unless forwardfile?
76
+
77
+ options = YAML.load_file(forwardfile_path)
78
+
79
+ raise CLIError unless options.kind_of?(Hash)
80
+
81
+ logger.debug "[CLI] read Forwardfile `#{forwardfile_path}' as: `#{options.inspect}'"
82
+
83
+ @options.merge!(options.symbolize_keys)
84
+ rescue ArgumentError, SyntaxError, CLIError
85
+ exit_with_error "Unable to parse Forwardfile at `#{forwardfile_path}'"
86
+ end
87
+
88
+ # Parses the arguments to determine if we're forwarding a port or host
89
+ # and validates the port or host and updates @options if valid.
90
+ #
91
+ # arg - A String representing the port or host.
92
+ #
93
+ # Returns a Hash containing the forwarded host or port
94
+ def parse_forwarded
95
+ return if @args.empty?
96
+
97
+ forwarded = @args[0]
98
+ logger.debug "[CLI] forwarded: `#{forwarded}'"
99
+
100
+ if Dir.exist?(forwarded)
101
+ @options[:static_path] = forwarded
102
+ @options[:host] = STATIC_HOST
103
+ @options[:port] = STATIC_PORT
104
+ return
105
+ end
106
+
107
+ case forwarded
108
+ when /\A\d{1,5}\z/
109
+ @options[:port] = forwarded.to_i
110
+ when /\A[-a-z0-9\.\-]+\z/i
111
+ @options[:host] = forwarded
112
+ when /\A[-a-z0-9\.\-]+:\d{1,5}\z/i
113
+ host, port = forwarded.split(':')
114
+ port = port.to_i
115
+
116
+ @options[:host] = host
117
+ @options[:port] = port
118
+ end
119
+ end
120
+
121
+ # Checks to make sure the port being set is a number between 1 and 65535
122
+ # and exits with an error message if it's not.
123
+ #
124
+ # port - port number Integer
125
+ def validate_port
126
+ port = @options[:port]
127
+
128
+ logger.debug "[CLI] validating port: `#{port}'"
129
+ unless port.between?(1, 65535)
130
+ raise CLIError, "Invalid Port: #{port} is an invalid port number"
131
+ end
132
+ end
133
+
134
+ def validate_auth
135
+ auth = @options[:auth]
136
+
137
+ return if auth.nil?
138
+
139
+ logger.debug "[CLI] validating auth: `#{auth}'"
140
+ if auth =~ BASIC_AUTH_REGEX
141
+ @options[:username], @options[:password] = @options[:auth].split(':')
142
+ else
143
+ raise CLIError, "`#{auth}' isn't a valid username:password pair"
144
+ end
145
+ end
146
+
147
+ # Checks to make sure the cname is in the correct format and exits with an
148
+ # error message if it isn't.
149
+ #
150
+ # cname - cname String
151
+ def validate_cname
152
+ cname = @options[:cname]
153
+
154
+ return if cname.nil?
155
+
156
+ logger.debug "[CLI] validating CNAME: `#{cname}'"
157
+ unless cname =~ CNAME_REGEX
158
+ raise CLIError, "`#{cname}' is an invalid domain format"
159
+ end
160
+ end
161
+
162
+ # Checks to make sure the subdomain prefix is in the correct format
163
+ # and exits with an error message if it isn't.
164
+ #
165
+ # prefix - subdomain prefix String
166
+ def validate_subdomain_prefix
167
+ prefix = @options[:subdomain_prefix]
168
+
169
+ return if prefix.nil?
170
+
171
+ logger.debug "[CLI] validating subdomain prefix: `#{prefix}'"
172
+ unless prefix =~ SUBDOMAIN_PREFIX_REGEX
173
+ raise CLIError, "`#{prefix}' is an invalid subdomain prefix format"
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,44 @@
1
+ module Forward
2
+ module Common
3
+ EMAIL_REGEX = /\A[^@]+@[^@]+\.[^@]+\z/.freeze
4
+
5
+ def logger
6
+ Forward.logger
7
+ end
8
+ alias_method :log, :logger
9
+
10
+ def config
11
+ Forward::Config
12
+ end
13
+
14
+ def logged_in?
15
+ !Config.default_account.nil?
16
+ end
17
+
18
+ def os
19
+ Forward.os
20
+ end
21
+
22
+ def windows?
23
+ Forward.windows?
24
+ end
25
+
26
+ def exit_with_message(message = nil)
27
+ puts message if message
28
+ stop_reactor_and_exit
29
+ end
30
+
31
+ def stop_reactor_and_exit(code = 0)
32
+ EM.stop if EM.reactor_running?
33
+ exit(code)
34
+ end
35
+
36
+ def exit_with_error(message = nil)
37
+ if message
38
+ puts HighLine.color(message, :red)
39
+ end
40
+ stop_reactor_and_exit(1)
41
+ end
42
+
43
+ end
44
+ end
@@ -1,30 +1,71 @@
1
- require 'fileutils'
2
- require 'yaml'
3
-
4
1
  module Forward
5
- class Config
6
- CONFIG_FILE_VALUES = [ :api_token ]
2
+ module Config
3
+ extend self
4
+ DEFAULTS = {
5
+ accounts: {},
6
+ default_account: nil,
7
+ auto_copy: true,
8
+ auto_open: false
9
+ }.freeze
10
+
11
+ attr_accessor :accounts
12
+ attr_accessor :default_account
13
+ attr_accessor :auto_copy
14
+ attr_accessor :auto_open
15
+
16
+ def set_defaults!
17
+ DEFAULTS.keys.each { |setting| set_default!(setting) }
18
+ end
7
19
 
8
- attr_accessor :id
9
- attr_accessor :api_token
10
- attr_accessor :private_key
20
+ def set_default!(setting)
21
+ value = DEFAULTS[setting.to_sym]
22
+ value = value.dup if value.is_a?(Hash) || value.is_a?(Array)
23
+ self.send("#{setting}=", value)
11
24
 
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
25
+ value
26
+ end
27
+
28
+ def set_default_account(subdomain)
29
+ self.default_account = subdomain
30
+
31
+ write
32
+ end
33
+
34
+ def add_account(subdomain, api_token)
35
+ accounts[subdomain.to_sym] = api_token
36
+ self.default_account = subdomain
37
+
38
+ write
39
+ end
40
+
41
+ def remove_account(subdomain)
42
+ accounts.delete(subdomain.to_sym)
43
+ self.default_account = accounts.empty? ? nil : accounts.keys.first
44
+
45
+ write
46
+ end
47
+
48
+ %w[set_default_account add_account remove_account].each do |name|
49
+ module_eval "def #{name}!(*args); #{name}(*args); write; end"
50
+ end
51
+
52
+ def api_key
53
+ accounts[default_account.to_sym]
54
+ end
55
+
56
+ def auto_open?
57
+ auto_open == true
58
+ end
59
+
60
+ def auto_copy?
61
+ auto_copy == true
21
62
  end
22
63
 
23
64
  # Updates an existing Config object.
24
65
  #
25
66
  # Returns the updated Config object.
26
67
  def update(attributes)
27
- Forward.log.debug('Updating Config')
68
+ Forward.logger.debug('[config] updating')
28
69
  attributes.each do |key, value|
29
70
  self.send(:"#{key}=", value)
30
71
  end
@@ -39,113 +80,49 @@ module Forward
39
80
  Hash[instance_variables.map { |var| [var[1..-1].to_sym, instance_variable_get(var)] }]
40
81
  end
41
82
 
42
- # Validate that the required values are in the Config.
43
- # Raises a config error if values are missing.
44
- def validate
45
- Forward.log.debug('Validating Config')
46
- attributes = [:api_token, :private_key]
47
- errors = []
48
-
49
- attributes.each do |attribute|
50
- value = instance_variable_get("@#{attribute}")
51
-
52
- errors << attribute if value.nil? || value.to_s.empty?
53
- end
54
-
55
- if errors.length == 1
56
- raise ConfigError, "#{errors.first} is a required field"
57
- elsif errors.length >= 2
58
- raise ConfigError, "#{errors.join(', ')} are required fields"
59
- end
60
- end
61
-
62
83
  # Write the current config data to `config_path', and the current
63
84
  # private_key to `key_path'.
64
85
  #
65
86
  # Returns the Config object.
66
87
  def write
67
- Forward.log.debug('Writing Config')
68
- key_folder = File.dirname(Config.key_path)
69
- config_data = to_hash.delete_if { |k,v| !CONFIG_FILE_VALUES.include?(k) }
70
-
71
- self.validate
88
+ Forward.logger.debug('[config] writing')
72
89
 
73
- FileUtils.mkdir(key_folder) unless File.exist?(key_folder)
74
- File.open(Config.config_path, 'w') { |f| f.write(YAML.dump(config_data)) }
75
- File.open(Config.key_path, 'w') { |f| f.write(private_key) }
90
+ File.open(config_path, 'w') { |f| f.write(YAML.dump(to_hash)) }
76
91
 
77
92
  self
78
- rescue
93
+ rescue Exception => e
94
+ Forward.logger.fatal "#{e.message}"
79
95
  raise ConfigError, 'Unable to write config file'
80
96
  end
81
97
 
82
- # Returns the location of the forward ssh private key
83
- # based on the host os.
84
- #
85
- # Returns the String path.
86
- def self.key_path
87
- if Forward.windows?
88
- File.join(ENV['HOME'], 'forward', 'key')
89
- else
90
- File.join(ENV['HOME'], '.ssh', 'forwardhq.com')
91
- end
92
- end
93
-
94
98
  # Returns the location of the forward config file
95
99
  # based on the host os.
96
100
  #
97
101
  # Returns the String path.
98
- def self.config_path
102
+ def config_path
99
103
  if Forward.windows?
100
104
  File.join(ENV['HOME'], 'forward', 'config')
101
105
  else
102
- File.join(ENV['HOME'], '.forward')
106
+ File.join(ENV['HOME'], '.forwardrc')
103
107
  end
104
108
  end
105
109
 
106
110
  # Checks to see if a .forward config file exists.
107
111
  #
108
112
  # Returns true or false based on the existence of the config file.
109
- def self.present?
113
+ def present?
110
114
  File.exist? config_path
111
115
  end
112
116
 
113
- # Checks to see if a `private_key' exist.
114
- #
115
- # Returns true or false based on the existence of the key file.
116
- def self.key_file_present?
117
- File.exist? key_path
118
- end
119
-
120
117
  # Create a config file if it doesn't exist, load one if it does.
121
118
  #
122
119
  # Returns the resulting Config object.
123
- def self.create_or_load
124
- if Config.present?
125
- Config.load
120
+ def create_or_load
121
+ if present?
122
+ load
126
123
  else
127
- Config.create
128
- end
129
- end
130
-
131
- # Create a config by authenticating the user via the api,
132
- # and saving the users api_token/id/private_key.
133
- #
134
- # Returns the new Config object.
135
- def self.create
136
- Forward.log.debug('Creating Config')
137
- if @updating_config || ask('Already have an account with Forward? ').chomp =~ /\Ay/i
138
- config = Config.new
139
- email, password = CLI.authenticate.values_at(:email, :password)
140
- config.update(Forward::Api::User.api_token(email, password))
141
- Forward::Api.token = config.api_token
142
- config.private_key = Forward::Api::TunnelKey.create
143
-
144
- config.write
145
- else
146
- message = "You'll need a Forward account first. You can create one at "
147
- message << HighLine.color('https://forwardhq.com', :underline)
148
- Client.cleanup_and_exit!(message)
124
+ set_defaults!
125
+ write
149
126
  end
150
127
  end
151
128
 
@@ -154,31 +131,11 @@ module Forward
154
131
  # the config options aren't valid.
155
132
  #
156
133
  # Returns the Config object.
157
- def self.load
158
- Forward.log.debug('Loading Config')
159
- config = Config.new
160
-
161
- raise ConfigError, "Unable to find a forward config file at `#{config_path}'" unless Config.present?
162
-
163
- if File.read(config_path).include? '-----M-----'
164
- puts "Forward needs to update your config file, please re-authenticate"
165
- File.delete(config_path)
166
- @updating_config = true
167
- create_or_load
168
- end
169
-
170
- raise ConfigError, "Unable to find a forward key file at `#{key_path}'" unless Config.key_file_present?
171
-
172
-
173
- config.update(YAML.load_file(config_path).symbolize_keys)
174
- config.private_key = File.read(key_path)
175
- Forward::Api.token = config.api_token
176
-
177
- config.validate
178
-
179
- Forward.config = config
134
+ def load
135
+ Forward.logger.debug('[config] loading')
136
+ raise ConfigError, "Unable to find a forward config file at `#{config_path}'" unless present?
180
137
 
181
- config
138
+ update(YAML.load_file(config_path).symbolize_keys)
182
139
  end
183
140
 
184
141
  end
@@ -0,0 +1,72 @@
1
+ module Forward
2
+ class Request
3
+ include Common
4
+
5
+ attr_accessor :tunnel
6
+ attr_accessor :id
7
+ attr_accessor :method
8
+ attr_accessor :path
9
+ attr_accessor :body
10
+ attr_accessor :headers
11
+
12
+ def initialize(tunnel, attributes)
13
+ @tunnel = tunnel
14
+ @id = attributes[:id]
15
+ @method = attributes[:method]
16
+ @path = attributes[:url]
17
+ @headers = attributes[:headers]
18
+ @body = ''
19
+ end
20
+
21
+ def url
22
+ @url ||= "http://#{@tunnel.host}:#{@tunnel.port}"
23
+ end
24
+
25
+ def <<(data)
26
+ @body << data
27
+
28
+ @body
29
+ end
30
+
31
+ def destroy
32
+ @tunnel.requests.delete(@id)
33
+ end
34
+
35
+ def process
36
+ options = {
37
+ path: path,
38
+ body: (body.empty? ? nil : body),
39
+ head: headers
40
+ }
41
+
42
+ puts "[#{Time.now.strftime('%H:%M:%S')}] [#{method}] #{path}" unless Forward.quiet?
43
+
44
+ http = EM::HttpRequest.new(url).send(method.downcase, options)
45
+
46
+ http.headers { |header|
47
+ tunnel.socket.send(type: 'response:start', data: { id: id, headers: header.raw, status: header.status })
48
+ }
49
+
50
+ http.stream { |chunk|
51
+ tunnel.socket.send({type: 'response:data', data: { id: id }}, chunk)
52
+ }
53
+
54
+ http.callback {
55
+ tunnel.socket.send(type: 'response:end', data: { id: id })
56
+ destroy
57
+ }
58
+
59
+ http.errback {
60
+ handle_error(http.error)
61
+ destroy
62
+ }
63
+ end
64
+
65
+ def handle_error(error)
66
+ puts "\e[31mhttp://#{tunnel.authority} isn't responding, make sure your server is running on localhost.\e[0m"
67
+ tunnel.socket.send(type: 'response:error', data: { id: id, error_type: 'localhost_unavailable' })
68
+
69
+ destroy
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,125 @@
1
+ module Forward
2
+ class Socket
3
+ include Common
4
+ HEART_BEAT_INTERVAL = 10.freeze
5
+ WATCH_INTERVAL = 60.freeze
6
+ ACTIVITY_TIMEOUT = 60.freeze
7
+
8
+ def initialize(tunnel)
9
+ @tunnel = tunnel
10
+ @socket = Faye::WebSocket::Client.new(@tunnel.tunneler)
11
+ @socket.on :open do |event|
12
+ logger.debug '[socket] open'
13
+ send(type: 'tunnel:identify', tunnelId: @tunnel.id)
14
+ end
15
+
16
+ @socket.on :message do |event|
17
+ receive(event.data)
18
+ end
19
+
20
+ @socket.on :close do |event|
21
+ logger.debug "[socket] close - code: #{event.code} reason: #{event.reason}"
22
+ if @tunnel.open?
23
+ exit_with_message "Your tunnel has been disconnected"
24
+ else
25
+ @tunnel.destroy { exit_with_error "Unable to open tunnel, please contact us at #{SUPPORT_EMAIL}" }
26
+ end
27
+ end
28
+
29
+ @socket.on :error do |event|
30
+ logger.debug "[socket] error #{event.inspect}"
31
+ end
32
+ end
33
+
34
+ def send(message, data = nil)
35
+ @socket.send(pack(message, data))
36
+ end
37
+
38
+ private
39
+
40
+ def receive(data)
41
+ message, data = unpack(data)
42
+ logger.debug "[socket] #{message[:type]}"
43
+
44
+ @tunnel.track_activity!
45
+
46
+ if message[:type] == 'tunnel:ready'
47
+ @tunnel.ready!
48
+ beat
49
+ watch
50
+
51
+ return
52
+ end
53
+
54
+ if message[:type] == 'tunnel:notfound'
55
+ logger.debug "[socket] tunnel not found"
56
+ exit_with_message "Unable to open tunnel, please contact us at #{SUPPORT_EMAIL}"
57
+ end
58
+
59
+ if message[:type] == 'notification'
60
+ puts message[:message]
61
+
62
+ return
63
+ end
64
+
65
+ if message[:type] == 'request:start'
66
+ logger.debug "[request] start #{message[:url]}"
67
+ request = Request.new(@tunnel, message)
68
+ @tunnel.requests[request.id] = request
69
+ return
70
+ end
71
+
72
+ request = @tunnel.requests[message[:id]]
73
+ return if request.nil?
74
+
75
+ if message[:type] == 'request:data'
76
+ logger.debug '[request] data'
77
+ request << data
78
+ end
79
+
80
+ if message[:type] == 'request:end'
81
+ logger.debug '[request] end'
82
+ request.process
83
+ end
84
+ end
85
+
86
+ def beat
87
+ EM.add_periodic_timer(HEART_BEAT_INTERVAL) do
88
+ logger.debug "[heartbeat] lub"
89
+ send(type: 'heartbeat:lub')
90
+ end
91
+ end
92
+
93
+ def watch
94
+ EM.add_periodic_timer(WATCH_INTERVAL) do
95
+ logger.debug "[socket] checking activity"
96
+ inactive_for = Time.now.to_i - @tunnel.last_active_at
97
+
98
+ if inactive_for > ACTIVITY_TIMEOUT
99
+ logger.debug "[socket] closing due to inactivity (heartbeats and requests)"
100
+ @socket.close
101
+ end
102
+ end
103
+ end
104
+
105
+ def pack(message, data = nil)
106
+ message = message.is_a?(String) ? message : message.to_json
107
+ message.encode!('utf-16le')
108
+
109
+ bytes = Array(message.bytesize).pack('v').bytes.to_a
110
+ bytes += message.bytes.to_a
111
+ bytes += data.bytes.to_a unless data.nil?
112
+
113
+ bytes
114
+ end
115
+
116
+ def unpack(bytes)
117
+ bytes = bytes.pack('C*')
118
+ message_size = bytes.unpack('v')[0]
119
+ message = bytes.byteslice(2, message_size)
120
+ binary_offset = 2 + message_size
121
+
122
+ [JSON.parse(message, symbolize_names: true), bytes.byteslice(binary_offset..-1)]
123
+ end
124
+ end
125
+ end