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