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,157 @@
1
+ require 'erb'
2
+
3
+ module Forward
4
+ module Static
5
+
6
+ class TemplateContext
7
+ FILESIZE_FORMAT = [
8
+ ['%.1fT', 1 << 40],
9
+ ['%.1fG', 1 << 30],
10
+ ['%.1fM', 1 << 20],
11
+ ['%.1fK', 1 << 10],
12
+ ]
13
+
14
+
15
+ def initialize(root, path_info)
16
+ @root = root
17
+ @project_name = File.basename(root)
18
+ @path_info = path_info
19
+ @path = File.join(root, path_info)
20
+ end
21
+
22
+ def path_components
23
+ @path_info.split('/')[1..-1]
24
+ end
25
+
26
+ def files
27
+ _files = []
28
+
29
+ Dir["#{@path}*"].sort.each do |path|
30
+ _stat = stat(path)
31
+ next if _stat.nil?
32
+
33
+ basename = File.basename(path)
34
+ url = path.sub(@root, '')
35
+ ext = File.extname(path)
36
+ size = _stat.size
37
+ type = _stat.directory? ? 'directory' : Rack::Mime.mime_type(ext)
38
+ size = _stat.directory? ? '-' : filesize_format(size)
39
+
40
+ if _stat.directory?
41
+ url << '/'
42
+ basename << '/'
43
+ end
44
+
45
+ _files << {
46
+ url: url,
47
+ basename: basename,
48
+ size: size,
49
+ type: type,
50
+ }
51
+ end
52
+
53
+ _files
54
+ end
55
+
56
+ def get_binding
57
+ binding
58
+ end
59
+
60
+ private
61
+
62
+ def stat(path)
63
+ File.stat(path)
64
+ rescue Errno::ENOENT, Errno::ELOOP
65
+ nil
66
+ end
67
+
68
+ def filesize_format(int)
69
+ FILESIZE_FORMAT.each do |format, size|
70
+ return format % (int.to_f / size) if int >= size
71
+ end
72
+
73
+ int.to_s + 'B'
74
+ end
75
+ end
76
+
77
+ class App
78
+ attr_reader :files
79
+ attr_accessor :root
80
+ attr_accessor :path
81
+
82
+ def initialize(root, app = nil)
83
+ @root = File.expand_path(root)
84
+ @app = app || Rack::File.new(@root)
85
+ @listing_view = File.read(File.expand_path('../directory.erb', __FILE__))
86
+ end
87
+
88
+ def call(env)
89
+ dup._call(env)
90
+ end
91
+
92
+ def _call(env)
93
+ @env = env
94
+ @path_info = Rack::Utils.unescape(env['PATH_INFO'])
95
+
96
+ if forbidden?
97
+ render_404
98
+ else
99
+ @path = File.join(@root, @path_info)
100
+ process
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def forbidden?
107
+ @path_info =~ /\.\./
108
+ end
109
+
110
+ def render_directory_listing
111
+ context = TemplateContext.new(@root, @path_info)
112
+ body = ERB.new(@listing_view).result(context.get_binding)
113
+
114
+ [ 200, {'Content-Type' => 'text/html; charset=utf-8'}, [body] ]
115
+ end
116
+
117
+ def has_index?
118
+ File.exist?(File.join(@path, 'index.html'))
119
+ end
120
+
121
+ def process
122
+ return render_404 unless File.exist?(@path)
123
+
124
+ @stat = File.stat(@path)
125
+
126
+ raise Errno::ENOENT, 'No such file or directory' unless @stat.readable?
127
+
128
+ if @stat.directory?
129
+ return render_404 unless @path.end_with?('/')
130
+ return render_directory_listing unless has_index?
131
+
132
+ @env['PATH_INFO'] << 'index.html'
133
+ end
134
+
135
+ Rack::File.new(@root).call(@env)
136
+
137
+ rescue Errno::ENOENT, Errno::ELOOP => e
138
+ Forward.logger.debug e
139
+ Forward.logger.debug e.message
140
+ render_404
141
+ end
142
+
143
+
144
+ def render_404
145
+ body = "Not Found: #{@path_info}\n"
146
+ headers = {
147
+ "Content-Type" => "text/plain",
148
+ "Content-Length" => Rack::Utils.bytesize(body).to_s,
149
+ "X-Cascade" => "pass"
150
+ }
151
+
152
+ [404, headers, [body]]
153
+ end
154
+
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,142 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Directory Listing: <%= @project_name%><%= @path_info %></title>
5
+ <meta http-equiv="content-kind" content="text/html; charset=utf-8">
6
+ <link rel="stylesheet" href="https://assets.50east.co/v1.0/css/mark.css">
7
+ <link rel="stylesheet" href="https://assets.50east.co/v1.0/css/picons.css">
8
+ <link rel="stylesheet" href="https://assets.50east.co/v1.0/css/core.css">
9
+ <style kind="text/css">
10
+ h1 {
11
+ padding-left: 3rem;
12
+ text-indent: -1rem;
13
+ font-size: 1rem;
14
+ color: #999;
15
+ cursor: default;
16
+ }
17
+
18
+ h1 .root {
19
+ color: #222;
20
+ font-weight: 600;
21
+ }
22
+
23
+ h1 a {
24
+ color: inherit;
25
+ white-space: nowrap;
26
+ }
27
+
28
+ a {
29
+ color: #222;
30
+ }
31
+
32
+ a:hover {
33
+ color: #27c166 !important;
34
+ }
35
+
36
+ table {
37
+ max-width: 35rem;
38
+ width: 100%;
39
+ }
40
+
41
+ td,
42
+ th {
43
+ padding: 0.25rem 0 0.5rem;
44
+ }
45
+
46
+ th {
47
+ text-align: left;
48
+ padding-bottom: 1rem;
49
+ color: #ccc;
50
+ }
51
+
52
+ td.kind,
53
+ td.size {
54
+ color: #555;
55
+ }
56
+
57
+ .size {
58
+ text-align: right;
59
+ padding-right: 1rem;
60
+ }
61
+
62
+ td.name {
63
+ position: relative;
64
+ font-weight: 500;
65
+ }
66
+
67
+ .file-icon {
68
+ position: absolute;
69
+ top: 8px; left: -33px;
70
+ width: 20px;
71
+ height: 20px;
72
+ }
73
+
74
+ *[data-type="file"] .file-icon {
75
+ background-position: 0% -0.5%;
76
+ }
77
+
78
+ *[data-type="directory"] .file-icon {
79
+ top: 10px;
80
+ left: -34px;
81
+ }
82
+
83
+ footer p {
84
+ text-align: left !important;
85
+ }
86
+
87
+ footer a {
88
+ color: #27c166 !important;
89
+ }
90
+
91
+ footer a:hover {
92
+ border-bottom: 1px solid #ccc;
93
+ }
94
+ </style>
95
+ </head>
96
+
97
+ <body>
98
+ <div class="page">
99
+ <h1>
100
+ <a href="/" class="root"><%= @project_name %></a>
101
+ <% if path_components%>
102
+ <% path_components.each_with_index do |part, i| %>
103
+ /&nbsp;<a href="/<%= path_components[0..i].join('/') %>/"><%= part %></a>
104
+ <% end %>
105
+ <% end %>
106
+ </h1>
107
+
108
+ <section>
109
+ <table>
110
+ <thead>
111
+ <tr>
112
+ <th class="name">Name</th>
113
+ <th class="size">Size</th>
114
+ <th class="kind">Kind</th>
115
+ </tr>
116
+ </thead>
117
+ <tbody>
118
+ <% files.each do |file| %>
119
+ <tr>
120
+ <td class="name"><div class="file-icon"></div><a href="<%= file[:url] %>"><%= file[:basename] %></a></td><td class="size"><%= file[:size] %></td><td class="kind"><%= file[:type] %></td>
121
+ </tr>
122
+ <% end %>
123
+ </tbody>
124
+ </table>
125
+ </section>
126
+ <footer>
127
+ <p>
128
+ Brought to you by <a href="https://forwardhq.com/">Forward</a>
129
+ </p>
130
+ </footer>
131
+ </div>
132
+ <script src="https://assets.50east.co/v1.0/js/core.js"></script>
133
+ <script>
134
+ $('td.name').each(function(){
135
+ var filename = $.trim($(this).text());
136
+ var row = $(this).closest('tr');
137
+
138
+ row.attr('data-type', FileTypes.getTypeForFilename(filename));
139
+ });
140
+ </script>
141
+ </body>
142
+ </html>
@@ -1,6 +1,6 @@
1
1
  module Forward
