forward 0.3.3 → 1.0.0

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