terminalwire 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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