2
2
  class Tunnel
3
- CHECK_INTERVAL = 7
3
+ include Common
4
4
 
5
5
  # The Tunnel resource ID
6
6
  attr_reader :id
@@ -8,60 +8,122 @@ module Forward
8
8
  attr_reader :subdomain
9
9
  # The CNAME for the Tunnel
10
10
  attr_reader :cname
11
- # The host
11
+ # The host to forward requests to
12
12
  attr_reader :host
13
- # The vhost
14
- attr_reader :vhost
15
- # The hostport (local port)
16
- attr_reader :hostport
17
- # The remote port
13
+ # The port to forward requests to
18
14
  attr_reader :port
15
+ # Authentication for tunnel
16
+ attr_reader :username
17
+ attr_reader :password
18
+ attr_reader :no_auth
19
+ # The public hostname/subdomain url for the tunnel
20
+ attr_reader :url
19
21
  # The tunneler host
20
22
  attr_reader :tunneler
21
23
  # The timeout
22
24
  attr_reader :timeout
23
- # The amount of time in seconds the Tunnel has be inactive for
24
- attr_accessor :inactive_for
25
+
26
+ attr_reader :socket
27
+ attr_reader :socket_url
28
+ attr_reader :requests
29
+
30
+ attr_accessor :last_active_at
25
31
 
