terminalwire 0.1.1 → 0.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fec6bb413813bdaa761403bca8fcaebb92b884a448cfe3b5cba7fc56e3589b0
4
- data.tar.gz: 319498815fe666a7266f1dcfd2648af1e15477f7f42f22cd8661f2c42bda45c9
3
+ metadata.gz: 8eacca5d9e410385fee49a9115cdc9d92ac1276b0666af88b8c343f11306a05f
4
+ data.tar.gz: c94050aef56399743f202c13c3813bb66a05cfffd2333ea9431bccded43234f2
5
5
  SHA512:
6
- metadata.gz: bbc8104d13cd4acf420c0c5873b4f1df0749ea94768c91a9ceb6100d34c9fe6da0cd62bf4bb717a5c5e5fdc4f3d7395db0a0cd44b5d88cb1e18006dab6c3f791
7
- data.tar.gz: daeeb21577a26536a4daad6c61b07f1b6a96a23ea52adfbc4eb25881b2948a1010f81ffc6b4958eb92befc0bae348b18cbecf4aeecea813c0b84b18ea0e33ace
6
+ metadata.gz: 2a25a33121a603fdcb11c5c664d17772f999d4a379379390ad578ed434fa75428f45deda6d2023e490ecf2bedfad61cf58a9fd4d7c880e17fff714978075159c
7
+ data.tar.gz: 76e5bf2edd88ad0ca092328514ebce7bc22ee64ff13aa4ebf83c77319849fe2564515cc89bf27364f3135ca2456143869066ca6eaef70daae15dabedb006b3e3
@@ -2,7 +2,7 @@
2
2
  require_relative "../lib/terminalwire.rb"
3
3
 
4
4
  begin
5
- Terminalwire::Client::Binary.execute
5
+ Terminalwire::Client::Exec.start
6
6
  rescue Terminalwire::Error => e
7
7
  puts e.message
8
8
  exit 1
@@ -17,7 +17,7 @@ class Terminalwire::InstallGenerator < Rails::Generators::Base
17
17
  def add_route
18
18
  route <<~ROUTE
19
19
  match "/terminal",
20
- to: Terminalwire::WebSocket::ThorServer.new(ApplicationTerminal),
20
+ to: Terminalwire::Server::Thor.new(ApplicationTerminal),
21
21
  via: [:get, :connect]
22
22
  ROUTE
23
23
  end
@@ -15,7 +15,7 @@ class ApplicationTerminal < Thor
15
15
  email = gets
16
16
 
17
17
  print "Password: "
18
- password = getch
18
+ password = getpass
19
19
 
20
20
  if self.current_user = User.authenticate(email, password)
21
21
  puts "Successfully logged in as #{user.email}."
@@ -24,9 +24,27 @@ class ApplicationTerminal < Thor
24
24
  end
25
25
  end
26
26
 
27
+ desc "whoami", "Displays current user information."
28
+ def whoami
29
+ if self.current_user
30
+ puts "Logged in as #{user.email}."
31
+ else
32
+ puts "Not logged in. Run `#{self.class.basename} login` to login."
33
+ end
34
+ end
35
+
36
+ desc "logout", "Logout of your account"
37
+ def logout
38
+ session.reset
39
+ puts "Successfully logged out."
40
+ end
41
+
27
42
  private
28
43
 
29
44
  def current_user=(user)
45
+ # The Session object is a hash-like object that encrypts and signs a hash that's
46
+ # stored on the client's file sytem. Conceptually, it's similar to Rails signed
47
+ # and encrypted client-side cookies.
30
48
  session["user_id"] = user.id
31
49
  end
32
50
 
