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 +4 -4
- data/exe/terminalwire-exec +1 -1
- data/lib/generators/terminalwire/install/install_generator.rb +1 -1
- data/lib/generators/terminalwire/install/templates/application_terminal.rb.tt +19 -1
- data/lib/terminalwire/adapter.rb +32 -0
- data/lib/terminalwire/client/entitlement.rb +107 -0
- data/lib/terminalwire/client/{binary.rb → exec.rb} +2 -2
- data/lib/terminalwire/client/resource.rb +154 -0
- data/lib/terminalwire/client.rb +46 -168
- data/lib/terminalwire/logging.rb +8 -0
- data/lib/terminalwire/rails.rb +69 -0
- data/lib/terminalwire/server.rb +104 -111
- data/lib/terminalwire/thor.rb +21 -13
- data/lib/terminalwire/transport.rb +72 -10
- data/lib/terminalwire/version.rb +1 -1
- data/lib/terminalwire.rb +13 -108
- metadata +23 -4
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
require 'pathname'
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module Terminalwire::Rails
|
6
|
+
class Session
|
7
|
+
# JWT file name for the session file.
|
8
|
+
FILENAME = "session.jwt"
|
9
|
+
|
10
|
+
# Empty dictionary the user can stash all their session data into.
|
11
|
+
EMPTY_SESSION = {}.freeze
|
12
|
+
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
# Delegate `dig` and `fetch` to the `read` method
|
16
|
+
def_delegators :read,
|
17
|
+
:dig, :fetch, :[]
|
18
|
+
|
19
|
+
def initialize(context:, path: nil, secret_key: self.class.secret_key)
|
20
|
+
@context = context
|
21
|
+
@path = path || context.storage_path
|
22
|
+
@config_file_path = @path.join(FILENAME)
|
23
|
+
@secret_key = secret_key
|
24
|
+
|
25
|
+
ensure_file
|
26
|
+
end
|
27
|
+
|
28
|
+
def read
|
29
|
+
jwt_token = @context.file.read(@config_file_path)
|
30
|
+
decoded_data = JWT.decode(jwt_token, @secret_key, true, algorithm: 'HS256')
|
31
|
+
decoded_data[0] # JWT payload is the first element in the array
|
32
|
+
rescue JWT::DecodeError => e
|
33
|
+
raise "Invalid or tampered file: #{e.message}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def reset
|
37
|
+
@context.file.delete @config_file_path
|
38
|
+
end
|
39
|
+
|
40
|
+
def edit
|
41
|
+
config = read
|
42
|
+
yield config
|
43
|
+
write(config)
|
44
|
+
end
|
45
|
+
|
46
|
+
def []=(key, value)
|
47
|
+
edit { |config| config[key] = value }
|
48
|
+
end
|
49
|
+
|
50
|
+
def write(config)
|
51
|
+
token = JWT.encode(config, @secret_key, 'HS256')
|
52
|
+
@context.file.write(@config_file_path, token)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def ensure_file
|
58
|
+
return true if @context.file.exist? @config_file_path
|
59
|
+
# Create the path if it doesn't exist on the client.
|
60
|
+
@context.file.mkdir(@path) unless @context.file.exist?(@path)
|
61
|
+
# Write an empty configuration on initialization
|
62
|
+
write(EMPTY_SESSION)
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.secret_key
|
66
|
+
Rails.application.secret_key_base
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/lib/terminalwire/server.rb
CHANGED
@@ -1,144 +1,101 @@
|
|
1
1
|
module Terminalwire
|
2
2
|
module Server
|
3
3
|
module Resource
|
4
|
-
class
|
4
|
+
class Base < Terminalwire::Resource::Base
|
5
|
+
private
|
6
|
+
|
7
|
+
def command(command, **parameters)
|
8
|
+
@adapter.write(
|
9
|
+
event: "resource",
|
10
|
+
name: @name,
|
11
|
+
action: "command",
|
12
|
+
command: command,
|
13
|
+
parameters: parameters
|
14
|
+
)
|
15
|
+
@adapter.recv&.fetch(:response)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class STDOUT < Base
|
5
20
|
def puts(data)
|
6
|
-
command("
|
21
|
+
command("print_line", data: data)
|
7
22
|
end
|
8
23
|
|
9
24
|
def print(data)
|
10
25
|
command("print", data: data)
|
11
26
|
end
|
12
27
|
|
13
|
-
def gets
|
14
|
-
command("gets")
|
15
|
-
end
|
16
|
-
|
17
28
|
def flush
|
18
|
-
#
|
19
|
-
end
|
20
|
-
|
21
|
-
private
|
22
|
-
|
23
|
-
def command(command, data: nil)
|
24
|
-
@connection.write(event: "device", id: @id, action: "command", command: command, data: data)
|
25
|
-
@connection.recv&.fetch(:response)
|
29
|
+
# Do nothing
|
26
30
|
end
|
27
31
|
end
|
28
32
|
|
29
|
-
class
|
33
|
+
class STDERR < STDOUT
|
30
34
|
end
|
31
35
|
|
32
|
-
class STDIN <
|
36
|
+
class STDIN < Base
|
33
37
|
def getpass
|
34
|
-
command("
|
38
|
+
command("read_password")
|
35
39
|
end
|
36
|
-
end
|
37
40
|
|
38
|
-
|
41
|
+
def gets
|
42
|
+
command("read_line")
|
43
|
+
end
|
39
44
|
end
|
40
45
|
|
41
|
-
class File <
|
46
|
+
class File < Base
|
42
47
|
def read(path)
|
43
|
-
command("read", path.to_s)
|
48
|
+
command("read", path: path.to_s)
|
44
49
|
end
|
45
50
|
|
46
51
|
def write(path, content)
|
47
|
-
command("write",
|
52
|
+
command("write", path: path.to_s, content:)
|
48
53
|
end
|
49
54
|
|
50
55
|
def append(path, content)
|
51
|
-
command("append",
|
56
|
+
command("append", path: path.to_s, content:)
|
52
57
|
end
|
53
58
|
|
54
59
|
def mkdir(path)
|
55
|
-
command("mkdir",
|
60
|
+
command("mkdir", path: path.to_s)
|
56
61
|
end
|
57
62
|
|
58
|
-
def
|
59
|
-
command("
|
63
|
+
def delete(path)
|
64
|
+
command("delete", path: path.to_s)
|
60
65
|
end
|
61
66
|
|
62
|
-
|
63
|
-
|
64
|
-
def command(action, data)
|
65
|
-
@connection.write(event: "device", id: @id, action: "command", command: action, data: data)
|
66
|
-
response = @connection.recv
|
67
|
-
response.fetch(:response)
|
67
|
+
def exist?(path)
|
68
|
+
command("exist", path: path.to_s)
|
68
69
|
end
|
69
70
|
end
|
70
71
|
|
71
|
-
class Browser <
|
72
|
+
class Browser < Base
|
72
73
|
def launch(url)
|
73
|
-
command("launch",
|
74
|
-
end
|
75
|
-
|
76
|
-
private
|
77
|
-
|
78
|
-
def command(command, data: nil)
|
79
|
-
@connection.write(event: "device", id: @id, action: "command", command: command, data: data)
|
80
|
-
@connection.recv.fetch(:response)
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
class ResourceMapper
|
86
|
-
include Logging
|
87
|
-
|
88
|
-
def initialize(connection, resources = self.class.resources)
|
89
|
-
@id = -1
|
90
|
-
@resources = resources
|
91
|
-
@devices = Hash.new { |h,k| h[Integer(k)] }
|
92
|
-
@connection = connection
|
93
|
-
end
|
94
|
-
|
95
|
-
def connect_device(type)
|
96
|
-
id = next_id
|
97
|
-
logger.debug "Server: Requesting client to connect device #{type} with ID #{id}"
|
98
|
-
@connection.write(event: "device", action: "connect", id: id, type: type)
|
99
|
-
response = @connection.recv
|
100
|
-
case response
|
101
|
-
in { status: "success" }
|
102
|
-
logger.debug "Server: Resource #{type} connected with ID #{id}."
|
103
|
-
@devices[id] = @resources.find(type).new(id, @connection)
|
104
|
-
else
|
105
|
-
logger.debug "Server: Failed to connect device #{type} with ID #{id}."
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
private
|
110
|
-
|
111
|
-
def next_id
|
112
|
-
@id += 1
|
113
|
-
end
|
114
|
-
|
115
|
-
def self.resources
|
116
|
-
ResourceRegistry.new.tap do |resources|
|
117
|
-
resources << Server::Resource::STDOUT
|
118
|
-
resources << Server::Resource::STDIN
|
119
|
-
resources << Server::Resource::STDERR
|
120
|
-
resources << Server::Resource::Browser
|
121
|
-
resources << Server::Resource::File
|
74
|
+
command("launch", url: url)
|
122
75
|
end
|
123
76
|
end
|
124
77
|
end
|
125
78
|
|
126
|
-
class
|
79
|
+
class Context
|
127
80
|
extend Forwardable
|
128
81
|
|
129
|
-
attr_reader :stdout, :stdin, :stderr, :browser, :file
|
82
|
+
attr_reader :stdout, :stdin, :stderr, :browser, :file, :storage_path
|
130
83
|
|
131
84
|
def_delegators :@stdout, :puts, :print
|
132
85
|
def_delegators :@stdin, :gets, :getpass
|
133
86
|
|
134
|
-
def initialize(
|
135
|
-
@
|
136
|
-
|
137
|
-
|
138
|
-
@
|
139
|
-
@
|
140
|
-
|
141
|
-
@
|
87
|
+
def initialize(adapter:, entitlement:)
|
88
|
+
@adapter = adapter
|
89
|
+
|
90
|
+
# TODO: Encapsulate entitlement in a class instead of a hash.
|
91
|
+
@entitlement = entitlement
|
92
|
+
@storage_path = Pathname.new(entitlement.fetch(:storage_path))
|
93
|
+
|
94
|
+
@stdout = Server::Resource::STDOUT.new("stdout", @adapter)
|
95
|
+
@stdin = Server::Resource::STDIN.new("stdin", @adapter)
|
96
|
+
@stderr = Server::Resource::STDERR.new("stderr", @adapter)
|
97
|
+
@browser = Server::Resource::Browser.new("browser", @adapter)
|
98
|
+
@file = Server::Resource::File.new("file", @adapter)
|
142
99
|
|
143
100
|
if block_given?
|
144
101
|
begin
|
@@ -149,18 +106,12 @@ module Terminalwire
|
|
149
106
|
end
|
150
107
|
end
|
151
108
|
|
152
|
-
def exec(&shell)
|
153
|
-
instance_eval(&shell)
|
154
|
-
ensure
|
155
|
-
exit
|
156
|
-
end
|
157
|
-
|
158
109
|
def exit(status = 0)
|
159
|
-
@
|
110
|
+
@adapter.write(event: "exit", status: status)
|
160
111
|
end
|
161
112
|
|
162
113
|
def close
|
163
|
-
@
|
114
|
+
@adapter.close
|
164
115
|
end
|
165
116
|
end
|
166
117
|
|
@@ -182,7 +133,7 @@ module Terminalwire
|
|
182
133
|
end
|
183
134
|
|
184
135
|
def listen
|
185
|
-
logger.info "Socket:
|
136
|
+
logger.info "Socket: Listening..."
|
186
137
|
loop do
|
187
138
|
client_socket = @server_socket.accept
|
188
139
|
logger.debug "Socket: Client #{client_socket.inspect} connected"
|
@@ -194,37 +145,79 @@ module Terminalwire
|
|
194
145
|
|
195
146
|
def handle_client(socket)
|
196
147
|
transport = Transport::Socket.new(socket)
|
197
|
-
|
148
|
+
adapter = Adapter.new(transport)
|
198
149
|
|
199
150
|
Thread.new do
|
200
|
-
handler = Handler.new(
|
151
|
+
handler = Handler.new(adapter)
|
201
152
|
handler.run
|
202
153
|
end
|
203
154
|
end
|
204
155
|
end
|
205
156
|
|
157
|
+
class WebSocket
|
158
|
+
include Logging
|
159
|
+
|
160
|
+
def call(env)
|
161
|
+
Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
|
162
|
+
run(Adapter.new(Terminalwire::Transport::WebSocket.new(connection)))
|
163
|
+
end or [200, { "Content-Type" => "text/plain" }, ["Connect via WebSockets"]]
|
164
|
+
end
|
165
|
+
|
166
|
+
private
|
167
|
+
|
168
|
+
def run(adapter)
|
169
|
+
while message = adapter.recv
|
170
|
+
puts message
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
class Thor < WebSocket
|
176
|
+
include Logging
|
177
|
+
|
178
|
+
def initialize(cli_class)
|
179
|
+
@cli_class = cli_class
|
180
|
+
|
181
|
+
unless @cli_class.included_modules.include?(Terminalwire::Thor)
|
182
|
+
raise 'Add `include Terminalwire::Thor` to the #{@cli_class.inspect} class.'
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
def run(adapter)
|
187
|
+
logger.info "ThorServer: Running #{@cli_class.inspect}"
|
188
|
+
while message = adapter.recv
|
189
|
+
case message
|
190
|
+
in { event: "initialization", protocol:, program: { arguments: }, entitlement: }
|
191
|
+
Terminalwire::Server::Context.new(adapter:, entitlement:) do |context|
|
192
|
+
@cli_class.start(arguments, context:)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
206
199
|
class Handler
|
207
200
|
include Logging
|
208
201
|
|
209
|
-
def initialize(
|
210
|
-
@
|
202
|
+
def initialize(adapter)
|
203
|
+
@adapter = adapter
|
211
204
|
end
|
212
205
|
|
213
206
|
def run
|
214
207
|
logger.info "Server Handler: Running"
|
215
208
|
loop do
|
216
|
-
message = @
|
209
|
+
message = @adapter.recv
|
217
210
|
case message
|
218
|
-
in { event: "
|
219
|
-
|
220
|
-
MyCLI.start(arguments,
|
211
|
+
in { event: "initialization", protocol:, program: { arguments: }, entitlement: }
|
212
|
+
Context.new(adapter: @adapter) do |context|
|
213
|
+
MyCLI.start(arguments, context:)
|
221
214
|
end
|
222
215
|
end
|
223
216
|
end
|
224
217
|
rescue EOFError, Errno::ECONNRESET
|
225
218
|
logger.info "Server Handler: Client disconnected"
|
226
219
|
ensure
|
227
|
-
@
|
220
|
+
@adapter.close
|
228
221
|
end
|
229
222
|
end
|
230
223
|
|
@@ -236,4 +229,4 @@ module Terminalwire
|
|
236
229
|
Server::Socket.new(UNIXServer.new(...))
|
237
230
|
end
|
238
231
|
end
|
239
|
-
end
|
232
|
+
end
|
data/lib/terminalwire/thor.rb
CHANGED
@@ -1,16 +1,20 @@
|
|
1
|
+
require 'thor'
|
2
|
+
|
1
3
|
module Terminalwire
|
2
4
|
module Thor
|
3
5
|
class Shell < ::Thor::Shell::Basic
|
4
6
|
extend Forwardable
|
5
7
|
|
6
|
-
# Encapsulates all of the IO
|
7
|
-
attr_reader :session
|
8
|
+
# Encapsulates all of the IO resources for a Terminalwire adapter.
|
9
|
+
attr_reader :context, :session
|
8
10
|
|
9
|
-
def_delegators
|
11
|
+
def_delegators :context,
|
12
|
+
:stdin, :stdout, :stderr
|
10
13
|
|
11
|
-
def initialize(
|
12
|
-
@
|
13
|
-
|
14
|
+
def initialize(context, *, **, &)
|
15
|
+
@context = context
|
16
|
+
@session = Terminalwire::Rails::Session.new(context:)
|
17
|
+
super(*,**,&)
|
14
18
|
end
|
15
19
|
end
|
16
20
|
|
@@ -24,20 +28,24 @@ module Terminalwire
|
|
24
28
|
protected
|
25
29
|
|
26
30
|
no_commands do
|
27
|
-
def_delegators :shell,
|
28
|
-
|
29
|
-
def_delegators :
|
30
|
-
|
31
|
+
def_delegators :shell,
|
32
|
+
:context, :session
|
33
|
+
def_delegators :context,
|
34
|
+
:stdout, :stdin, :stderr, :browser
|
35
|
+
def_delegators :stdout,
|
36
|
+
:puts, :print
|
37
|
+
def_delegators :stdin,
|
38
|
+
:gets
|
31
39
|
end
|
32
40
|
end
|
33
41
|
end
|
34
42
|
|
35
43
|
module ClassMethods
|
36
44
|
def start(given_args = ARGV, config = {})
|
37
|
-
|
38
|
-
config[:shell] = Shell.new(
|
45
|
+
context = config.delete(:context)
|
46
|
+
config[:shell] = Shell.new(context) if context
|
39
47
|
super(given_args, config)
|
40
48
|
end
|
41
49
|
end
|
42
50
|
end
|
43
|
-
end
|
51
|
+
end
|
@@ -1,8 +1,16 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'socket'
|
3
|
+
require 'async/websocket/client'
|
4
|
+
|
1
5
|
module Terminalwire
|
2
6
|
module Transport
|
3
7
|
class Base
|
4
|
-
def
|
5
|
-
raise NotImplementedError, "
|
8
|
+
def self.connect(url)
|
9
|
+
raise NotImplementedError, "Subclass must implement .connect"
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.listen(url)
|
13
|
+
raise NotImplementedError, "Subclass must implement .listen"
|
6
14
|
end
|
7
15
|
|
8
16
|
def read
|
@@ -18,25 +26,49 @@ module Terminalwire
|
|
18
26
|
end
|
19
27
|
end
|
20
28
|
|
21
|
-
class
|
22
|
-
def
|
23
|
-
|
29
|
+
class TCP < Base
|
30
|
+
def self.connect(url)
|
31
|
+
uri = URI(url)
|
32
|
+
new(TCPSocket.new(uri.host, uri.port))
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.listen(url)
|
36
|
+
uri = URI(url)
|
37
|
+
new(TCPServer.new(uri.host, uri.port))
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(socket)
|
41
|
+
@socket = socket
|
24
42
|
end
|
25
43
|
|
26
44
|
def read
|
27
|
-
@
|
45
|
+
length = @socket.read(4)
|
46
|
+
return nil if length.nil?
|
47
|
+
length = length.unpack('L>')[0]
|
48
|
+
@socket.read(length)
|
28
49
|
end
|
29
50
|
|
30
51
|
def write(data)
|
31
|
-
|
52
|
+
length = [data.bytesize].pack('L>')
|
53
|
+
@socket.write(length + data)
|
32
54
|
end
|
33
55
|
|
34
56
|
def close
|
35
|
-
@
|
57
|
+
@socket.close
|
36
58
|
end
|
37
59
|
end
|
38
60
|
|
39
|
-
class
|
61
|
+
class Unix < Base
|
62
|
+
def self.connect(url)
|
63
|
+
uri = URI(url)
|
64
|
+
new(UNIXSocket.new(uri.path))
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.listen(url)
|
68
|
+
uri = URI(url)
|
69
|
+
new(UNIXServer.new(uri.path))
|
70
|
+
end
|
71
|
+
|
40
72
|
def initialize(socket)
|
41
73
|
@socket = socket
|
42
74
|
end
|
@@ -57,5 +89,35 @@ module Terminalwire
|
|
57
89
|
@socket.close
|
58
90
|
end
|
59
91
|
end
|
92
|
+
|
93
|
+
class WebSocket < Base
|
94
|
+
def self.connect(url)
|
95
|
+
uri = URI(url)
|
96
|
+
endpoint = Async::HTTP::Endpoint.parse(uri)
|
97
|
+
adapter = Async::WebSocket::Client.connect(endpoint)
|
98
|
+
new(adapter)
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.listen(url)
|
102
|
+
# This would need to be implemented with a WebSocket server library
|
103
|
+
raise NotImplementedError, "WebSocket server not implemented"
|
104
|
+
end
|
105
|
+
|
106
|
+
def initialize(websocket)
|
107
|
+
@websocket = websocket
|
108
|
+
end
|
109
|
+
|
110
|
+
def read
|
111
|
+
@websocket.read&.buffer
|
112
|
+
end
|
113
|
+
|
114
|
+
def write(data)
|
115
|
+
@websocket.write(data)
|
116
|
+
end
|
117
|
+
|
118
|
+
def close
|
119
|
+
@websocket.close
|
120
|
+
end
|
121
|
+
end
|
60
122
|
end
|
61
|
-
end
|
123
|
+
end
|
data/lib/terminalwire/version.rb
CHANGED