terminalwire 0.3.5.alpha2 → 2.0.0

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.
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "handler"
5
+ require_relative "../transport/queue"
6
+ # NOTE: the async stack (async, async-websocket) is required lazily, only when a
7
+ # request actually arrives inside an Async reactor (Falcon). Threaded servers
8
+ # (Puma & friends) and the frame parser never load it — see #call / #async_reactor?.
9
+
10
+ module Terminalwire; end
11
+ module Terminalwire::V2
12
+ module Server
13
+ # A Rack endpoint that serves a Terminalwire CLI over a WebSocket. Mounting it
14
+ # is the entire integration:
15
+ #
16
+ # # config/routes.rb
17
+ # mount Terminalwire::V2::Server::Rack.new(MyCLI), at: "/terminal"
18
+ #
19
+ # It runs on threaded servers (Puma, and friends) and on async servers (Falcon)
20
+ # alike. The server runtime is thread-based, so each connection runs its CLI on
21
+ # its own thread; this class owns that thread, the WebSocket framing, and the
22
+ # teardown — the host app never sees any of it.
23
+ #
24
+ # Two server worlds, picked per request by whether we're inside an async
25
+ # reactor (Async::Task.current?):
26
+ #
27
+ # * Threaded (Puma): a raw RFC 6455 upgrade whose streaming body does the
28
+ # framing on plain blocking socket I/O in threads — exactly what the
29
+ # thread-based runtime and Puma's socket want (no async reactor fighting
30
+ # Puma's write-timeout watchdog).
31
+ # * Async (Falcon): async-websocket drives the connection in reactor fibers,
32
+ # bridged to the runtime's threads via a queue + a wake pipe.
33
+ #
34
+ # Opt-in require: require "terminalwire/v2/server/rack". The async stack is
35
+ # pulled in lazily and only on the Falcon path, so Puma deployments (and the
36
+ # frame parser in unit tests) never load async/async-websocket at all.
37
+ class Rack
38
+ WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
39
+
40
+ # WebSocket subprotocols this server speaks, best first. The handshake echoes
41
+ # the first one the client also offered (RFC 6455 negotiation) — the v1 handler
42
+ # did this (`protocols: ['ws']`) and some edges/proxies (e.g. Fly) drop a
43
+ # WebSocket whose Sec-WebSocket-Protocol the server never echoes back.
44
+ SUBPROTOCOLS = %w[terminalwire.v2 ws].freeze
45
+
46
+ # @param cli_class [Class] a Thor CLI that includes Terminalwire::V2::Server::Thor
47
+ # @param verbose [Boolean] send full backtraces to the client (dev only)
48
+ # @param report [#call, nil] optional callable invoked with unexpected errors
49
+ def initialize(cli_class, verbose: false, report: nil)
50
+ @handler = Handler.new(cli_class: cli_class, verbose: verbose, report: report)
51
+ end
52
+
53
+ def call(env)
54
+ return upgrade_required unless websocket?(env)
55
+ # The incoming connection profile, captured at the upgrade: host (so URL
56
+ # helpers can build absolute URLs, as v1 did), real client IP, User-Agent,
57
+ # and the raw headers. Threaded into the session for server code + `about`.
58
+ request = request_info(env)
59
+
60
+ if async_reactor?
61
+ # Async server (Falcon): let async-websocket own the connection. Pull the
62
+ # adapter in here — this is the only path that needs the async stack.
63
+ # :nocov: Falcon transport wiring — exercised live by the conformance suite, not units.
64
+ require "async/websocket/adapters/rack"
65
+ Async::WebSocket::Adapters::Rack.open(env, protocols: SUBPROTOCOLS) { |connection| ReactorBridge.new(connection, @handler, request: request).run }
66
+ # :nocov:
67
+ else
68
+ # Threaded server (Puma & friends): hand-roll the upgrade and stream.
69
+ [101, upgrade_headers(env), ThreadBridge.new(@handler, request: request)]
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Curate the incoming connection profile from the Rack env: the Host, the real
76
+ # client IP (through Fly/proxies), the User-Agent, and the raw HTTP headers.
77
+ # Read at the upgrade — the client identifies itself in headers before the WS
78
+ # handshake, so the server can log it, route on it, or surface it via `about`.
79
+ def request_info(env)
80
+ {
81
+ host: env["HTTP_HOST"],
82
+ ip: client_ip(env),
83
+ user_agent: env["HTTP_USER_AGENT"],
84
+ headers: http_headers(env),
85
+ }
86
+ end
87
+
88
+ # Real client IP behind Fly/proxies: Fly-Client-IP, else the first
89
+ # X-Forwarded-For hop, else the direct peer.
90
+ #
91
+ # SECURITY: Fly-Client-IP and X-Forwarded-For are client-settable and are
92
+ # trusted verbatim here — there is no trusted-proxy check. This value is for
93
+ # logging and `about` only; it MUST NOT be used as an authorization input
94
+ # (allow/deny, rate-limit identity, audit-as-proof). A direct client can send
95
+ # any IP it likes. If you deploy behind a proxy that isn't Fly, the first
96
+ # X-Forwarded-For hop is likewise attacker-controlled unless your proxy
97
+ # overwrites it.
98
+ def client_ip(env)
99
+ fwd = env["HTTP_X_FORWARDED_FOR"]
100
+ env["HTTP_FLY_CLIENT_IP"] || (fwd && fwd.split(",").first&.strip) || env["REMOTE_ADDR"]
101
+ end
102
+
103
+ # The client/connection headers worth surfacing — standard request headers,
104
+ # not infra chrome. Everything else (Fly-*, X-Forwarded-*, Via, X-Request-*,
105
+ # the WS handshake nonce/mechanics) is proxy/transport noise and is dropped;
106
+ # the real client IP is still recovered separately (see #client_ip). Matched
107
+ # case-insensitively. Allowlist (not denylist) so new proxy headers can't leak in.
108
+ ALLOWED_HEADERS = %w[
109
+ user-agent host origin accept accept-language accept-encoding
110
+ sec-websocket-protocol sec-websocket-version
111
+ ].freeze
112
+
113
+ # Rack's HTTP_* env keys -> human header names (HTTP_USER_AGENT -> User-Agent),
114
+ # filtered to the allowlist above.
115
+ def http_headers(env)
116
+ env.each_with_object({}) do |(k, v), h|
117
+ next unless k.is_a?(String) && k.start_with?("HTTP_")
118
+ name = k.sub(/\AHTTP_/, "").split("_").map(&:capitalize).join("-")
119
+ h[name] = v if ALLOWED_HEADERS.include?(name.downcase)
120
+ end
121
+ end
122
+
123
+ # Are we running inside an Async reactor (Falcon)? If the async gem isn't even
124
+ # loaded we cannot be in a reactor — so this is a threaded server and we never
125
+ # touch async. defined? short-circuits before Async::Task is referenced.
126
+ def async_reactor?
127
+ defined?(Async::Task) && Async::Task.current?
128
+ end
129
+
130
+ def websocket?(env)
131
+ env["HTTP_UPGRADE"].to_s.casecmp?("websocket") && env["HTTP_SEC_WEBSOCKET_KEY"]
132
+ end
133
+
134
+ def upgrade_headers(env)
135
+ accept = [Digest::SHA1.digest("#{env['HTTP_SEC_WEBSOCKET_KEY']}#{WS_GUID}")].pack("m0")
136
+ headers = { "upgrade" => "websocket", "connection" => "Upgrade", "sec-websocket-accept" => accept }
137
+ if (proto = negotiated_subprotocol(env))
138
+ headers["sec-websocket-protocol"] = proto
139
+ end
140
+ headers
141
+ end
142
+
143
+ # The first subprotocol both we and the client support (RFC 6455), or nil.
144
+ def negotiated_subprotocol(env)
145
+ offered = env["HTTP_SEC_WEBSOCKET_PROTOCOL"].to_s.split(/,\s*/)
146
+ (SUBPROTOCOLS & offered).first
147
+ end
148
+
149
+ def upgrade_required
150
+ body = "This endpoint speaks the Terminalwire WebSocket protocol.\n"
151
+ [426, { "content-type" => "text/plain", "connection" => "Upgrade",
152
+ "upgrade" => "websocket", "content-length" => body.bytesize.to_s }, [body]]
153
+ end
154
+
155
+ # --- threaded path (Puma): minimal RFC 6455 framing ----------------------
156
+
157
+ # Just the framing this needs: encode unmasked server->client binary frames;
158
+ # the Sec-WebSocket-Accept value lives on Rack. One WebSocket message == one
159
+ # MessagePack protocol frame.
160
+ module Frame
161
+ CLOSE = [0x88, 0].pack("C2").freeze
162
+
163
+ module_function
164
+
165
+ def binary(payload)
166
+ body = payload.b
167
+ n = body.bytesize
168
+ head =
169
+ if n < 126 then [0x82, n].pack("C2")
170
+ elsif n < 65_536 then [0x82, 126, n].pack("C2n")
171
+ else [0x82, 127, n].pack("C2Q>")
172
+ end
173
+ head + body
174
+ end
175
+
176
+ def pong(payload) = [0x8A, payload.bytesize].pack("C2") + payload.b
177
+ end
178
+
179
+ # Incremental parser: feed raw socket chunks, yield [opcode, payload] per
180
+ # message (reassembling fragments, unmasking — client frames are masked).
181
+ class Parser
182
+ def initialize
183
+ @buf = "".b
184
+ @frag = "".b
185
+ @frag_opcode = nil
186
+ end
187
+
188
+ def push(chunk)
189
+ @buf << chunk.b
190
+ while (frame = next_frame)
191
+ fin, opcode, payload = frame
192
+ case opcode
193
+ when 0x0 # continuation
194
+ @frag << payload
195
+ (yield(@frag_opcode, @frag); @frag = "".b; @frag_opcode = nil) if fin
196
+ when 0x1, 0x2 # text / binary
197
+ if fin then yield(opcode, payload) else @frag_opcode = opcode; @frag = payload.b end
198
+ else # control: 0x8 close, 0x9 ping, 0xA pong
199
+ yield(opcode, payload)
200
+ end
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ # A non-destructive read cursor over the buffer. Each read either returns
207
+ # the next bytes and advances, or throws :incomplete — meaning a whole frame
208
+ # isn't buffered yet, so we must leave @buf untouched and wait for more. That
209
+ # "consume nothing until the frame is complete" rule is the parser's one
210
+ # sharp edge; the cursor makes it structural instead of a hand-checked guard
211
+ # before every slice.
212
+ class Cursor
213
+ def initialize(buf)
214
+ @buf = buf
215
+ @off = 0
216
+ end
217
+
218
+ def byte
219
+ throw :incomplete if @off >= @buf.bytesize
220
+ b = @buf.getbyte(@off)
221
+ @off += 1
222
+ b
223
+ end
224
+
225
+ def take(n)
226
+ throw :incomplete if @buf.bytesize < @off + n
227
+ slice = @buf.byteslice(@off, n)
228
+ @off += n
229
+ slice
230
+ end
231
+
232
+ # The unconsumed tail — what's left after a frame is committed.
233
+ def rest = @buf.byteslice(@off..) || "".b
234
+ end
235
+
236
+ # Decode one frame from @buf, or return nil if a whole frame isn't buffered
237
+ # yet (consuming nothing). Reads top to bottom in RFC 6455 wire order:
238
+ # 2-byte header, optional extended length, optional mask key, then payload.
239
+ # @buf is only advanced (cur.rest) once the full frame is in hand.
240
+ def next_frame
241
+ catch(:incomplete) do
242
+ cur = Cursor.new(@buf)
243
+ b0 = cur.byte
244
+ b1 = cur.byte
245
+ fin = b0.anybits?(0x80)
246
+ opcode = b0 & 0x0F
247
+ masked = b1.anybits?(0x80)
248
+ len = b1 & 0x7F
249
+ len = cur.take(2).unpack1("n") if len == 126
250
+ len = cur.take(8).unpack1("Q>") if len == 127
251
+ key = cur.take(4).bytes if masked
252
+ payload = cur.take(len).b
253
+ @buf = cur.rest
254
+ unmask!(payload, key) if masked
255
+ return [fin, opcode, payload]
256
+ end
257
+ nil # incomplete frame: need more bytes, @buf left untouched
258
+ end
259
+
260
+ def unmask!(payload, key)
261
+ i = 0
262
+ n = payload.bytesize
263
+ while i < n
264
+ payload.setbyte(i, payload.getbyte(i) ^ key[i & 3])
265
+ i += 1
266
+ end
267
+ end
268
+ end
269
+
270
+ # The streaming body for a threaded server. #call(stream) gets the raw
271
+ # blocking socket after the 101; it runs the CLI on its own thread and pumps
272
+ # the socket on another, then returns so the web server can reuse the worker.
273
+ class ThreadBridge
274
+ def initialize(handler, request: {})
275
+ @handler = handler
276
+ @request = request
277
+ end
278
+
279
+ # :nocov: blocking-socket threading — exercised live by the conformance suite, not units.
280
+ def call(stream)
281
+ write_lock = Mutex.new
282
+ parser = Parser.new
283
+ transport = Transport::Queue.new(
284
+ # Serialize writes: the client forbids concurrent writes, and the
285
+ # runtime emits from more than one thread.
286
+ sink: ->(bytes) { write_lock.synchronize { stream.write(Frame.binary(bytes)) } }
287
+ )
288
+
289
+ cli = Thread.new { @handler.call(transport: transport, request: @request) }
290
+
291
+ Thread.new do
292
+ loop do
293
+ parser.push(stream.readpartial(4096)) do |opcode, payload|
294
+ case opcode
295
+ when 0x1, 0x2 then transport.deliver(payload) # protocol frame
296
+ when 0x9 then write_lock.synchronize { stream.write(Frame.pong(payload)) }
297
+ when 0x8 then raise EOFError # client close
298
+ end
299
+ end
300
+ end
301
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
302
+ # client went away — normal end
303
+ ensure
304
+ transport.close
305
+ cli.join(2)
306
+ write_lock.synchronize { stream.write(Frame::CLOSE) rescue nil }
307
+ stream.close rescue nil
308
+ end
309
+ end
310
+ # :nocov:
311
+ end
312
+
313
+ # --- async path (Falcon): bridge async-websocket to the thread runtime ----
314
+
315
+ # One WebSocket connection over async-websocket. inbound: connection.read
316
+ # (reactor fiber) -> transport.deliver; outbound: the CLI thread pushes to a
317
+ # Thread::Queue that a writer fiber drains -> connection.
318
+ #
319
+ # The cross-thread hand-off is a plain Thread::Queue, NOT a self-pipe. A fiber
320
+ # blocked in `outbox.pop` yields the reactor, and a `push` from the CLI thread
321
+ # wakes it through the fiber scheduler's own cross-thread wakeup
322
+ # (Async::Scheduler#unblock -> selector.wakeup). We verified this empirically
323
+ # against async 2.39: the reactor keeps running while the writer is parked, and
324
+ # a push from a real OS thread resumes it. An earlier version hand-rolled an
325
+ # IO.pipe to wake the reactor — that just reimplemented selector.wakeup, so it
326
+ # is gone. The CLI runs on a real Thread (not a fiber) on purpose: a user's CLI
327
+ # makes arbitrary blocking calls, which would stall the whole reactor if run as
328
+ # a fiber — Sam's own guidance is to offload blocking work to a thread.
329
+ class ReactorBridge
330
+ JOIN_TIMEOUT = 2
331
+
332
+ def initialize(connection, handler, request: {})
333
+ @connection = connection
334
+ @handler = handler
335
+ @request = request
336
+ end
337
+
338
+ # :nocov: reactor-fiber bridge — exercised live by the conformance suite, not units.
339
+ def run
340
+ # Falcon already runs us in a reactor; Sync reuses it (and would create
341
+ # one if absent), giving the connection's fiber I/O a scheduler.
342
+ Sync do |task|
343
+ outbox = ::Queue.new # Thread::Queue: thread-safe + fiber-scheduler aware
344
+ transport = Transport::Queue.new(sink: ->(bytes) { outbox << bytes })
345
+
346
+ cli = Thread.new { @handler.call(transport: transport, request: @request) }
347
+
348
+ writer = task.async do
349
+ # pop blocks the fiber until the CLI thread pushes (cross-thread wakeup
350
+ # via the scheduler); nil means the outbox was closed in teardown.
351
+ while (bytes = outbox.pop)
352
+ @connection.send_binary(bytes)
353
+ @connection.flush if outbox.empty? # batch: flush once the burst drains
354
+ end
355
+ rescue EOFError, IOError, Errno::EPIPE
356
+ # connection died mid-write
357
+ end
358
+
359
+ begin
360
+ while (message = @connection.read)
361
+ transport.deliver(message.buffer.b)
362
+ end
363
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
364
+ # client disconnected
365
+ end
366
+ ensure
367
+ transport&.close # unblock the handler's pending reads/requests
368
+ cli&.join(JOIN_TIMEOUT) # let it emit the exit frame (writer drains it meanwhile)
369
+ outbox&.close # -> writer's pop returns nil -> writer fiber ends
370
+ writer&.wait
371
+ end
372
+ end
373
+ # :nocov:
374
+ end
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire::V2
4
+ module Server
5
+ # Runs a block with Ruby's standard I/O globals pointed at the client's
6
+ # terminal, so an ordinary CLI — OptionParser, GLI, dry-cli, or bare
7
+ # Kernel#puts/gets — streams to/from the client with no Terminalwire-specific
8
+ # code.
9
+ #
10
+ # Terminalwire::V2::Server.redirect(context, argv: args) do
11
+ # OptionParser.new { |o| ... }.parse!(args) # help/errors -> client
12
+ # puts "done" # -> client
13
+ # end
14
+ #
15
+ # Concurrency-safe by design. We do NOT swap the process-global $stdout per
16
+ # command (that interleaves when two run at once). Instead we install a
17
+ # StreamRouter as $stdout/$stderr/$stdin ONCE, and each call to #redirect binds
18
+ # a fiber-local target. Two commands running concurrently — on different
19
+ # threads (Puma) or fibers (Falcon) — each see their own client and never cross
20
+ # streams. Outside a #redirect block the routers delegate to the real streams,
21
+ # so installing them is transparent to the rest of the process.
22
+ module_function
23
+
24
+ STDOUT_KEY = :terminalwire_stdout
25
+ STDERR_KEY = :terminalwire_stderr
26
+ STDIN_KEY = :terminalwire_stdin
27
+
28
+ # Install the routers as the global streams, once per process. Idempotent and
29
+ # thread-safe. Safe to leave installed: with no fiber-local target they pass
30
+ # straight through to the original $stdout/$stderr/$stdin.
31
+ @install_mutex = Mutex.new
32
+
33
+ def install!
34
+ @install_mutex.synchronize do
35
+ return if @installed
36
+
37
+ $stdout = StreamRouter.new(STDOUT_KEY, $stdout)
38
+ $stderr = StreamRouter.new(STDERR_KEY, $stderr)
39
+ $stdin = StreamRouter.new(STDIN_KEY, $stdin)
40
+ @installed = true
41
+ end
42
+ end
43
+
44
+ def installed? = @installed == true
45
+
46
+ # Run the block with this fiber's standard streams pointed at `context`.
47
+ #
48
+ # `argv:` is accepted for convenience but ARGV / $PROGRAM_NAME are genuinely
49
+ # process-global (Ruby has no fiber-local ARGV), so we pass the arguments to
50
+ # the block instead of mutating ARGV — the Handler hands them to your CLI
51
+ # directly. We deliberately do NOT mutate global ARGV here, to avoid the exact
52
+ # cross-command races this method exists to prevent.
53
+ def redirect(context, argv: nil)
54
+ install!
55
+
56
+ out = IO.new(context, :stdout)
57
+ err = IO.new(context, :stderr)
58
+ in_ = IO.new(context, :stdin)
59
+
60
+ prev_out = $stdout.__bind__(out)
61
+ prev_err = $stderr.__bind__(err)
62
+ prev_in = $stdin.__bind__(in_)
63
+
64
+ yield(out: out, err: err, in: in_, argv: argv)
65
+ ensure
66
+ $stdout.__restore__(prev_out)
67
+ $stderr.__restore__(prev_err)
68
+ $stdin.__restore__(prev_in)
69
+ end
70
+ end
71
+ end