@@ -0,0 +1,32 @@
1
+ require 'msgpack'
2
+
3
+ module Terminalwire
4
+ class Adapter
5
+ include Logging
6
+
7
+ attr_reader :transport
8
+
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ def write(data)
14
+ logger.debug "Adapter: Sending #{data.inspect}"
15
+ packed_data = MessagePack.pack(data, symbolize_keys: true)
16
+ @transport.write(packed_data)
17
+ end
18
+
19
+ def recv
20
+ logger.debug "Adapter: Reading"
21
+ packed_data = @transport.read
22
+ return nil if packed_data.nil?
23
+ data = MessagePack.unpack(packed_data, symbolize_keys: true)
24
+ logger.debug "Adapter: Received #{data.inspect}"
25
+ data
26
+ end
27
+
28
+ def close
29
+ @transport.close
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ module Terminalwire::Client
2
+ class Entitlement
3
+ class Paths
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @permitted = []
8
+ end
9
+
10
+ def each(&)
11
+ @permitted.each(&)
12
+ end
13
+
14
+ def permit(path)
15
+ @permitted.append Pathname.new(path).expand_path
16
+ end
17
+
18
+ def permitted?(path)
19
+ @permitted.find { |pattern| matches?(permitted: pattern, path:) }
20
+ end
21
+
22
+ def serialize
23
+ @permitted.to_a.map(&:to_s)
24
+ end
25
+
26
+ private
27
+ def matches?(permitted:, path:)
28
+ # This MUST be done via File.fnmatch because Pathname#fnmatch does not work. If you
29
+ # try changing this 🚨 YOU MAY CIRCUMVENT THE SECURITY MEASURES IN PLACE. 🚨
30
+ File.fnmatch permitted.to_s, File.expand_path(path), File::FNM_PATHNAME
31
+ end
32
+ end
33
+
34
+ class Schemes
35
+ include Enumerable
36
+
37
+ def initialize
38
+ @permitted = Set.new
39
+ end
40
+
41
+ def each(&)
42
+ @permitted.each(&)
43
+ end
44
+
45
+ def permit(scheme)
46
+ @permitted << scheme.to_s
47
+ end
48
+
49
+ def permitted?(url)
50
+ include? URI(url).scheme
51
+ end
52
+
53
+ def serialize
54
+ @permitted.to_a.map(&:to_s)
55
+ end
56
+ end
57
+
58
+ attr_reader :paths, :authority, :schemes
59
+
60
+ def initialize(authority:)
61
+ @authority = authority
62
+ @paths = Paths.new
63
+
64
+ # Permit the domain directory. This is necessary for basic operation of the client.
65
+ @paths.permit storage_path
66
+ @paths.permit storage_pattern
67
+
68
+ @schemes = Schemes.new
69
+ # Permit http & https by default.
70
+ @schemes.permit "http"
71
+ @schemes.permit "https"
72
+ end
73
+
74
+ def domain_path
75
+ Pathname.new("~/.terminalwire/authorities/#{@authority}").expand_path
76
+ end
77
+
78
+ def storage_path
79
+ domain_path.join("storage")
80
+ end
81
+
82
+ def storage_pattern
83
+ storage_path.join("**/*")
84
+ end
85
+
86
+ def serialize
87
+ {
88
+ authority: @authority,
89
+ schemes: @schemes.serialize,
90
+ paths: @paths.serialize,
91
+ storage_path: storage_path.to_s,
92
+ }
93
+ end
94
+
95
+ def self.from_url(url)
96
+ # I had to lift this from URI::HTTP because `ws://` doesn't
97
+ # have an authority method.
98
+ authority = if url.port == url.default_port
99
+ url.host
100
+ else
101
+ "#{url.host}:#{url.port}"
102
+ end
103
+
104
+ new authority:
105
+ end
106
+ end
107
+ end
@@ -3,7 +3,7 @@ require "yaml"
3
3
  require "uri"
4
4
 
5
5
  module Terminalwire::Client
6
- class Binary
6
+ class Exec
7
7
  attr_reader :arguments, :path, :configuration, :url
8
8
 
9
9
  def initialize(path:, arguments:)
@@ -23,7 +23,7 @@ module Terminalwire::Client
23
23
  Terminalwire::Client.websocket(url:, arguments:)
24
24
  end
25
25
 
26
- def self.execute
26
+ def self.start
27
27
  case ARGV
28
28
  in path, *arguments
29
29
  new(path:, arguments:).start
