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
data/lib/forward/cli.rb CHANGED
@@ -1,280 +1,82 @@
1
+ require 'forward/command/base'
2
+ require 'forward/command/account'
3
+ require 'forward/command/tunnel'
4
+ require 'forward/command/config'
5
+
1
6
  module Forward
2
7
  class CLI
3
- BASIC_AUTH_REGEX = /\A[^\s:]+:[^\s:]+\z/i
4
- CNAME_REGEX = /\A[a-z0-9]+(?:[\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/i
5
- SUBDOMAIN_PREFIX_REGEX = /\A[a-z0-9]{1}[a-z0-9\-]+\z/i
6
- USERNAME_REGEX = PASSWORD_REGEX = /\A[^\s]+\z/i
7
-
8
- BANNER = <<-BANNER
9
- Usage: forward <port> [options]
10
- forward <host> [options]
11
- forward <host:port> [options]
12
-
13
- Description:
14
-
15
- Share a server running on localhost:port over the web by tunneling
16
- through Forward. A URL is created for each tunnel.
17
-
18
- Simple example:
8
+ class ValidationError < StandardError; end
19
9
 
20
- # You are developing a Rails site.
10
+ extend Forward::Common
11
+ BANNER = <<-BANNER.gsub /^ {4}/, ''
12
+ Usage: forward <port|host|host:port|path> [options]
21
13
 
22
- > rails server &
23
- > forward 3000
24
- Forward created at https://mycompany.fwd.wf
14
+ Examples:
15
+ > forward 3000 # forward server running on port 3000
16
+ > forward mysite.dev # forward virtual host mysite.dev
17
+ > forward ~/Dev/mysite # forward a path or directory
25
18
 
26
- Assigning a static subdomain prefix:
27
-
28
- > rails server &
29
- > forward 3000 myapp
30
- Forward created at https://myapp-mycompany.fwd.wf
31
-
32
- Virtual Host example:
33
-
34
- # You are already running something on port 80 that uses
35
- # virtual host names.
36
-
37
- > forward mysite.dev
38
- Forward created at https://mycompany.fwd.wf
19
+ Common Options:
20
+ > forward 3000 -a foo:bar # password protect a tunnel
21
+ > forward 3000 myapp # assign a static subdomain prefix
22
+ > forward 3000 myapp -c my.domain.com # use a custom CNAME
39
23
 
24
+ Options:
40
25
  BANNER
41
26
 
42
- # Parse non-published options and remove them from ARGV, then
43
- # parse published options and update the options Hash with provided
44
- # options and removes switches from ARGV.
45
- def self.parse_cli_options(args)
46
- Forward.log.debug("Parsing options")
47
- options = {}
48
-
49
- @opts = OptionParser.new do |opts|
50
- opts.banner = BANNER.gsub(/^ {6}/, '')
27
+ # Parses various options and arguments, validates everything to ensure
28
+ # we're safe to proceed, and finally passes options to the Client.
29
+ def self.start
30
+ HighLine.use_color = false if windows?
31
+ if ARGV.include?('--debug')
32
+ Forward.debug!
33
+ ARGV.delete('--debug')
34
+ end
35
+ Forward.logger.debug("Starting forward v#{Forward::VERSION}")
51
36
 
52
- opts.separator ''
53
- opts.separator 'Options:'
37
+ Slop.parse(banner: BANNER, help: true) do
38
+ on '-a=', '--auth=', "Protect this tunnel with HTTP Basic Auth."
39
+ on '-A', '--no-auth', "Disable authentication on this tunnel (if a default is set in your preferences)"
40
+ on '-c=', '--cname=', "Allow access to this tunnel as CNAME (requires CNAME setup on your DNS server)"
54
41
 
55
- opts.on('-a', '--auth [USER:PASS]', 'Protect this tunnel with HTTP Basic Auth.') do |credentials|
56
- exit_with_error("Basic Auth: bad format, expecting USER:PASS") if credentials !~ BASIC_AUTH_REGEX
57
- username, password = credentials.split(':')
58
- options[:username] = username
59
- options[:password] = password
42
+ on '-q', '--quiet', "Don't display requests" do
43
+ Forward.quiet!
60
44
  end
61
45
 
62
- opts.on('-A', '--no-auth', 'Disable authentication on this tunnel (if a default is set in your preferences)') do |credentials|
63
- options[:no_auth] = true
46
+ on '-v', '--version', 'Display version number' do
47
+ puts "forward #{VERSION}"
48
+ exit
64
49
  end
65
50
 
66
- opts.on('-c', '--cname [CNAME]', 'Allow access to this tunnel as CNAME (you will need to setup a CNAME entry on your DNS server).') do |cname|
67
- options[:cname] = cname.downcase
51
+ # Start / Open a Tunnel
52
+ run do |opts, args|
53
+ Command::Tunnel.run(:start, opts, args)
68
54
  end
69
55
 
70
- opts.on('--logout', 'Remove credentials to allow logging in as another user.' ) do
71
- logout
56
+ # Account Commands
57
+ command 'account:login', banner: "Usage: forward account:login", help: true do
58
+ description "Logs into a new account and makes it the default"
59
+ run { |opts, args| Command::Account.run(:login, opts, args) }
72
60
  end
73
61
 
74
- opts.on( '-h', '--help', 'Display this help.' ) do
75
- puts opts
76
- exit
62
+ command 'account:default', banner: "Usage: forward account:default SUBDOMAIN", help: true do
63
+ description "Sets an account to the default account"
64
+ run { |opts, args| Command::Account.run(:default, opts, args) }
77
65
  end
78
66
 
79
- opts.on('-v', '--version', 'Display version number.') do
80
- puts "forward #{VERSION}"
81
- exit
67
+ command 'account:logout', banner: "Usage: forward account:logout SUBDOMAIN", help: true do
68
+ description "Logs out of an account"
69
+ run { |opts, args| Command::Account.run(:logout, opts, args) }
82
70
  end
83
- end
84
-
85
- @opts.parse!(args)
86
-
87
- options
88
- end
89
-
90
- # Returns a String file path for PWD/Forwardfile
91
- def self.forwardfile_path
92
- File.join(Dir.pwd, 'Forwardfile')
93
- end
94
-
95
- # Parse arguments from CLI and options from Forwardfile
96
- #
97
- # args - An Array of command line arguments
98
- #
99
- # Returns a Hash of options.
100
- def self.parse_args_and_options(args)
101
- options = {
102
- :host => '127.0.0.1',
103
- :port => 80
104
- }
105
-
106
- Forward.log.debug("Default options: `#{options.inspect}'")
107
-
108
- if File.exist? forwardfile_path
109
- options.merge!(parse_forwardfile)
110
- Forward.log.debug("Forwardfile options: `#{options.inspect}'")
111
- end
112
-
113
- options.merge!(parse_cli_options(args))
114
-
115
- forwarded, prefix = args[0..1]
116
- options[:subdomain_prefix] = prefix unless prefix.nil?
117
- options.merge!(parse_forwarded(forwarded))
118
- Forward.log.debug("CLI options: `#{options.inspect}'")
119
-
120
- options
121
- end
122
-
123
- # Parse a local Forwardfile (in the PWD) and return it as a Hash.
124
- # Raise an error and exit if unable to parse or result isn't a Hash.
125
- #
126
- # Returns a Hash of the options found in the Forwardfile
127
- def self.parse_forwardfile
128
- options = YAML.load_file(forwardfile_path)
129
- raise CLIError unless options.kind_of?(Hash)
130
-
131
- options.symbolize_keys
132
- rescue ArgumentError, SyntaxError, CLIError
133
- exit_with_error("Unable to parse #{forwardfile_path}")
134
- end
135
-
136
- # Parses the arguments to determine if we're forwarding a port or host
137
- # and validates the port or host and updates @options if valid.
138
- #
139
- # arg - A String representing the port or host.
140
- #
141
- # Returns a Hash containing the forwarded host or port
142
- def self.parse_forwarded(arg)
143
- Forward.log.debug("Forwarded: `#{arg}'")
144
- forwarded = {}
145
71
 
146
- if arg =~ /\A\d{1,5}\z/
147
- port = arg.to_i
148
-
149
- forwarded[:port] = port
150
- elsif arg =~ /\A[-a-z0-9\.\-]+\z/i
151
- forwarded[:host] = arg
152
- elsif arg =~ /\A[-a-z0-9\.\-]+:\d{1,5}\z/i
153
- host, port = arg.split(':')
154
- port = port.to_i
155
-
156
- forwarded[:host] = host
157
- forwarded[:port] = port
158
- end
159
-
160
- forwarded
161
- end
162
-
163
- # Checks to make sure the port being set is a number between 1 and 65535
164
- # and exits with an error message if it's not.
165
- #
166
- # port - port number Integer
167
- def self.validate_port(port)
168
- Forward.log.debug("Validating Port: `#{port}'")
169
- unless port.between?(1, 65535)
170
- exit_with_error "Invalid Port: #{port} is an invalid port number"
171
- end
172
- end
173
-
174
- # Checks to make sure the username is a valid format
175
- # and exits with an error message if not.
176
- #
177
- # username - username String
178
- def self.validate_username(username)
179
- Forward.log.debug("Validating Username: `#{username}'")
180
- exit_with_error("`#{username}' is an invalid username format") unless username =~ USERNAME_REGEX
181
- end
182
-
183
- # Checks to make sure the password is a valid format
184
- # and exits with an error message if not.
185
- #
186
- # password - password String
187
- def self.validate_password(password)
188
- Forward.log.debug("Validating Password: `#{password}'")
189
- exit_with_error("`#{password}' is an invalid password format") unless password =~ PASSWORD_REGEX
190
- end
191
-
192
- # Checks to make sure the cname is in the correct format and exits with an
193
- # error message if it isn't.
194
- #
195
- # cname - cname String
196
- def self.validate_cname(cname)
197
- Forward.log.debug("Validating CNAME: `#{cname}'")
198
- exit_with_error("`#{cname}' is an invalid domain format") unless cname =~ CNAME_REGEX
199
- end
200
-
201
- # Checks to make sure the subdomain prefix is in the correct format
202
- # and exits with an error message if it isn't.
203
- #
204
- # prefix - subdomain prefix String
205
- def self.validate_subdomain_prefix(prefix)
206
- Forward.log.debug("Validating Subdomain Prefix: `#{prefix}'")
207
- exit_with_error("`#{prefix}' is an invalid subdomain prefix format") unless prefix =~ SUBDOMAIN_PREFIX_REGEX
208
- end
209
-
210
- # Validate all options in options Hash.
211
- #
212
- # options - the options Hash
213
- def self.validate_options(options)
214
- Forward.log.debug("Validating options: `#{options.inspect}'")
215
- options.each do |key, value|
216
- next if value.nil?
217
- validate_method = :"validate_#{key}"
218
- send(validate_method, value) if respond_to?(validate_method)
219
- end
220
- end
221
-
222
- # Asks for the user's email and password and puts them in a Hash.
223
- #
224
- # Returns a Hash with the email and password
225
- def self.authenticate
226
- puts 'Enter your email and password'
227
- email = ask('email: ').chomp
228
- password = ask('password: ') { |q| q.echo = false }.chomp
229
- Forward.log.debug("Authenticating User: `#{email}:#{password.gsub(/./, 'x')}'")
230
-
231
- { :email => email, :password => password }
232
- end
233
-
234
- # Remove .forward file and SSH key (log a user out)
235
- def self.logout
236
- FileUtils.rm_f(Config.config_path)
237
- FileUtils.rm_f(Config.key_path)
238
-
239
- puts "You've been logged out. You'll be asked to log back in when you create a new tunnel."
240
- exit
241
- end
242
-
243
- # Parses various options and arguments, validates everything to ensure
244
- # we're safe to proceed, and finally passes options to the Client.
245
- def self.run(args)
246
- ::HighLine.use_color = false if Forward.windows?
247
- if ARGV.include?('--debug')
248
- Forward.debug!
249
- ARGV.delete('--debug')
250
- elsif ARGV.include?('--rdebug')
251
- Forward.debug_remotely!
252
- ARGV.delete('--rdebug')
72
+ command 'account:list', banner: "Usage: forward account:list", help: true do
73
+ description "Lists active accounts"
74
+ run { |opts, args| Command::Account.run(:list, opts, args) }
75
+ end
253
76
  end
254
77
 
255
- Forward.log.debug("Starting forward v#{Forward::VERSION}")
256
-
257
- options = parse_args_and_options(args)
258
-
259
- validate_options(options)
260
- print_usage_and_exit if args.empty? && !File.exist?(forwardfile_path)
261
-
262
- Client.start(options)
263
- end
264
-
265
- # Colors an error message red and displays it.
266
- #
267
- # message - error message String
268
- def self.exit_with_error(message)
269
- Forward.log.fatal(message)
270
- puts HighLine.color(message, :red)
271
- exit 1
272
- end
273
-
274
- # Print the usage banner and Exit Code 0.
275
- def self.print_usage_and_exit
276
- puts @opts
277
- exit
78
+ rescue CLIError => e
79
+ exit_with_error(e.message)
278
80
  end
279
81
 
280
82
  end
@@ -0,0 +1,69 @@
1
+ module Forward
2
+ module Command
3
+ class Account < Base
4
+ def login
5
+ config.create_or_load
6
+ email = @args.first
7
+
8
+ email, password = ask_for_credentials(email)
9
+ logger.debug("[API] logging in user: `#{email}:#{password.gsub(/./, 'x')}'")
10
+
11
+ client do
12
+ API::User.authenticate(email, password) do |subdomain, token|
13
+ config.add_account(subdomain, token)
14
+ exit_with_message "`#{subdomain}' is now logged in and set to the default account"
15
+ end
16
+ end
17
+ end
18
+
19
+ def logout
20
+ config.create_or_load
21
+ subdomain = @args.first
22
+
23
+ if config.accounts.empty?
24
+ exit_with_message "You aren't logged into any accounts"
25
+ elsif subdomain
26
+ config.remove_account!(subdomain)
27
+ exit_with_message "You are now logged out of the '#{subdomain}' account"
28
+ else
29
+ message = "You must provide a subdomain to logout of an account, you're currently logged into:\n"
30
+ message << config.accounts.map { |s, _| " - #{s}" }.join("\n")
31
+
32
+ exit_with_message message
33
+ end
34
+ end
35
+
36
+ def list
37
+ config.create_or_load
38
+
39
+ if config.accounts.empty?
40
+ exit_with_message("You're not logged into any accounts. You can login with: `forward account:login'")
41
+ else
42
+ puts "Currently logged into:"
43
+ config.accounts.keys.sort.each do |subdomain|
44
+ default = config.default_account.to_sym == subdomain.to_sym
45
+ puts default ? HighLine.color(" - #{subdomain} (default)", :green) : " - #{subdomain}"
46
+ end
47
+ exit_with_message
48
+ end
49
+ end
50
+
51
+ def default
52
+ config.create_or_load
53
+ subdomain = @args.first
54
+
55
+ if config.accounts.empty?
56
+ exit_with_message "You aren't logged into any accounts"
57
+ elsif subdomain && config.accounts.has_key?(subdomain.to_sym)
58
+ config.create_or_load
59
+ config.set_default_account!(subdomain.to_sym)
60
+ exit_with_message "#{subdomain} is now your default account"
61
+ else
62
+ exit_with_message "You're not logged into that account. You can login with: `forward account:login'"
63
+ end
64
+ rescue ConfigError => e
65
+ exit_with_error(e.message)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,62 @@
1
+ module Forward
2
+ module Command
3
+ class Base
4
+ include Forward::Common
5
+
6
+ attr_accessor :options
7
+ attr_accessor :args
8
+
9
+ def self.run(command, options = {}, args = [])
10
+ Forward.logger.debug "[CLI] running `#{command}'"
11
+ new(options, args).send(command)
12
+ end
13
+
14
+ def initialize(options = {}, args = [])
15
+ @opts = options
16
+ @options = options.to_hash
17
+ @args = args
18
+ logger.debug "[CLI] options: #{@options.inspect}"
19
+ logger.debug "[CLI] args: #{@args.inspect}"
20
+ end
21
+
22
+ private
23
+
24
+ def client(&block)
25
+ EM.run {
26
+ yield
27
+ Signal.trap('INT') { EM.stop; exit }
28
+ Signal.trap('TERM') { EM.stop; exit }
29
+ }
30
+ end
31
+
32
+ def ask_for_credentials(email = nil)
33
+ if email.nil? || email !~ EMAIL_REGEX
34
+ puts "Forward requires an account on #{HighLine.color('forwardhq.com', :underline)}"
35
+ puts "Enter your email and password"
36
+ email = ask('email: ').chomp
37
+ end
38
+
39
+ password = ask('password: ') { |q| q.echo = false }.chomp
40
+
41
+ [email, password]
42
+ end
43
+
44
+ def print_help_and_exit!
45
+ puts @opts
46
+ exit
47
+ end
48
+
49
+ def validate(*validators)
50
+ validators.each do |validator|
51
+ validator = "validate_#{validator}"
52
+
53
+ if respond_to?(validator, true)
54
+ send(validator)
55
+ else
56
+ raise UnknownValidator, "Unable to find validator `#{validator}' in #{self.class.name}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,64 @@
1
+ module Forward
2
+ module Command
3
+ class Config < Base
4
+ CONFIGURABLE_SETTINGS = %w[auto_copy auto_open].freeze
5
+ TRUTHY_REGEX = /\A(?:true|t|yes|y|1)\z/i.freeze
6
+ FALESY_REGEX = /\A(?:false|f|no|n|0)\z/i.freeze
7
+
8
+ def set(*args)
9
+ @setting = args[0]
10
+ @value = args[1]
11
+
12
+ validate :setting, :value
13
+ config.load
14
+ config.send("#{@setting}=", @value)
15
+ config.write
16
+ exit_with_message "#{@setting} is now #{config.send(@setting)}"
17
+ end
18
+
19
+ def unset(*args)
20
+ @setting = args.first
21
+
22
+ validate :setting
23
+ config.load
24
+ default_value = config.set_default!(@setting)
25
+ config.write
26
+ exit_with_message "#{@setting} is now set to the default `#{default_value}'"
27
+ end
28
+
29
+ def get(*args)
30
+ @setting = args.first
31
+
32
+ validate :setting
33
+ config.load
34
+ exit_with_message "#{@setting} is currently #{config.send(@setting)}"
35
+ end
36
+
37
+ private
38
+
39
+ def validate_value
40
+ @value = @value.dup.strip
41
+
42
+ case @setting
43
+ when 'auto_copy'
44
+ booleanize_value
45
+ when 'auto_open'
46
+ booleanize_value
47
+ end
48
+ end
49
+
50
+ def booleanize_value
51
+ return @value = true if @value =~ TRUTHY_REGEX
52
+ return @value = false if @value =~ FALESY_REGEX
53
+
54
+ raise ValidationError, "#{@setting} cannot be set to `#{@value}'"
55
+ end
56
+
57
+ def validate_setting
58
+ unless CONFIGURABLE_SETTINGS.include?(@setting)
59
+ raise ValidationError, "`#{@setting}' is an unknown setting"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end