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