@claudecollab/cli 0.1.3 → 0.2.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.
- package/README.md +70 -7
- package/package.json +2 -3
- package/packages/cli/bin/claude-share.js +298 -24
- package/packages/cli/src/brain/ask.js +67 -0
- package/packages/cli/src/brain/card.js +7 -3
- package/packages/cli/src/brain/commands.js +5 -1
- package/packages/cli/src/brain/history.js +72 -0
- package/packages/cli/src/brain/log.js +21 -3
- package/packages/cli/src/brain/state.js +24 -2
- package/packages/cli/src/brain/transcript.js +65 -0
- package/packages/cli/src/ctl-client.js +119 -0
- package/packages/cli/src/ctl.js +97 -0
- package/packages/cli/src/first-run.js +166 -0
- package/packages/cli/src/relay-client.js +13 -3
- package/packages/cli/src/room-file.js +22 -0
- package/packages/cli/src/setup-actions.js +163 -0
- package/packages/cli/src/shim.js +125 -0
- package/packages/relay/bin/serve.js +2 -0
- package/packages/relay/public/client.js +206 -41
- package/packages/relay/public/index.html +15 -0
- package/packages/relay/public/style.css +232 -3
- package/packages/relay/rooms.js +5 -2
- package/packages/relay/server.js +34 -3
- package/packages/relay/web.js +102 -24
- package/packages/shared/protocol.js +11 -1
|
@@ -475,6 +475,7 @@ body {
|
|
|
475
475
|
|
|
476
476
|
/* ── the terminal: full width, glass frame, gradient border ────────────────── */
|
|
477
477
|
.workspace {
|
|
478
|
+
position: relative; /* the floating ask card anchors here (desktop) */
|
|
478
479
|
flex: 1;
|
|
479
480
|
min-height: 0;
|
|
480
481
|
display: flex;
|
|
@@ -562,6 +563,117 @@ body {
|
|
|
562
563
|
0 24px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
563
564
|
}
|
|
564
565
|
}
|
|
566
|
+
/* the ask CARD is the real affordance now — suppress the old "click to answer" pill
|
|
567
|
+
whenever it is showing so they never overlap (the amber pulse still frames it). */
|
|
568
|
+
#stage.ask.has-ask-card::after {
|
|
569
|
+
display: none;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/* ── permission ask card: touch-first Approve / Deny (all widths) ─────────────
|
|
573
|
+
On desktop it FLOATS over the top of the terminal (so the baseline, non-asking
|
|
574
|
+
layout is untouched); on narrow it leaves the flow and leads the card column. */
|
|
575
|
+
.ask-card {
|
|
576
|
+
position: absolute;
|
|
577
|
+
z-index: 9;
|
|
578
|
+
top: 22px;
|
|
579
|
+
left: 50%;
|
|
580
|
+
transform: translateX(-50%);
|
|
581
|
+
width: min(480px, calc(100% - 48px));
|
|
582
|
+
display: flex;
|
|
583
|
+
flex-direction: column;
|
|
584
|
+
gap: 10px;
|
|
585
|
+
padding: 14px 16px;
|
|
586
|
+
border-radius: 14px;
|
|
587
|
+
background: rgba(20, 15, 6, 0.92);
|
|
588
|
+
backdrop-filter: blur(14px);
|
|
589
|
+
-webkit-backdrop-filter: blur(14px);
|
|
590
|
+
box-shadow: 0 0 0 1.5px rgba(232, 163, 61, 0.6), 0 18px 44px rgba(0, 0, 0, 0.55);
|
|
591
|
+
color: var(--text);
|
|
592
|
+
}
|
|
593
|
+
.ask-card[hidden] {
|
|
594
|
+
display: none;
|
|
595
|
+
}
|
|
596
|
+
.ask-head {
|
|
597
|
+
display: flex;
|
|
598
|
+
align-items: center;
|
|
599
|
+
gap: 8px;
|
|
600
|
+
font-size: 13.5px;
|
|
601
|
+
color: var(--text);
|
|
602
|
+
}
|
|
603
|
+
.ask-head b {
|
|
604
|
+
color: var(--amber-hi);
|
|
605
|
+
font-weight: 700;
|
|
606
|
+
}
|
|
607
|
+
.ask-badge {
|
|
608
|
+
flex: 0 0 auto;
|
|
609
|
+
font-family: var(--mono);
|
|
610
|
+
font-size: 10.5px;
|
|
611
|
+
font-weight: 700;
|
|
612
|
+
text-transform: uppercase;
|
|
613
|
+
letter-spacing: 0.04em;
|
|
614
|
+
color: #1a1205;
|
|
615
|
+
background: var(--amber);
|
|
616
|
+
padding: 2px 8px;
|
|
617
|
+
border-radius: 999px;
|
|
618
|
+
}
|
|
619
|
+
.ask-summary {
|
|
620
|
+
font-family: var(--mono);
|
|
621
|
+
font-size: 12px;
|
|
622
|
+
color: var(--text-dim);
|
|
623
|
+
background: rgba(0, 0, 0, 0.32);
|
|
624
|
+
border-radius: 8px;
|
|
625
|
+
padding: 8px 10px;
|
|
626
|
+
max-height: 4.5em;
|
|
627
|
+
overflow: auto;
|
|
628
|
+
word-break: break-word;
|
|
629
|
+
white-space: pre-wrap;
|
|
630
|
+
}
|
|
631
|
+
.ask-summary[hidden] {
|
|
632
|
+
display: none;
|
|
633
|
+
}
|
|
634
|
+
.ask-actions {
|
|
635
|
+
display: flex;
|
|
636
|
+
gap: 10px;
|
|
637
|
+
}
|
|
638
|
+
.ask-actions[hidden] {
|
|
639
|
+
display: none;
|
|
640
|
+
}
|
|
641
|
+
.ask-btn {
|
|
642
|
+
flex: 1 1 0;
|
|
643
|
+
min-height: 44px; /* a comfortable touch target */
|
|
644
|
+
font: inherit;
|
|
645
|
+
font-weight: 700;
|
|
646
|
+
font-size: 14px;
|
|
647
|
+
border: none;
|
|
648
|
+
border-radius: 10px;
|
|
649
|
+
cursor: pointer;
|
|
650
|
+
transition: transform 0.12s var(--spring), filter 0.15s ease;
|
|
651
|
+
}
|
|
652
|
+
.ask-btn:active {
|
|
653
|
+
transform: scale(0.97);
|
|
654
|
+
}
|
|
655
|
+
.ask-btn.yes {
|
|
656
|
+
color: #05130c;
|
|
657
|
+
background: var(--green);
|
|
658
|
+
}
|
|
659
|
+
.ask-btn.yes:hover {
|
|
660
|
+
filter: brightness(1.08);
|
|
661
|
+
}
|
|
662
|
+
.ask-btn.no {
|
|
663
|
+
color: var(--red);
|
|
664
|
+
background: rgba(240, 140, 140, 0.12);
|
|
665
|
+
box-shadow: inset 0 0 0 1px var(--red-ring);
|
|
666
|
+
}
|
|
667
|
+
.ask-btn.no:hover {
|
|
668
|
+
background: rgba(240, 140, 140, 0.2);
|
|
669
|
+
}
|
|
670
|
+
.ask-viewnote {
|
|
671
|
+
font-size: 12px;
|
|
672
|
+
color: var(--text-faint);
|
|
673
|
+
}
|
|
674
|
+
.ask-viewnote[hidden] {
|
|
675
|
+
display: none;
|
|
676
|
+
}
|
|
565
677
|
|
|
566
678
|
/* ── the per-user compose windows: a row beneath Claude's live terminal ──────
|
|
567
679
|
Each column is one person's own space to write a prompt SEPARATELY; the
|
|
@@ -628,6 +740,9 @@ body {
|
|
|
628
740
|
.uw-status.typing {
|
|
629
741
|
color: var(--c, var(--amber));
|
|
630
742
|
}
|
|
743
|
+
.uw-status.waiting {
|
|
744
|
+
color: var(--blue);
|
|
745
|
+
}
|
|
631
746
|
.uw-body {
|
|
632
747
|
flex: 1;
|
|
633
748
|
min-height: 0;
|
|
@@ -637,6 +752,9 @@ body {
|
|
|
637
752
|
font-size: 12px;
|
|
638
753
|
line-height: 1.55;
|
|
639
754
|
color: var(--text-dim);
|
|
755
|
+
/* selectable so prompts + responses can be copied out of the window */
|
|
756
|
+
user-select: text;
|
|
757
|
+
-webkit-user-select: text;
|
|
640
758
|
}
|
|
641
759
|
.uw-empty {
|
|
642
760
|
color: var(--text-faint);
|
|
@@ -648,13 +766,73 @@ body {
|
|
|
648
766
|
white-space: pre-wrap;
|
|
649
767
|
word-break: break-word;
|
|
650
768
|
}
|
|
651
|
-
.uw-line.sent {
|
|
652
|
-
color: var(--text-dim);
|
|
653
|
-
}
|
|
654
769
|
.uw-line .uw-n {
|
|
655
770
|
color: var(--text-faint);
|
|
656
771
|
margin-right: 6px;
|
|
657
772
|
}
|
|
773
|
+
|
|
774
|
+
/* one turn: the person's prompt, then Claude's reply beneath it */
|
|
775
|
+
.uw-turn {
|
|
776
|
+
margin: 0 0 12px;
|
|
777
|
+
padding: 0 0 10px;
|
|
778
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
779
|
+
}
|
|
780
|
+
.uw-turn:last-child {
|
|
781
|
+
border-bottom: none;
|
|
782
|
+
}
|
|
783
|
+
.uw-prompt {
|
|
784
|
+
color: var(--c, var(--text));
|
|
785
|
+
font-weight: 600;
|
|
786
|
+
}
|
|
787
|
+
.uw-caret {
|
|
788
|
+
opacity: 0.7;
|
|
789
|
+
margin-right: 2px;
|
|
790
|
+
}
|
|
791
|
+
.uw-resp {
|
|
792
|
+
margin-top: 5px;
|
|
793
|
+
padding-left: 10px;
|
|
794
|
+
border-left: 2px solid rgba(255, 255, 255, 0.12);
|
|
795
|
+
color: var(--text-dim);
|
|
796
|
+
white-space: pre-wrap;
|
|
797
|
+
word-break: break-word;
|
|
798
|
+
}
|
|
799
|
+
.uw-resp.running {
|
|
800
|
+
color: var(--text-faint);
|
|
801
|
+
font-style: italic;
|
|
802
|
+
border-left-color: color-mix(in srgb, var(--blue) 50%, transparent);
|
|
803
|
+
}
|
|
804
|
+
.uw-more {
|
|
805
|
+
display: inline-block;
|
|
806
|
+
margin-left: 6px;
|
|
807
|
+
padding: 0;
|
|
808
|
+
font: inherit;
|
|
809
|
+
font-size: 11px;
|
|
810
|
+
color: var(--blue);
|
|
811
|
+
background: none;
|
|
812
|
+
border: none;
|
|
813
|
+
cursor: pointer;
|
|
814
|
+
text-decoration: underline;
|
|
815
|
+
text-underline-offset: 2px;
|
|
816
|
+
}
|
|
817
|
+
.uw-more:hover {
|
|
818
|
+
color: var(--text);
|
|
819
|
+
}
|
|
820
|
+
.uw-queued {
|
|
821
|
+
color: var(--text-faint);
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: baseline;
|
|
824
|
+
gap: 6px;
|
|
825
|
+
}
|
|
826
|
+
.uw-badge {
|
|
827
|
+
font-size: 9.5px;
|
|
828
|
+
text-transform: uppercase;
|
|
829
|
+
letter-spacing: 0.05em;
|
|
830
|
+
color: var(--amber);
|
|
831
|
+
border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
|
|
832
|
+
border-radius: 999px;
|
|
833
|
+
padding: 1px 6px;
|
|
834
|
+
margin-left: auto;
|
|
835
|
+
}
|
|
658
836
|
.uw-input-wrap {
|
|
659
837
|
flex: 0 0 auto;
|
|
660
838
|
padding: 9px 11px 11px;
|
|
@@ -1055,6 +1233,57 @@ body {
|
|
|
1055
1233
|
}
|
|
1056
1234
|
}
|
|
1057
1235
|
|
|
1236
|
+
/* ── phones (≤700px): card column is primary, the terminal is a scaled secondary
|
|
1237
|
+
surface below it. The cards fingers use — ask, your compose window, and the host's
|
|
1238
|
+
knock chips — lead; the mirror renders scaled-to-width (tap to zoom). ────────── */
|
|
1239
|
+
@media (max-width: 700px) {
|
|
1240
|
+
.workspace {
|
|
1241
|
+
padding: 8px;
|
|
1242
|
+
gap: 8px;
|
|
1243
|
+
}
|
|
1244
|
+
/* the terminal steps down from flex:1 to a shorter, scaled secondary panel */
|
|
1245
|
+
#stage {
|
|
1246
|
+
order: 3;
|
|
1247
|
+
flex: 0 0 auto;
|
|
1248
|
+
height: 40vh;
|
|
1249
|
+
min-height: 200px;
|
|
1250
|
+
}
|
|
1251
|
+
/* the ask card leaves the float and LEADS the column, full width */
|
|
1252
|
+
.ask-card {
|
|
1253
|
+
order: 0;
|
|
1254
|
+
position: static;
|
|
1255
|
+
transform: none;
|
|
1256
|
+
top: auto;
|
|
1257
|
+
left: auto;
|
|
1258
|
+
width: 100%;
|
|
1259
|
+
}
|
|
1260
|
+
/* your compose window(s) become the primary surface, stacked full width */
|
|
1261
|
+
.panels-grid {
|
|
1262
|
+
order: 2;
|
|
1263
|
+
height: auto;
|
|
1264
|
+
min-height: 34vh;
|
|
1265
|
+
grid-template-columns: 1fr;
|
|
1266
|
+
}
|
|
1267
|
+
/* bigger touch targets for the header controls fingers reach for */
|
|
1268
|
+
.ctrl {
|
|
1269
|
+
padding: 7px 14px;
|
|
1270
|
+
font-size: 12.5px;
|
|
1271
|
+
}
|
|
1272
|
+
.kbtn {
|
|
1273
|
+
padding: 8px 14px;
|
|
1274
|
+
font-size: 13px;
|
|
1275
|
+
}
|
|
1276
|
+
.av {
|
|
1277
|
+
width: 26px;
|
|
1278
|
+
height: 26px;
|
|
1279
|
+
font-size: 12px;
|
|
1280
|
+
margin-left: -4px;
|
|
1281
|
+
}
|
|
1282
|
+
.knock-chip {
|
|
1283
|
+
width: 100%;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1058
1287
|
/* Claude's real cursor in the mirror is the "you type HERE" indicator — but the
|
|
1059
1288
|
mirror never holds browser focus, so xterm draws it as a hollow outline. Force
|
|
1060
1289
|
it filled and blinking, like the terminal it mirrors. */
|
package/packages/relay/rooms.js
CHANGED
|
@@ -52,13 +52,16 @@ export function createRegistry(opts = {}) {
|
|
|
52
52
|
return `${base}-${n}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// A HELLO may request a room cap; it is clamped to [1, relayCap] so a client
|
|
56
|
+
// can't ask for more seats than the relay allows. Absent request = the relay cap.
|
|
57
|
+
function create(o = {}) {
|
|
58
|
+
const roomCap = Number.isFinite(o.cap) ? Math.max(1, Math.min(cap, Math.floor(o.cap))) : cap;
|
|
56
59
|
const code = makeCode();
|
|
57
60
|
const room = {
|
|
58
61
|
code,
|
|
59
62
|
guests: new Map(), // id -> {name, fp, role}
|
|
60
63
|
banned: new Set(), // banned fingerprints
|
|
61
|
-
cap,
|
|
64
|
+
cap: roomCap,
|
|
62
65
|
hostPresent: true,
|
|
63
66
|
createdAt: now(),
|
|
64
67
|
_ttlTimer: null, // pending host-drop close timer
|
package/packages/relay/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createRegistry } from './rooms.js';
|
|
|
19
19
|
import { secretsMatch } from './auth.js';
|
|
20
20
|
import { startWebDoor } from './web.js';
|
|
21
21
|
import { sanitizeName } from './names.js';
|
|
22
|
-
import { TYPES, encode, validate, Decoder } from '../shared/protocol.js';
|
|
22
|
+
import { TYPES, encode, validate, Decoder, PROTOCOL_V } from '../shared/protocol.js';
|
|
23
23
|
|
|
24
24
|
const { Server, utils } = ssh2;
|
|
25
25
|
const { parseKey } = utils;
|
|
@@ -27,6 +27,12 @@ const { parseKey } = utils;
|
|
|
27
27
|
const DEFAULT_KNOCK_TIMEOUT_MS = 60 * 1000; // spec: 60s waiting screen
|
|
28
28
|
const DEFAULT_TTL_MS = 10 * 60 * 1000; // spec: hold the code 10 min on host drop
|
|
29
29
|
|
|
30
|
+
// Per-room ceiling on PENDING (knocked-but-not-yet-admitted) guests. room.pending
|
|
31
|
+
// is otherwise unbounded, so a script could stack knock cards forever without ever
|
|
32
|
+
// being admitted. Sits well above the 8-guest room cap + normal churn; overflow is
|
|
33
|
+
// refused exactly like a deny. Shared by both doors (the web door gets it via ctx).
|
|
34
|
+
export const MAX_PENDING = 12;
|
|
35
|
+
|
|
30
36
|
// Standard OpenSSH SHA256 fingerprint over the raw public-key blob.
|
|
31
37
|
function fingerprint(keyData) {
|
|
32
38
|
return 'SHA256:' + createHash('sha256').update(keyData).digest('base64').replace(/=+$/, '');
|
|
@@ -104,6 +110,10 @@ export function startRelay(opts = {}) {
|
|
|
104
110
|
// ungated on purpose: it is already bound to the creating host's key
|
|
105
111
|
// fingerprint, and must keep working mid-session if the secret rotates.
|
|
106
112
|
roomSecret,
|
|
113
|
+
// Trust Fly's `Fly-Client-IP` header for per-IP knock limits. Only safe when
|
|
114
|
+
// the relay genuinely sits behind Fly's proxy (a direct-exposed relay could be
|
|
115
|
+
// spoofed by a client-sent header), so it is off unless the operator opts in.
|
|
116
|
+
trustProxy = false,
|
|
107
117
|
registry: registryOpts = {},
|
|
108
118
|
} = opts;
|
|
109
119
|
|
|
@@ -191,6 +201,15 @@ export function startRelay(opts = {}) {
|
|
|
191
201
|
switch (msg.t) {
|
|
192
202
|
case TYPES.HELLO: {
|
|
193
203
|
if (code) return; // one room per host connection
|
|
204
|
+
// Version gate first — the cheapest refusal. A NEWER major than we
|
|
205
|
+
// speak is refused machine-readably (the CLI tells the user to update);
|
|
206
|
+
// an older or absent version is served (the relay stays back-compatible).
|
|
207
|
+
if (msg.v !== undefined && Math.floor(msg.v) > PROTOCOL_V) {
|
|
208
|
+
safeWrite(stream, encode({ t: TYPES.REFUSED, reason: 'version' }));
|
|
209
|
+
safeEnd(stream);
|
|
210
|
+
safeEnd(conn);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
194
213
|
if (roomSecret && !secretsMatch(msg.secret, roomSecret)) {
|
|
195
214
|
// Machine-readable refusal so the CLI can say "bad secret" instead
|
|
196
215
|
// of mistaking this for a dead relay and reconnect-looping.
|
|
@@ -206,7 +225,7 @@ export function startRelay(opts = {}) {
|
|
|
206
225
|
safeEnd(conn);
|
|
207
226
|
return;
|
|
208
227
|
}
|
|
209
|
-
const room = registry.create();
|
|
228
|
+
const room = registry.create({ cap: msg.cap });
|
|
210
229
|
code = room.code;
|
|
211
230
|
live.set(code, {
|
|
212
231
|
code,
|
|
@@ -397,6 +416,15 @@ export function startRelay(opts = {}) {
|
|
|
397
416
|
return;
|
|
398
417
|
}
|
|
399
418
|
|
|
419
|
+
// Card-spam lid: a full pending queue is refused exactly like a deny, so a
|
|
420
|
+
// script can't stack knock cards without ever being admitted.
|
|
421
|
+
if (room.pending.size >= MAX_PENDING) {
|
|
422
|
+
safeWrite(stream, COPY.denied);
|
|
423
|
+
safeEnd(stream);
|
|
424
|
+
safeEnd(conn);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
400
428
|
const rec = {
|
|
401
429
|
id: randomUUID(),
|
|
402
430
|
code,
|
|
@@ -634,12 +662,15 @@ export function startRelay(opts = {}) {
|
|
|
634
662
|
address,
|
|
635
663
|
webPort: web ? web.port : null,
|
|
636
664
|
webAddress: web ? web.address : null,
|
|
665
|
+
// How many rooms are currently live. Lets a caller assert that a lazy host
|
|
666
|
+
// (one started without --live) dialed nothing and created no room.
|
|
667
|
+
roomCount: () => live.size,
|
|
637
668
|
});
|
|
638
669
|
};
|
|
639
670
|
// The browser door shares this relay's live rooms + registry, so web
|
|
640
671
|
// participants are real room members subject to the same knock/ban/cap.
|
|
641
672
|
if (webPort == null) return done(null);
|
|
642
|
-
startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite }).then(done, (err) => {
|
|
673
|
+
startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite, trustProxy, maxPending: MAX_PENDING }).then(done, (err) => {
|
|
643
674
|
try {
|
|
644
675
|
close();
|
|
645
676
|
} catch {
|
package/packages/relay/web.js
CHANGED
|
@@ -39,6 +39,10 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
39
39
|
const PUBLIC_DIR = path.join(here, 'public');
|
|
40
40
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
41
41
|
|
|
42
|
+
// How long a passworded room waits for the client's first {t:'pass'} frame
|
|
43
|
+
// before closing the connection. Generous — a human may be prompted for it.
|
|
44
|
+
const PASS_TIMEOUT_MS = 10000;
|
|
45
|
+
|
|
42
46
|
// Resolve the vendored xterm package dir through node resolution so monorepo
|
|
43
47
|
// hoisting doesn't matter. `/assets/<rest>` maps to `<xtermDir>/<rest>`.
|
|
44
48
|
const require = createRequire(import.meta.url);
|
|
@@ -97,6 +101,24 @@ function wsClose(ws) {
|
|
|
97
101
|
}
|
|
98
102
|
const sendErr = (ws, reason) => wsSendText(ws, JSON.stringify({ t: 'error', reason }));
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The client IP the knock limiter keys on. Behind Fly's proxy every connection's
|
|
106
|
+
* `req.socket.remoteAddress` is the proxy, so all strangers share ONE lockout
|
|
107
|
+
* bucket. When the operator declares the deploy sits behind Fly
|
|
108
|
+
* (CLAUDE_SHARE_TRUST_PROXY=1 → trustProxy), honor the `Fly-Client-IP` header;
|
|
109
|
+
* otherwise ignore it (a direct-exposed relay must not trust a client-set header).
|
|
110
|
+
* @param {import('node:http').IncomingMessage} req
|
|
111
|
+
* @param {boolean} trustProxy
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function clientIp(req, trustProxy) {
|
|
115
|
+
if (trustProxy) {
|
|
116
|
+
const h = req.headers['fly-client-ip'];
|
|
117
|
+
if (typeof h === 'string' && h.length) return h;
|
|
118
|
+
}
|
|
119
|
+
return req.socket.remoteAddress || 'unknown';
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
/**
|
|
101
123
|
* Start the HTTP + WebSocket web door. Shares the live routing state and the
|
|
102
124
|
* registry with the ssh relay so web participants are real room members.
|
|
@@ -109,10 +131,13 @@ const sendErr = (ws, reason) => wsSendText(ws, JSON.stringify({ t: 'error', reas
|
|
|
109
131
|
* @param {number} ctx.knockTimeoutMs waiting-screen timeout
|
|
110
132
|
* @param {(rec:object)=>void} ctx.onGuestGone shared guest-teardown (frees seat, LEFTs the host)
|
|
111
133
|
* @param {(stream:any,data:any)=>void} ctx.safeWrite shared fail-soft writer (to the host stream)
|
|
134
|
+
* @param {boolean} [ctx.trustProxy] honor Fly-Client-IP for per-IP knock limits (Fly deploys only)
|
|
112
135
|
* @returns {Promise<{close():void, port:number, address:object}>}
|
|
113
136
|
*/
|
|
114
137
|
export function startWebDoor(ctx) {
|
|
115
|
-
|
|
138
|
+
// maxPending's fallback mirrors server.js's MAX_PENDING — startRelay always
|
|
139
|
+
// supplies it; the default only covers a direct startWebDoor caller (tests).
|
|
140
|
+
const { port = 0, host = '127.0.0.1', live, registry, knockTimeoutMs, onGuestGone, safeWrite, trustProxy = false, maxPending = 12 } = ctx;
|
|
116
141
|
|
|
117
142
|
// ---- HTTP: index (SPA-style, room code in the path) + vendored xterm assets
|
|
118
143
|
|
|
@@ -218,7 +243,7 @@ export function startWebDoor(ctx) {
|
|
|
218
243
|
// and the host writes it verbatim to its terminal / log / session.md and mirrors
|
|
219
244
|
// it to every ssh guest — raw ESC/OSC/BEL bytes here would be escape injection.
|
|
220
245
|
const name = sanitizeName(q.get('name'), isHostTab ? 'host' : 'guest');
|
|
221
|
-
const ip = req
|
|
246
|
+
const ip = clientIp(req, trustProxy);
|
|
222
247
|
|
|
223
248
|
// Same gate as the ssh door (spec: respect bans/lockouts/caps identically).
|
|
224
249
|
const room = code ? live.get(code) : null;
|
|
@@ -226,14 +251,16 @@ export function startWebDoor(ctx) {
|
|
|
226
251
|
if (!room.hostPresent) return void (sendErr(ws, 'host-gone'), wsClose(ws));
|
|
227
252
|
if (fp && registry.get(code).banned.has(fp)) return void (sendErr(ws, 'banned'), wsClose(ws));
|
|
228
253
|
if (!registry.tryKnock(code, ip)) return void (sendErr(ws, 'lockout'), wsClose(ws));
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
|
|
255
|
+
// Room password pre-gate. Instead of reading `?pass=` off the URL (where it
|
|
256
|
+
// leaks into proxy/access logs and browser history), a passworded room
|
|
257
|
+
// challenges over the WIRE: it answers with {t:'pass?'} and waits for the
|
|
258
|
+
// client's FIRST message, {t:'pass', pass}. Only a match proceeds to the knock.
|
|
259
|
+
// Host tabs are exempt: their credential is the host token the brain itself
|
|
260
|
+
// minted — a forged `?host=` gains nothing, it just lands as an ordinary
|
|
261
|
+
// pending knock the host will deny. The tryKnock slot was already burned above,
|
|
262
|
+
// so brute force still hits the per-ip lockout fast.
|
|
263
|
+
const needsPass = !isHostTab && !!room.pass;
|
|
237
264
|
|
|
238
265
|
const rec = {
|
|
239
266
|
id: randomUUID(),
|
|
@@ -242,7 +269,9 @@ export function startWebDoor(ctx) {
|
|
|
242
269
|
ws,
|
|
243
270
|
fp,
|
|
244
271
|
name,
|
|
245
|
-
|
|
272
|
+
// web skips the ssh name prompt (name arrives in the query); a passworded
|
|
273
|
+
// room parks in 'pass' until the client answers the wire challenge.
|
|
274
|
+
phase: needsPass ? 'pass' : 'knocking',
|
|
246
275
|
cols: 80, // spec floor; refined by the browser's first {t:'resize'}
|
|
247
276
|
rows: 24,
|
|
248
277
|
knockTimer: null,
|
|
@@ -254,24 +283,73 @@ export function startWebDoor(ctx) {
|
|
|
254
283
|
conn: { end: () => wsClose(ws) },
|
|
255
284
|
sendText: (s) => wsSendText(ws, s), // STATE + the 'joined' signal go as text
|
|
256
285
|
};
|
|
257
|
-
room.pending.set(rec.id, rec);
|
|
258
286
|
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
room.seen.set(fp, name);
|
|
264
|
-
rec.announced = true;
|
|
265
|
-
safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp, seen }));
|
|
266
|
-
rec.knockTimer = setTimeout(() => {
|
|
287
|
+
// Register the pending rec, KNOCK the host, and arm the admit timer. Deferred
|
|
288
|
+
// behind the password gate so a passworded room never shows a pending card (or
|
|
289
|
+
// sends a KNOCK) for a guest that hasn't proven the password yet.
|
|
290
|
+
function proceedToKnock() {
|
|
267
291
|
if (rec.gone) return;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
292
|
+
// The room may have ended (or its host dropped) while the guest sat at the
|
|
293
|
+
// password prompt — a pass-phase rec is in neither guests nor pending, so
|
|
294
|
+
// closeRoom/onHostGone can't reach it. Refuse instead of knocking a dead room.
|
|
295
|
+
if (live.get(rec.code) !== room) return void (sendErr(ws, 'no-room'), wsClose(ws));
|
|
296
|
+
if (!room.hostPresent) return void (sendErr(ws, 'host-gone'), wsClose(ws));
|
|
297
|
+
if (rec.knockTimer) {
|
|
298
|
+
clearTimeout(rec.knockTimer); // clear the pass-challenge timer, if any
|
|
299
|
+
rec.knockTimer = null;
|
|
300
|
+
}
|
|
301
|
+
// Card-spam lid (mirrors the ssh door): a full pending queue is refused like
|
|
302
|
+
// a deny, so a script can't stack knock cards without ever being admitted.
|
|
303
|
+
if (room.pending.size >= maxPending) {
|
|
304
|
+
return void (sendErr(ws, 'busy'), wsClose(ws));
|
|
305
|
+
}
|
|
306
|
+
rec.phase = 'knocking';
|
|
307
|
+
room.pending.set(rec.id, rec);
|
|
308
|
+
// A returning token carries its prior name in `seen`; brand-new ⇒ null.
|
|
309
|
+
const priorName = room.seen.get(fp);
|
|
310
|
+
const seen = priorName !== undefined ? priorName : null;
|
|
311
|
+
room.seen.set(fp, name);
|
|
312
|
+
rec.announced = true;
|
|
313
|
+
safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp, seen }));
|
|
314
|
+
rec.knockTimer = setTimeout(() => {
|
|
315
|
+
if (rec.gone) return;
|
|
316
|
+
sendErr(ws, 'timeout');
|
|
317
|
+
wsClose(ws); // 'close' → onGuestGone: drops the seat, LEFTs the host
|
|
318
|
+
}, knockTimeoutMs);
|
|
319
|
+
rec.knockTimer.unref?.();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (needsPass) {
|
|
323
|
+
wsSendText(ws, '{"t":"pass?"}'); // ask on the wire; client replies {t:'pass'}
|
|
324
|
+
rec.knockTimer = setTimeout(() => {
|
|
325
|
+
if (rec.gone) return;
|
|
326
|
+
sendErr(ws, 'timeout');
|
|
327
|
+
wsClose(ws);
|
|
328
|
+
}, PASS_TIMEOUT_MS);
|
|
329
|
+
rec.knockTimer.unref?.();
|
|
330
|
+
} else {
|
|
331
|
+
proceedToKnock();
|
|
332
|
+
}
|
|
272
333
|
|
|
273
334
|
ws.on('message', (data, isBinary) => {
|
|
274
335
|
if (isBinary) return; // browsers only send JSON text; screen flows one way
|
|
336
|
+
// Pre-knock password phase: the FIRST text frame must be {t:'pass', pass}.
|
|
337
|
+
if (rec.phase === 'pass') {
|
|
338
|
+
let pm;
|
|
339
|
+
try {
|
|
340
|
+
pm = JSON.parse(data.toString('utf8'));
|
|
341
|
+
} catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!isObj(pm) || pm.t !== 'pass') return;
|
|
345
|
+
if (secretsMatch(typeof pm.pass === 'string' ? pm.pass : '', room.pass)) {
|
|
346
|
+
proceedToKnock();
|
|
347
|
+
} else {
|
|
348
|
+
sendErr(ws, 'password');
|
|
349
|
+
wsClose(ws);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
275
353
|
if (rec.phase !== 'live') return; // ignore input until admitted (ssh parity)
|
|
276
354
|
const r = live.get(rec.code);
|
|
277
355
|
if (!r || !r.hostPresent || !r.guests.has(rec.id)) return;
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// Guests speak no protocol — they exchange raw terminal bytes. Only the host<->relay
|
|
3
3
|
// control channel uses these messages.
|
|
4
4
|
|
|
5
|
+
// Wire protocol version. Bump on breaking wire changes; the relay refuses NEWER
|
|
6
|
+
// majors with REFUSED reason 'version' (an older/absent v is served — the relay
|
|
7
|
+
// stays backward compatible). One source both sides import.
|
|
8
|
+
export const PROTOCOL_V = 1;
|
|
9
|
+
|
|
5
10
|
/** Canonical message-type strings (the `t` field of every message). */
|
|
6
11
|
export const TYPES = Object.freeze({
|
|
7
|
-
HELLO: 'hello', // host->relay: {t:'hello', want:'room', secret?, pass?} — new room
|
|
12
|
+
HELLO: 'hello', // host->relay: {t:'hello', want:'room', v?, cap?, secret?, pass?} — new room
|
|
13
|
+
// (v = protocol version; cap = requested room size, clamped by the relay; secret = relay
|
|
8
14
|
// room-creation credential; pass = optional per-room JOIN password guests must present)
|
|
9
15
|
RECLAIM: 'reclaim', // host->relay: {t:'reclaim', code} — take back an existing room after a drop
|
|
10
16
|
ROOM: 'room', // relay->host: {t:'room', code} — room granted (create OR reclaim)
|
|
@@ -137,8 +143,12 @@ export function validate(obj) {
|
|
|
137
143
|
// the relay when configured; a plain relay ignores it.
|
|
138
144
|
// pass (optional): a join password for THIS room — the relay stores it and
|
|
139
145
|
// pre-gates every guest (ssh prompt / web form) before the knock is sent.
|
|
146
|
+
// v/cap (optional): v = the client's protocol version (absent = a
|
|
147
|
+
// pre-versioning client); cap = a requested room size the relay clamps.
|
|
140
148
|
return (
|
|
141
149
|
obj.want === 'room' &&
|
|
150
|
+
(obj.v === undefined || isNum(obj.v)) &&
|
|
151
|
+
(obj.cap === undefined || isNum(obj.cap)) &&
|
|
142
152
|
(obj.secret === undefined || isStr(obj.secret)) &&
|
|
143
153
|
(obj.pass === undefined || isStr(obj.pass))
|
|
144
154
|
);
|