tunnel-rb 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f05bc6a5b62aa9876fe9214d1b45d8bffa70f307805bb9cedda03c5aefbc3c9
4
+ data.tar.gz: b7a6fca4bbaddd1d6f18f2ad5a493f1f7e4f497bdace5879717e25fc20149720
5
+ SHA512:
6
+ metadata.gz: add440acc45cc5a459a54b7c6137a7d254faa34430b44706519b2b46b532bd9b247ab60112452722be3de13cfe83eb94e21b8efcf48853c184350a62cf88e6b5
7
+ data.tar.gz: 0f88caa949e07e68eb73371f5eedd2c0cf4c3eaaf1d6b8b24e60b4db60161d7a87d70e4a88479dac347ec9e2294bdb4a51eda8b24d4e3e1d7807cd923351d7c4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) tunnel-rb contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,506 @@
1
+ # [tunnel-rb](https://github.com/headmandev/tunnel-rb)
2
+
3
+ Expose a local HTTP server (e.g. a Rails app on port 3000) to the internet through a tunnel server. A tunnel **client** running on your machine maintains a persistent control connection to the tunnel **server**; incoming browser traffic hits the server and is forwarded through the client to your local process.
4
+
5
+ **By default, no server setup is required.** The client connects to the hosted tunnel server at [tunnel-rb.dev](https://tunnel-rb.dev) and prints a public HTTPS URL you can open or share. Run your own server only if you want to self-host a private instance.
6
+
7
+ Built with plain Ruby — blocking I/O and threads, no runtime gem dependencies.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ gem install tunnel-rb
13
+ ```
14
+
15
+ This installs the `tunnel` client on your `PATH`:
16
+
17
+ ```bash
18
+ tunnel 3000
19
+ ```
20
+
21
+ From source:
22
+
23
+ ```bash
24
+ git clone https://github.com/headmandev/tunnel-rb.git
25
+ cd tunnel-rb
26
+ bundle install
27
+ bundle exec rake install # or: gem build tunnel-rb.gemspec && gem install tunnel-rb-*.gem
28
+ ```
29
+
30
+ Without installing the gem (from a checkout):
31
+
32
+ ```bash
33
+ ruby tunnel.rb 3000
34
+ ```
35
+
36
+ The root-level script `tunnel.rb` (and `bin/tunnel`) remain for development checkout.
37
+
38
+ ## Quick start
39
+
40
+ Start your local app, then run the client with its port — no `--server-host` or other server options needed:
41
+
42
+ ```bash
43
+ rails server -p 3000 # or any local HTTP server
44
+
45
+ tunnel 3000
46
+ # from checkout: ruby tunnel.rb 3000
47
+ ```
48
+
49
+ The client connects to the hosted server at **tunnel-rb.dev** (control host: `server.tunnel-rb.dev`) and prints a public URL:
50
+
51
+ ```
52
+ 🚀 [Tunnel] Ready! https://1ef59cf5.tunnel-rb.dev -> localhost:3000 (server: TLS)
53
+ ```
54
+
55
+ Open that URL in a browser, or:
56
+
57
+ ```bash
58
+ curl -I https://1ef59cf5.tunnel-rb.dev/
59
+ ```
60
+
61
+ Each run gets a unique subdomain. TLS to the server is on by default.
62
+
63
+ > **Self-hosting:** Run your own server with `ruby tunnel-server.rb` only if you want a private instance instead of the hosted tunnel-rb.dev service. See [Quick start (self-hosted)](#quick-start-self-hosted).
64
+
65
+ ## Start here (3 common scenarios)
66
+
67
+ 1. Hosted default (fastest): `tunnel 3000`
68
+ 2. Self-hosted local dev (plaintext server):
69
+ - Terminal 1: `ruby tunnel-server.rb`
70
+ - Terminal 2: `ruby tunnel.rb 3000 --server-host localhost --no-tls`
71
+ 3. Self-hosted production (TLS + reverse proxy):
72
+ - Set `RELAY_DOMAIN`, `RELAY_TLS_CERT`, `RELAY_TLS_KEY`, and usually `RELAY_URL_PORT=`
73
+ - Start: `ruby tunnel-server.rb`
74
+ - Connect: `ruby tunnel.rb 3000 --server-host your-control-host`
75
+
76
+ ### Library API
77
+
78
+ ```ruby
79
+ require "tunnel_rb"
80
+
81
+ TunnelRb::Client.new(
82
+ local_port: 3000,
83
+ server_host: "server.tunnel-rb.dev", # hosted server (same default as the CLI)
84
+ server_port: 7777,
85
+ tls: true
86
+ ).start
87
+
88
+ TunnelRb::Server.new(
89
+ control_port: 7777,
90
+ public_port: 8080,
91
+ domain: "localhost"
92
+ ).start
93
+ ```
94
+
95
+ ## Architecture
96
+
97
+ ```
98
+ Browser Tunnel Server Tunnel Client Local App
99
+ | | | |
100
+ | HTTP (public port) | | |
101
+ |--------------------------->| new_connection (control port) | |
102
+ | |------------------------------------->| |
103
+ | | bind + data socket (control port) | |
104
+ | |<-------------------------------------| |
105
+ | | proxy bytes | TCP (local port) |
106
+ | |------------------------------------->|----------------------->|
107
+ | |<-------------------------------------|<-----------------------|
108
+ |<---------------------------| | |
109
+ ```
110
+
111
+ The tunnel server listens on two ports:
112
+
113
+
114
+ | Port | Role | Who connects |
115
+ | ------------------ | -------------------------------------------- | -------------- |
116
+ | **7777** (control) | Registration, ping/pong, data-socket handoff | Tunnel clients |
117
+ | **8080** (public) | Incoming HTTP from browsers / nginx | End users |
118
+
119
+
120
+ Each tunneled HTTP request uses **two** TCP connections on the control port:
121
+
122
+ 1. The long-lived **control channel** (register, ping, `new_connection` commands).
123
+ 2. A short-lived **data channel** (`bind` with a `conn_id`) that carries the actual HTTP bytes.
124
+
125
+ ## Quick start (self-hosted)
126
+
127
+ Use this when you run your own tunnel server (e.g. local development or a private deployment) instead of the hosted tunnel-rb.dev service.
128
+
129
+ **Terminal 1 — tunnel server**
130
+
131
+ ```bash
132
+ ruby tunnel-server.rb
133
+ ```
134
+
135
+ **Terminal 2 — your local app** (example: Rails on 3000)
136
+
137
+ ```bash
138
+ rails server -p 3000
139
+ ```
140
+
141
+ **Terminal 3 — tunnel client**
142
+
143
+ The client connects over TLS by default. The local server above runs plaintext, so disable TLS with `--no-tls`:
144
+
145
+ ```bash
146
+ ruby tunnel.rb 3000 --no-tls
147
+ # after gem install: tunnel 3000 --no-tls
148
+ ```
149
+
150
+ The client prints a public URL, e.g.:
151
+
152
+ ```
153
+ 🚀 [Tunnel] Ready! https://a1b2c3d4.localhost:8080 -> localhost:3000
154
+ ```
155
+
156
+ For local plaintext testing, send a request over `http://` with the generated host:
157
+
158
+ ```bash
159
+ curl -H "Host: a1b2c3d4.localhost:8080" http://127.0.0.1:8080/
160
+ ```
161
+
162
+ The `Host` header must match the assigned subdomain. For local testing the default domain is `localhost` and the URL includes the public port (`:8080`), so curling `127.0.0.1:8080` with `Host: <subdomain>.localhost:8080` needs no `/etc/hosts` edits.
163
+
164
+ > Note: the registration URL is always printed as `https://...` because production traffic is expected behind a TLS edge (e.g. nginx). In local self-hosted mode (`ruby tunnel-server.rb` with no TLS on `public_port`), access the public port via `http://`.
165
+
166
+ Point the client at your server with `--server-host localhost --no-tls` (plaintext local server) or set `SERVER_HOST` / `SERVER_PORT` for a remote instance.
167
+
168
+ ---
169
+
170
+ ## Tunnel server
171
+
172
+ ### Running
173
+
174
+ ```bash
175
+ ruby tunnel-server.rb
176
+ ```
177
+
178
+ Press **Ctrl+C** or send **SIGTERM** for graceful shutdown (listeners closed, thread pools drained, tokens persisted).
179
+
180
+ ### Configuration
181
+
182
+ `TunnelRb::Server` accepts keyword arguments:
183
+
184
+
185
+ | Option | Default | Description |
186
+ | --------------- | ----------------------------------------- | ----------------------------------------------------------------------------- |
187
+ | `control_port` | _(required)_ | Tunnel client control plane |
188
+ | `public_port` | _(required)_ | Public HTTP edge (port the server binds/listens on) |
189
+ | `domain` | _(required)_ | Base domain for the registration URL (`https://{subdomain}.{domain}`) |
190
+ | `url_port` | `nil` | Port shown in the registration URL; `nil` omits it (e.g. nginx on 443) |
191
+ | `tokens_path` | `/tmp/tunnel-rb-server-tokens.json` | Persistent token store |
192
+ | `tls_cert` | `nil` | PEM cert path; enables TLS on the control port when set with `tls_key` |
193
+ | `tls_key` | `nil` | PEM private key path; enables TLS on the control port when set with `tls_cert`|
194
+ | `logger` | `TunnelRb::Server::Logger.new` | Injectable logger (stdout/stderr) |
195
+
196
+ `control_port`, `public_port`, and `domain` are required keyword arguments. When started via `ruby tunnel-server.rb` they're supplied from environment variables (defaults shown):
197
+
198
+ | Env var | Default | Maps to |
199
+ | -------------------- | ---------------- | -------------- |
200
+ | `RELAY_CONTROL_PORT` | `7777` | `control_port` |
201
+ | `RELAY_PUBLIC_PORT` | `8080` | `public_port` |
202
+ | `RELAY_DOMAIN` | `localhost` | `domain` |
203
+ | `RELAY_URL_PORT` | `public_port` | `url_port` |
204
+
205
+ `RELAY_URL_PORT` defaults to the public port, so locally the printed URL is `https://<subdomain>.localhost:8080`. Behind a reverse proxy (e.g. nginx terminating TLS on 443), the server's `8080` is internal — set `RELAY_URL_PORT=` (empty) so the URL drops the port:
206
+
207
+ ```bash
208
+ # Local dev: URL shows the port (https://<sub>.localhost:8080)
209
+ ruby tunnel-server.rb
210
+
211
+ # Production behind nginx: clients see https://<sub>.tunnel.example.com (no port)
212
+ RELAY_DOMAIN=tunnel.example.com RELAY_URL_PORT= ruby tunnel-server.rb
213
+ ```
214
+
215
+ Example with custom options:
216
+
217
+ ```ruby
218
+ require "tunnel_rb/server"
219
+
220
+ TunnelRb::Server.new(
221
+ control_port: 7777,
222
+ public_port: 8080,
223
+ domain: "tunnel.example.com",
224
+ tokens_path: "/var/lib/tunnel-rb/tokens.json"
225
+ ).start
226
+ ```
227
+
228
+ ### Server components
229
+
230
+ ```
231
+ lib/tunnel_rb/server/
232
+ server.rb Coordinator — wires components, signal handlers, shutdown
233
+ control_server.rb Port 7777 — accept, handshake pool, read loop, ping loop
234
+ public_server.rb Port 8080 — HTTP routing, pending connections, byte proxy
235
+ client_registry.rb Connected clients (subdomain ↔ socket)
236
+ token_store.rb Token persistence and subdomain assignment
237
+ pending_connections.rb conn_id ↔ Queue handoff between public and control sides
238
+ thread_pool.rb Bounded worker pools with backpressure
239
+ http_request.rb Header parsing + X-Forwarded-* injection
240
+ socket_helpers.rb TCP keepalive tuning
241
+ tls.rb Optional TLS context + listener wrapping for the control port
242
+ logger.rb Structured logging wrapper
243
+ client.rb Per-client state object
244
+ ```
245
+
246
+ ### Limits and behaviour
247
+
248
+
249
+ | Setting | Value | Effect |
250
+ | ------------------------ | ----------------------- | ------------------------------------------------ |
251
+ | Handshake pool | 64 workers + 64 queue | Backpressure on control-port connection floods |
252
+ | Public pool | 200 workers + 200 queue | Backpressure on HTTP connection floods |
253
+ | Ping interval | 30 s | Keeps NAT mappings alive |
254
+ | Missed pongs | 3 | Unresponsive clients are disconnected |
255
+ | HTTP header read timeout | 5 s | Slowloris protection on public port |
256
+ | Data socket wait | 10 s | Timeout if client does not bind in time |
257
+ | Control line max | 16 KiB | Slowloris protection on control read loop |
258
+ | Token TTL | 24 h | Tokens expire when no client holds the subdomain |
259
+ | Token cleanup | every 10 min | Background sweep of expired tokens |
260
+
261
+
262
+ ### Token persistence
263
+
264
+ On first registration the server assigns a random subdomain (e.g. `a1b2c3d4`) and issues a reconnect **token**. Tokens are written to disk so a client can reconnect after a disconnect and keep the same subdomain.
265
+
266
+ - Connected clients: token stays valid regardless of TTL.
267
+ - Disconnected clients: token expires after 24 hours unless the client reconnects in time.
268
+
269
+ ### Production notes
270
+
271
+ - Put **nginx** (or similar) in front of the public port for TLS termination. Forward `Host` unchanged so subdomain routing works.
272
+ - The server binds `0.0.0.0` on both ports.
273
+ - There is no authentication beyond the reconnect token — treat the control port as trusted infrastructure.
274
+ - Environment variables currently keep the `RELAY_*` prefix for backward compatibility.
275
+
276
+ ---
277
+
278
+ ## TLS (optional)
279
+
280
+ TLS can be enabled on the **control port** (`7777`) to encrypt the server ↔ tunnel-client link. Because both the long-lived control channel and the short-lived data sockets flow through this port, enabling it encrypts all traffic between the client and the server. The public HTTP port (`8080`) is unaffected — keep terminating its TLS at nginx as before.
281
+
282
+ The two sides default differently: the **server** runs plaintext unless a cert and key are supplied, while the **client** connects over TLS (and verifies against the system CA store) **by default** — use `--no-tls` to talk to a plaintext server.
283
+
284
+ ### Generating a self-signed cert (for testing)
285
+
286
+ ```bash
287
+ openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365 -subj "/CN=localhost"
288
+ ```
289
+
290
+ ### Server
291
+
292
+ Pass `tls_cert` / `tls_key`, or set the `RELAY_TLS_CERT` / `RELAY_TLS_KEY` env vars when starting the server:
293
+
294
+ ```bash
295
+ RELAY_TLS_CERT=server.crt RELAY_TLS_KEY=server.key ruby tunnel-server.rb
296
+ ```
297
+
298
+ ```ruby
299
+ TunnelRb::Server.new(
300
+ control_port: 7777,
301
+ public_port: 8080,
302
+ tls_cert: "server.crt",
303
+ tls_key: "server.key"
304
+ ).start
305
+ ```
306
+
307
+ On startup the server logs whether the control plane is running with TLS enabled.
308
+
309
+ For **Let's Encrypt**, point `tls_cert` / `RELAY_TLS_CERT` at **`fullchain.pem`**, not `cert.pem`. The server sends the entire chain to clients; a leaf cert alone causes `certificate verify failed (unable to get local issuer certificate)` on the client. A single PEM file (e.g. a self-signed test cert) still works — the server loads one or more certificates from the file.
310
+
311
+ ```bash
312
+ RELAY_TLS_CERT=/etc/letsencrypt/live/server.example.com/fullchain.pem \
313
+ RELAY_TLS_KEY=/etc/letsencrypt/live/server.example.com/privkey.pem \
314
+ ruby tunnel-server.rb
315
+ ```
316
+
317
+ ### Client
318
+
319
+ TLS and certificate verification are **on by default**. Connecting to a server with a publicly trusted cert needs no flags:
320
+
321
+ ```bash
322
+ # Default: TLS on, verified against the system CA store (e.g. a Let's Encrypt cert).
323
+ ruby tunnel.rb 3000 --server-host server.example.com
324
+
325
+ # Custom CA: verify against a specific CA bundle instead of the system store.
326
+ ruby tunnel.rb 3000 --server-host server.example.com --tls-ca ca.pem
327
+
328
+ # Self-signed server: keep TLS but skip verification.
329
+ ruby tunnel.rb 3000 --server-host server.example.com --no-tls-verify
330
+
331
+ # Plaintext server (local dev / tests): disable TLS entirely.
332
+ ruby tunnel.rb 3000 --no-tls
333
+ ```
334
+
335
+ The same options are available via `RELAY_TLS` / `RELAY_TLS_VERIFY` (`1`/`true`/`yes`, default on) and `RELAY_TLS_CA`.
336
+
337
+ `--[no-]tls` applies **only to connections to the server** (control and data sockets). The link to your local app (e.g. Rails on `:3000`) is always plain TCP — even when server TLS is on.
338
+
339
+ The client has three TLS verification modes (TLS itself is toggled with `--[no-]tls`):
340
+
341
+ | Mode | Flags | Verification |
342
+ | ------------- | --------------------------- | ------------------------------------------------------------- |
343
+ | System CAs | _(default)_ | `VERIFY_PEER` against the OS trusted CA store + hostname check |
344
+ | Custom CA | `--tls-ca PATH` | `VERIFY_PEER` against the given CA cert/bundle + hostname check |
345
+ | Insecure | `--no-tls-verify` | None (`VERIFY_NONE`) — accepts any cert, for self-signed servers |
346
+
347
+ `--tls-ca` takes precedence over the system-CA default if both are in effect.
348
+
349
+ > Note: because verification is on by default, a self-signed server needs `--no-tls-verify`, and a plaintext server needs `--no-tls`. Both verifying modes include a hostname check.
350
+
351
+ ---
352
+
353
+ ## Tunnel client
354
+
355
+ ### Running
356
+
357
+ By default the client uses the hosted server — just pass the local port:
358
+
359
+ ```bash
360
+ tunnel 3000
361
+ ruby tunnel.rb 3000 --help
362
+ ```
363
+
364
+ For a self-hosted server:
365
+
366
+ ```bash
367
+ ruby tunnel.rb 3000 --server-host localhost --no-tls # local plaintext server
368
+ ruby tunnel.rb 3000 --server-host server.example.com # your own deployment
369
+ ```
370
+
371
+ TLS is on by default (see [TLS](#tls-optional)); pass `--no-tls` when the server is plaintext (local dev with `ruby tunnel-server.rb`).
372
+
373
+ The local port can be passed as the first positional argument or via the `LOCAL_PORT` environment variable. The client exits with status 1 if neither is set.
374
+
375
+ ### Configuration
376
+
377
+
378
+ | Flag | Env var | Default | Description |
379
+ | -------------- | -------------- | ----------- | ---------------------------------------------------------------- |
380
+ | (positional) | `LOCAL_PORT` | — | Port of the local service (required) |
381
+ | `--local-host` | `LOCAL_HOST` | `localhost` | Host of the local service |
382
+ | `--server-host` | `SERVER_HOST` | `server.tunnel-rb.dev` | Server control host; default is the hosted tunnel-rb.dev service |
383
+ | `--server-port` | `SERVER_PORT` | `7777` | Server control port; only change for self-hosted servers |
384
+ | `--[no-]tls` | `RELAY_TLS` | `true` | Connect to the server over TLS (use `--no-tls` for local/testing) |
385
+ | `--[no-]tls-verify` | `RELAY_TLS_VERIFY` | `true` | Verify the server cert against the system CA store |
386
+ | `--tls-ca` | `RELAY_TLS_CA` | — | CA cert/bundle to verify the server instead of the system store |
387
+
388
+
389
+ Examples:
390
+
391
+ ```bash
392
+ SERVER_HOST=server.example.com LOCAL_PORT=3000 ruby tunnel.rb
393
+ ruby tunnel.rb 3000 --local-host 127.0.0.1
394
+ ```
395
+
396
+ ### Behaviour
397
+
398
+ 1. Opens a connection to the server control port (5 s connect timeout), wrapped in TLS unless `--no-tls` is set.
399
+ 2. Sends `register` (with optional `token` on reconnect).
400
+ 3. Receives `{ status: "ok", url: "...", token: "..." }`.
401
+ 4. Enters a read loop on the control socket:
402
+ - `**ping**` → replies with `**pong**` (keeps the connection alive through NAT).
403
+ - `**new_connection**` → opens a fresh connection to the server (TLS when enabled, plain TCP with `--no-tls`), sends `bind` with the given `conn_id`, then proxies bytes between server and the local service over plain TCP.
404
+ 5. If the local service is unreachable (connection refused, host unreachable, DNS failure, connect timeout), the client sends a `502 Bad Gateway` HTTP response back through the server instead of dropping the connection.
405
+ 6. On disconnect, the client reconnects automatically with **exponential backoff** (1 s → 2 s → 4 s → … capped at 30 s, reset to 1 s after a successful registration), reusing the saved token.
406
+
407
+ ### Reconnection
408
+
409
+ The client stores the token from the registration response. On reconnect it sends:
410
+
411
+ ```json
412
+ {"action":"register","token":"<hex-token>"}
413
+ ```
414
+
415
+ The server revokes the old token, closes the previous control socket, and restores the same subdomain.
416
+
417
+ ### TCP keepalive
418
+
419
+ Both client and server enable TCP keepalive (idle 60 s, interval 30 s, 3 probes) to detect dead peers through NAT/firewalls.
420
+
421
+ ---
422
+
423
+ ## Wire protocol
424
+
425
+ All messages are **newline-delimited JSON** (one object per line).
426
+
427
+ ### Client → server (first message on every TCP connection)
428
+
429
+
430
+ | `action` | Fields | Purpose |
431
+ | ---------- | ------------------ | ------------------------------------------------- |
432
+ | `register` | `token` (optional) | Claim or reclaim a subdomain |
433
+ | `bind` | `conn_id` | Attach a data socket to a pending browser request |
434
+
435
+
436
+ ### Server → client (on control channel)
437
+
438
+
439
+ | `action` / field | Purpose |
440
+ | ----------------------------------------------- | --------------------- |
441
+ | `{ status: "ok", url: "...", token: "..." }` | Registration response |
442
+ | `{ action: "ping" }` | Liveness check |
443
+ | `{ action: "new_connection", conn_id: "uuid" }` | Open a data channel |
444
+
445
+
446
+ ### Client → server (on control channel, after register)
447
+
448
+
449
+ | `action` | Purpose |
450
+ | -------------------- | ------------- |
451
+ | `{ action: "pong" }` | Reply to ping |
452
+
453
+
454
+ ### Request flow (one HTTP request)
455
+
456
+ ```
457
+ 1. Browser → server:8080 GET /path Host: xxxx.tunnel-rb.dev
458
+ 2. Server → client (control): {"action":"new_connection","conn_id":"<uuid>"}
459
+ 3. Client → server (new TCP): {"action":"bind","conn_id":"<uuid>"}
460
+ 4. Server forwards buffered HTTP headers on the data socket
461
+ 5. Client connects to localhost:3000, proxies bytes both ways
462
+ 6. Connection closes when either side finishes
463
+ ```
464
+
465
+ ---
466
+
467
+ ## Testing
468
+
469
+ Requires Ruby stdlib only (minitest).
470
+
471
+ ```bash
472
+ # All tests
473
+ bundle exec rake test
474
+
475
+ # or manually:
476
+ ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require_relative f }'
477
+
478
+ # Unit tests (TokenStore)
479
+ ruby -Ilib -Itest test/server/token_store_test.rb
480
+
481
+ # End-to-end (real server on ephemeral ports, fake tunnel client, HTTP request)
482
+ ruby -Ilib -Itest test/server/integration_test.rb
483
+ ```
484
+
485
+ Integration tests start a real `TunnelRb::Server` on random free ports with an isolated token file. They do **not** touch `/tmp/tunnel-rb-server-tokens.json`.
486
+
487
+ ---
488
+
489
+ ## Project layout
490
+
491
+ ```
492
+ exe/tunnel Gem executable (client)
493
+ lib/tunnel_rb/ Client library + CLI
494
+ lib/tunnel_rb/server/ Server (run from checkout: ruby tunnel-server.rb)
495
+ tunnel-rb.gemspec Gem specification
496
+ bin/ Development entry points
497
+ tunnel.rb Development shim (client)
498
+ tunnel-server.rb Server entry point (checkout only)
499
+ test/server/ Unit and integration tests
500
+ ```
501
+
502
+ ## Requirements
503
+
504
+ - Ruby 3.x (tested on 3.4)
505
+ - No runtime gem dependencies (stdlib only)
506
+
data/exe/tunnel ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "tunnel_rb/cli"
5
+
6
+ TunnelRb::CLI.run(ARGV)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "client"
6
+
7
+ module TunnelRb
8
+ module CLI
9
+ module_function
10
+
11
+ def run(argv = ARGV)
12
+ Client.new(**parse_options(argv)).start
13
+ end
14
+
15
+ def env_bool(name, default)
16
+ value = ENV[name]
17
+ return default if value.nil? || value.empty?
18
+
19
+ ["1", "true", "yes"].include?(value.downcase)
20
+ end
21
+
22
+ def parse_options(argv)
23
+ options = {
24
+ server_host: ENV.fetch("SERVER_HOST", "server.tunnel-rb.dev"),
25
+ server_port: Integer(ENV.fetch("SERVER_PORT", "7777")),
26
+ local_host: ENV.fetch("LOCAL_HOST", "localhost"),
27
+ local_port: ENV["LOCAL_PORT"] ? Integer(ENV["LOCAL_PORT"]) : nil,
28
+ tls: env_bool("RELAY_TLS", true),
29
+ tls_ca: ENV["RELAY_TLS_CA"],
30
+ tls_verify: env_bool("RELAY_TLS_VERIFY", true)
31
+ }
32
+
33
+ parser = OptionParser.new do |opts|
34
+ opts.banner = "Usage: tunnel [LOCAL_PORT] [options]"
35
+ opts.on("--server-host HOST", "Server host (default: #{options[:server_host]})") { |v| options[:server_host] = v }
36
+ opts.on("--server-port PORT", Integer, "Server port (default: #{options[:server_port]})") { |v| options[:server_port] = v }
37
+ opts.on("--local-host HOST", "Local host (default: #{options[:local_host]})") { |v| options[:local_host] = v }
38
+ opts.on("--[no-]tls", "Connect to the server over TLS (default: on; use --no-tls for local/testing)") { |v| options[:tls] = v }
39
+ opts.on("--[no-]tls-verify", "Verify the server cert against the system CA store (default: on)") { |v| options[:tls_verify] = v }
40
+ opts.on("--tls-ca PATH", "CA cert/bundle to verify the server instead of the system CA store") { |v| options[:tls_ca] = v }
41
+ opts.on("-h", "--help", "Show this help") do
42
+ puts opts
43
+ exit
44
+ end
45
+ end
46
+ parser.parse!(argv)
47
+
48
+ options[:local_port] = Integer(argv[0]) if argv[0]
49
+
50
+ if options[:local_port].nil?
51
+ warn "Error: local port is required (positional argument or LOCAL_PORT env var)"
52
+ warn parser.help
53
+ exit 1
54
+ end
55
+
56
+ options
57
+ end
58
+ end
59
+ end