@@ -0,0 +1,154 @@
1
+ module Terminalwire::Client::Resource
2
+ # Dispatches messages from the Client::Handler to the appropriate resource.
3
+ class Handler
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @resources = {}
8
+ yield self if block_given?
9
+ end
10
+
11
+ def each(&block)
12
+ @resources.values.each(&block)
13
+ end
14
+
15
+ def add(resource)
16
+ # Detect if the resource is already registered and throw an error
17
+ if @resources.key?(resource.name)
18
+ raise "Resource #{resource.name} already registered"
19
+ else
20
+ @resources[resource.name] = resource
21
+ end
22
+ end
23
+ alias :<< :add
24
+
25
+ def dispatch(**message)
26
+ case message
27
+ in { event:, action:, name:, command:, parameters: }
28
+ resource = @resources.fetch(name)
29
+ resource.command(command, **parameters)
30
+ end
31
+ end
32
+ end
33
+
34
+ # Dispatcher, security, and response macros for resources.
35
+ class Base < Terminalwire::Resource::Base
36
+ def initialize(*, entitlement:, **)
37
+ super(*, **)
38
+ @entitlement = entitlement
39
+ connect
40
+ end
41
+
42
+ def command(command, **parameters)
43
+ begin
44
+ if permit(command, **parameters)
45
+ succeed self.public_send(command, **parameters)
46
+ else
47
+ fail "Client denied #{command}", command:, parameters:
48
+ end
49
+ rescue => e
50
+ fail e.message, command:, parameters:
51
+ raise
52
+ end
53
+ end
54
+
55
+ def permit(...)
56
+ false
57
+ end
58
+ end
59
+
60
+ class STDOUT < Base
61
+ def connect
62
+ @io = $stdout
63
+ end
64
+
65
+ def print(data:)
66
+ @io.print(data)
67
+ end
68
+
69
+ def print_line(data:)
70
+ @io.puts(data)
71
+ end
72
+
73
+ def permit(...)
74
+ true
75
+ end
76
+ end
77
+
78
+ class STDERR < STDOUT
79
+ def connect
80
+ @io = $stderr
81
+ end
82
+ end
83
+
84
+ class STDIN < Base
85
+ def connect
86
+ @io = $stdin
87
+ end
88
+
89
+ def read_line
90
+ @io.gets
91
+ end
92
+
93
+ def read_password
94
+ @io.getpass
95
+ end
96
+
97
+ def permit(...)
98
+ true
99
+ end
100
+ end
101
+
102
+ class File < Base
103
+ File = ::File
104
+
105
+ # Ensure the default file mode is read/write for owner only. This ensures
106
+ # that if the server tries uploading an executable file, it won't be when it
107
+ # lands on the client.
108
+ #
109
+ # Eventually we'll move this into entitlements so the client can set maximum
110
+ # permissions for files and directories.
111
+ FILE_PERMISSIONS = 0o600 # rw-------
112
+
113
+ def read(path:)
114
+ File.read File.expand_path(path)
115
+ end
116
+
117
+ def write(path:, content:)
118
+ File.open(File.expand_path(path), "w", FILE_PERMISSIONS) { |f| f.write(content) }
119
+ end
120
+
121
+ def append(path:, content:)
122
+ File.open(File.expand_path(path), "a", FILE_PERMISSIONS) { |f| f.write(content) }
123
+ end
124
+
125
+ def mkdir(path:)
126
+ FileUtils.mkdir_p(File.expand_path(path))
127
+ end
128
+
129
+ def delete(path:)
130
+ File.delete(File.expand_path(path))
131
+ end
132
+
133
+ def exist(path:)
134
+ File.exist? File.expand_path(path)
135
+ end
136
+
137
+ def permit(command, path:, **)
138
+ @entitlement.paths.permitted? path
139
+ end
140
+ end
141
+
142
+ class Browser < Base
143
+ def permit(command, url:, **)
144
+ @entitlement.schemes.permitted? url
145
+ end
146
+
147
+ def launch(url:)
148
+ Launchy.open(URI(url))
149
+ # TODO: This is a hack to get the `respond` method to work.
150
+ # Maybe explicitly call a `suceed` and `fail` method?
151
+ nil
152
+ end
153
+ end
154
+ end
@@ -1,199 +1,76 @@
1
+ require 'fileutils'
2
+ require 'launchy'
3
+ require 'io/console'
4
+
1
5
  module Terminalwire
2
6
  module Client