26
32
  # Initializes a Tunnel instance for the Client and requests a tunnel from
27
33
  # API.
28
34
  #
29
35
  # client - The Client instance.
30
- def initialize(options = {})
31
- @host = options[:host]
32
- @response = Forward::Api::Tunnel.create(options)
33
- @id = @response[:_id]
34
- @subdomain = @response[:subdomain]
35
- @cname = @response[:cname]
36
- @vhost = @response[:vhost]
37
- @hostport = @response[:hostport]
38
- @port = @response[:port]
39
- @tunneler = @response[:tunneler_public]
40
- @timeout = @response[:timeout]
41
- @inactive_for = 0
36
+ def initialize(attributes = {})
37
+ logger.debug "[tunnel] initializing with: #{attributes.inspect}"
38
+ @attributes = attributes
39
+ @id = attributes[:id]
40
+ @host = attributes[:vhost]
41
+ @port = attributes[:hostport]
42
+ @subdomain_prefix = attributes[:subdomain_prefix]
43
+ @username = attributes[:username]
44
+ @password = attributes[:password]
45
+ @no_auth = attributes[:no_auth]
46
+ @static_path = attributes[:static_path]
47
+ @cname = attributes[:cname]
48
+ @timeout = attributes[:timeout]
49
+ @tunneler = attributes[:tunneler]
50
+ @url = attributes[:url]
51
+ @requests = {}
52
+ @open = false
53
+ @socket = Socket.new(self)
54
+ end
55
+
56
+ def ready!
57
+ logger.debug '[tunnel] opened successfully'
58
+ @open = true
59
+
60
+ copy_url_to_clipboard
61
+ open_url_in_browser
62
+ display_ready_message
63
+ end
64
+
65
+ def open?
66
+ @open
42
67
  end
43
68
 
