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