3
- module Resource
4
- class IO < Terminalwire::Resource::Base
5
- def dispatch(action, data)
6
- if @device.respond_to?(action)
7
- respond @device.public_send(action, data)
8
- else
9
- raise "Unknown action #{action} for device ID #{@id}"
10
- end
11
- end
12
- end
13
-
14
- class STDOUT < IO
15
- def connect
16
- @device = $stdout
17
- end
18
- end
19
-
20
- class STDIN < IO
21
- def connect
22
- @device = $stdin
23
- end
24
-
25
- def dispatch(action, data)
26
- respond case action
27
- when "puts"
28
- @device.puts(data)
29
- when "gets"
30
- @device.gets
31
- when "getpass"
32
- @device.getpass
33
- end
34
- end
35
- end
36
-
37
- class STDERR < IO
38
- def connect
39
- @device = $stderr
40
- end
41
- end
42
-
43
- class File < Terminalwire::Resource::Base
44
- def connect
45
- @files = {}
46
- end
47
-
48
- def dispatch(action, data)
49
- respond case action
50
- when "read"
51
- read_file(data)
52
- when "write"
53
- write_file(data.fetch(:path), data.fetch(:content))
54
- when "append"
55
- append_to_file(data.fetch(:path), data.fetch(:content))
56
- when "mkdir"
57
- mkdir(data.fetch(:path))
58
- when "exist"
59
- exist?(data.fetch(:path))
60
- else
61
- raise "Unknown action #{action} for file device"
62
- end
63
- end
64
-
65
- def mkdir(path)
66
- FileUtils.mkdir_p(::File.expand_path(path))
67
- end
68
-
69
- def exist?(path)
70
- ::File.exist? ::File.expand_path(path)
71
- end
72
-
73
- def read_file(path)
74
- ::File.read ::File.expand_path(path)
75
- end
76
-
77
- def write_file(path, content)
78
- ::File.open(::File.expand_path(path), "w") { |f| f.write(content) }
79
- end
80
-
81
- def append_to_file(path, content)
82
- ::File.open(::File.expand_path(path), "a") { |f| f.write(content) }
83
- end
84
-
85
- def disconnect
86
- @files.clear
87
- end
88
- end
89
-
90
- class Browser < Terminalwire::Resource::Base
91
- def dispatch(action, data)
92
- respond case action
93
- when "launch"
94
- Launchy.open(data)
95
- "Launched browser with URL: #{data}"
96
- else
97
- raise "Unknown action #{action} for browser device"
98
- end
99
- end
100
- end
101
- end
102
-
103
- class ResourceMapper
104
- def initialize(connection, resources)
105
- @connection = connection
106
- @resources = resources
107
- @devices = Hash.new { |h,k| h[Integer(k)] }
108
- end
109
-
110
- def connect_device(id, type)
111
- klass = @resources.find(type)
112
- if klass
113
- device = klass.new(id, @connection)
114
- device.connect
115
- @devices[id] = device
116
- @connection.write(event: "device", action: "connect", status: "success", id: id, type: type)
117
- else
118
- @connection.write(event: "device", action: "connect", status: "failure", id: id, type: type, message: "Unknown device type")
119
- end
120
- end
121
-
122
- def dispatch(id, action, data)
123
- device = @devices[id]
124
- if device
125
- device.dispatch(action, data)
126
- else
127
- raise "Unknown device ID: #{id}"
128
- end
129
- end
130
-
131
- def disconnect_device(id)
132
- device = @devices.delete(id)
133
- device&.disconnect
134
- @connection.write(event: "device", action: "disconnect", id: id)
135
- end
136
- end
137
-
138
7
  class Handler
139
8
  VERSION = "0.1.0".freeze
140
9
 
141
10
  include Logging
142
11
 
143
- attr_reader :arguments, :program_name
12
+ attr_reader :adapter, :entitlement, :resources
13
+
14
+ def initialize(adapter, arguments: ARGV, program_name: $0, entitlement:)
15
+ @entitlement = entitlement
16
+ @adapter = adapter
17
+ @program_arguments = arguments
18
+ @program_name = program_name
144
19
 