44
- def poll_status
45
- Thread.new {
46
- loop do
47
- if @timeout && !@timeout.zero? && @inactive_for > @timeout
48
- Forward.log.debug("Session closing due to inactivity `#{@inactive_for}' seconds")
49
- Client.cleanup_and_exit!("Tunnel has been inactive for #{@inactive_for} seconds, exiting...")
50
- elsif Forward::Api::Tunnel.show(@id).nil?
51
- Client.current.tunnel = nil
52
- Forward.log.debug("Tunnel destroyed, closing session")
53
- Client.cleanup_and_exit!
54
- else
55
- sleep CHECK_INTERVAL
56
- end
57
-
58
- @inactive_for += CHECK_INTERVAL
69
+ def destroy(&block)
70
+ API::Tunnel.destroy(id, &block)
71
+ end
72
+
73
+ def authority
74
+ @authority ||= begin
75
+ _authority = "#{host}"
76
+ _authority << ":#{port}" unless port == 80
77
+
78
+ _authority
79
+ end
80
+ end
81
+
82
+ def track_activity!
83
+ self.last_active_at = Time.now.to_i
84
+ end
85
+
86
+ private
87
+
88
+ def display_ready_message
89
+ source = static? ? "Directory `#{File.expand_path(@static_path).split('/').last}'" : authority
90
+ message = "#{source} is now available at: #{HighLine.color(url, :underline)}"
91
+
92
+ message << " and #{HighLine.color("http://#{cname}", :underline)}" if cname
93
+
94
+ puts "#{message}\n\nCtrl-C to stop forwarding"
95
+ end
96
+
97
+ def copy_url_to_clipboard
98
+ return unless config.auto_copy?
99
+
100
+ if windows?
101
+ begin
102
+ require 'ffi'
103
+ rescue LoadError
104
+ puts "The FFI gem is required to copy your url to the clipboard, you can install it with `gem install ffi'"
105
+ return
59
106
  end
60
- }
107
+ end
108
+
109
+ Clipboard.copy(url)
110
+ end
111
+
112
+ def open_url_in_browser
113
+ return unless config.auto_open?
114
+
115
+ case os
116
+ when :windows
117
+ %x[start #{url}]
118
+ when :osx
119
+ %x[open #{url}]
120
+ when :unix
121
+ %x[xdg-open #{url}]
122
+ end
61
123
  end
62
-
63
- def active?
64
- @active
124
+
125
+ def static?
126
+ !@static_path.nil?
65
127
  end
66
128
 
67
129
  end
@@ -1,3 +1,3 @@
1
1
  module Forward
2
- VERSION = '0.3.3'
2
+ VERSION = '1.0.0'
3
3
  end
data/lib/forward.rb CHANGED
@@ -1,108 +1,125 @@
1
1
  require 'base64'
2
2
  require 'json'
3
3
  require 'logger'
4
- require 'openssl'
5
- require 'optparse'
6
4
  require 'rbconfig'
7
- require 'stringio'
8
- require 'uri'
5
+ require 'fileutils'
6
+ require 'yaml'
9
7
 
8
+ require 'slop'
10
9
  require 'highline/import'
11
- require 'net/ssh'
10
+ require 'eventmachine'
11
+ require 'em-http-request'
12
+ require 'faye/websocket'
13
+ require 'clipboard'
12
14
 
13
15
  require 'forward/core_extensions'
14
16
 
15
- require 'forward/error'
17
+ require 'forward/common'
16
18
  require 'forward/api'
17
19
  require 'forward/config'
20
+ require 'forward/request'
21
+ require 'forward/socket'
18
22
  require 'forward/tunnel'
19
- require 'forward/client'
20
23
  require 'forward/cli'
21
24
  require 'forward/version'
22
25
 
23
26
  module Forward
24
- DEFAULT_SSL = true
25
- DEFAULT_SSH_PORT = 22
26
- DEFAULT_SSH_USER = 'tunnel'
27
-
28
- # Returns either a ssh user set in the environment or a set default.
27
+ class Error < StandardError; end
28
+ # An error occurred with the CLI
29
+ class CLIError < Error; end
30
+ # An error occurred with the Client
31
+ class ClientError < Error; end
32
+ # An error occurred with the Config
33
+ class ConfigError < Error; end
34
+ # An error occurred with the Tunnel
35
+ class TunnelError < Error; end
36
+
37
+ SUPPORT_EMAIL = 'support@forwardhq.com'.freeze
38
+
39
+ # Helper for determining the host OS
29
40
  #
30
- # Returns a String containing the ssh user.
31
- def self.ssh_user
32
- ENV['FORWARD_SSH_USER'] || DEFAULT_SSH_USER
33
- end
34
-
35
- # Returns either a ssh port set in the environment or a set default.
41
+ # Returns simplified and symbolized host os name
42
+ def self.os
43
+ @os ||= begin
44
+ case RbConfig::CONFIG['host_os']
45
+ when /mswin|mingw|cygwin/
46
+ :windows
47
+ when /darwin/
48
+ :osx
49
+ when /linux|bsd/
50
+ :unix
51
+ else
52
+ :unknown
53
+ end
54
+ end
55
+ end
56
+
57
+ # Helper to determine if host OS is windows
36
58
  #
37
- # Returns a String containing the ssh port.
38
- def self.ssh_port
39
- ENV['FORWARD_SSH_PORT'] || DEFAULT_SSH_PORT
40
- end
41
-
59
+ # Returns Boolean
42
60
  def self.windows?
43
- RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
61
+ @windows ||= os == :windows
44
62
  end
45
63
 
46
- def self.config=(config)
47
- @config = config
64
+ # Setter for the current Client
65
+ #
66
+ # Returns the new Forawrd::Client instance
67
+ def self.tunnel=(tunnel)
68
+ @tunnel = tunnel
48
69
  end
49
70
 
50
- def self.config
51
- @config
71
+ # Getter for the current Client
72
+ #
73
+ # Returns a Forward::Client instance
74
+ def self.tunnel
75
+ @tunnel
52
76
  end
53
77
 
54
- def self.client=(client)
55
- @client = client
78
+ # Helper method for making forward quiet (silence most output)
79
+ def self.quiet!
80
+ @quiet = true
56
81
  end
57
82
 
58
- def self.client
59
- @client
83
+ # Helper method for making forward quiet (silence most output)
84
+ def self.quiet?
85
+ @quiet == true
60
86
  end
61
87
 
88
+ # Helper method for setting the log level to debug
62
89
  def self.debug!
63
- @logdev = STDOUT
64
- @debug = true
65
- log.level = Logger::DEBUG
66
- end
67
-
68
- def self.debug?
69
- @debug
90
+ logger.level = Logger::DEBUG
70
91
  end
71
92
 
72
- def self.stringio_log
73
- @stringio_log ||= StringIO.new
74
- end
75
-
76
- def self.debug_remotely!
77
- @logdev = stringio_log
78
- @debug = true
79
- @debug_remotely = true
80
- log.level = Logger::DEBUG
81
- end
82
-
83
- def self.debug_remotely?
84
- @debug_remotely ||= false
85
- end
86
-
87
- def self.logdev
88
- @logdev ||= (windows? ? 'NUL:' : '/dev/null')
93
+ # Setter for the logger
94
+ #
95
+ # Returns the new Logger instance
96
+ def self.logger=(logger)
97
+ @logger ||= logger
89
98
  end
90
99
 
100
+ # Getter for the logger
101
+ #
102
+ # Returns the new or cached Logger instance
91
103
  def self.logger
92
- @log ||= Logger.new(logdev)
93
- end
104
+ @logger ||= begin
105
+ _logger = Logger.new(STDOUT)
106
+ _logger.level = Logger::WARN
107
+ _logger.formatter = proc do |severity, datetime, progname, msg|
108
+ "#{severity} - [#{datetime.strftime('%H:%M:%S')}] #{msg}\n"
109
+ end
94
110
 
95
- def self.log
96
- logger
111
+ _logger
112
+ end
97
113
  end
98
114
 
99
- # Returns a string representing a detailed client version.
115
+ # Returns a string representing a detailed client version
100
116
  #
101
- # Returns a String representing the client.
117
+ # Returns a String representing the client
102
118
  def self.client_string
103
119
  os = RbConfig::CONFIG['host_os']
104
120
  engine = defined?(RUBY_ENGINE) ? RUBY_ENGINE : 'ruby'
105
121
 
106
- "[#{os}]::[#{engine}-#{RUBY_VERSION}]::[ruby-client-#{Forward::VERSION}]"
122
+ # TODO: make os version more friendly
123
+ "(#{engine}-#{RUBY_VERSION})(#{os})(v#{Forward::VERSION})"
107
124
  end
108
125
  end