145
- def initialize(connection, resources = self.class.resources, arguments: ARGV, program_name: $0)
146
- @connection = connection
147
- @resources = resources
148
- @arguments = arguments
20
+ @resources = Resource::Handler.new do |it|
21
+ it << Resource::STDOUT.new("stdout", @adapter, entitlement:)
22
+ it << Resource::STDIN.new("stdin", @adapter, entitlement:)
23
+ it << Resource::STDERR.new("stderr", @adapter, entitlement:)
24
+ it << Resource::Browser.new("browser", @adapter, entitlement:)
25
+ it << Resource::File.new("file", @adapter, entitlement:)
26
+ end
149
27
  end
150
28
 
151
29
  def connect
152
- @devices = ResourceMapper.new(@connection, @resources)
153
-
154
- @connection.write(event: "initialize", protocol: { version: VERSION }, arguments:, program_name:)
30
+ @adapter.write(event: "initialization",
31
+ protocol: { version: VERSION },
32
+ entitlement: @entitlement.serialize,
33
+ program: {
34
+ name: @program_name,
35
+ arguments: @program_arguments
36
+ })
155
37
 
156
38
  loop do
157
- handle @connection.recv
39
+ handle @adapter.recv
158
40
  end
159
41
  end
160
42
 
161
43
  def handle(message)
162
44
  case message
163
- in { event: "device", action: "connect", id:, type: }
164
- @devices.connect_device(id, type)
165
- in { event: "device", action: "command", id:, command:, data: }
166
- @devices.dispatch(id, command, data)
167
- in { event: "device", action: "disconnect", id: }
168
- @devices.disconnect_device(id)
45
+ in { event: "resource", action: "command", name:, parameters: }
46
+ @resources.dispatch(**message)
169
47
  in { event: "exit", status: }
170
48
  exit Integer(status)
171
49
  end
172
50
  end
173
-
174
- def self.resources
175
- ResourceRegistry.new.tap do |resources|
176
- resources << Client::Resource::STDOUT
177
- resources << Client::Resource::STDIN
178
- resources << Client::Resource::STDERR
179
- resources << Client::Resource::Browser
180
- resources << Client::Resource::File
181
- end
182
- end
183
51
  end
184
52
 
185
53
  def self.tcp(...)
186
54
  socket = TCPSocket.new(...)
187
55
  transport = Terminalwire::Transport::Socket.new(socket)
188
- connection = Terminalwire::Connection.new(transport)
189
- Terminalwire::Client::Handler.new(connection)
56
+ adapter = Terminalwire::Adapter.new(transport)
57
+ Terminalwire::Client::Handler.new(adapter)
190
58
  end
191
59
 
192
60
  def self.socket(...)
193
61
  socket = UNIXSocket.new(...)
194
62
  transport = Terminalwire::Transport::Socket.new(socket)
195
- connection = Terminalwire::Connection.new(transport)
196
- Terminalwire::Client::Handler.new(connection)
63
+ adapter = Terminalwire::Adapter.new(transport)
64
+ Terminalwire::Client::Handler.new(adapter)
65
+ end
66
+
67
+ # Extracted from HTTP. This is so we can
68
+ def self.authority(url)
69
+ if url.port == url.default_port
70
+ url.host
71
+ else
72
+ "#{url.host}:#{url.port}"
73
+ end
197
74
  end
198
75
 
199
76
  def self.websocket(url:, arguments: ARGV)
@@ -202,10 +79,11 @@ module Terminalwire
202
79
  Async do |task|
203
80
  endpoint = Async::HTTP::Endpoint.parse(url)
204
81
 
205
- Async::WebSocket::Client.connect(endpoint) do |connection|
206
- transport = Terminalwire::Transport::WebSocket.new(connection)
207
- connection = Terminalwire::Connection.new(transport)
208
- Terminalwire::Client::Handler.new(connection, arguments:).connect
82
+ Async::WebSocket::Client.connect(endpoint) do |adapter|
83
+ transport = Terminalwire::Transport::WebSocket.new(adapter)
84
+ adapter = Terminalwire::Adapter.new(transport)
85
+ entitlement = Entitlement.from_url(url)
86
+ Terminalwire::Client::Handler.new(adapter, arguments:, entitlement:).connect
209
87
  end
210
88
  end
211
89
  end
@@ -0,0 +1,8 @@
1
+ require 'logger'
2
+
3
+ module Terminalwire
4
+ module Logging
5
+ DEVICE = Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", "info"))
6
+ def logger = DEVICE
7
+ end
8
+ end