agent-yes 1.190.0 → 1.192.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/dist/{SUPPORTED_CLIS-BB7Br0fS.js → SUPPORTED_CLIS-C0fh_eJs.js} +2 -2
- package/dist/SUPPORTED_CLIS-DAzPSQ95.js +8 -0
- package/dist/{agentShare-BXfWzPJu.js → agentShare-Byzqbfk6.js} +2 -2
- package/dist/cli.js +4 -4
- package/dist/{expose-DUYH0tvh.js → expose-B3AGhVhM.js} +151 -68
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-APkkoCzC.js → notifyDaemon-DjuvLK7-.js} +2 -2
- package/dist/{rustBinary-C8YZUZGT.js → rustBinary-INWRomvk.js} +2 -2
- package/dist/{schedule-BYG6MGpH.js → schedule-CsNFNbbW.js} +4 -4
- package/dist/{serve-Df3wcUU-.js → serve-CfR-7uMU.js} +46 -10
- package/dist/{setup-C65eaHZ8.js → setup-0Oby3hYf.js} +2 -2
- package/dist/{subcommands-D6TbKFTF.js → subcommands-D_QHSUp-.js} +1 -1
- package/dist/{subcommands-Deaq1dOk.js → subcommands-IIDRpOa1.js} +17 -9
- package/dist/{ts-CeGEhsC9.js → ts-BdFKarex.js} +2 -2
- package/dist/{versionChecker-DXwf5ip7.js → versionChecker-DEJVuzWZ.js} +2 -2
- package/lab/ui/index.html +239 -3
- package/package.json +1 -1
- package/ts/expose.ts +204 -78
- package/ts/serve.ts +44 -0
- package/ts/subcommands.ts +15 -6
- package/dist/SUPPORTED_CLIS-B729E086.js +0 -8
package/lab/ui/index.html
CHANGED
|
@@ -352,6 +352,46 @@
|
|
|
352
352
|
.app.steer-agent #shareAgentRW {
|
|
353
353
|
display: none !important;
|
|
354
354
|
}
|
|
355
|
+
/* Expose modal — ports manager list rows. */
|
|
356
|
+
.exposeList {
|
|
357
|
+
margin: 10px 0;
|
|
358
|
+
display: flex;
|
|
359
|
+
flex-direction: column;
|
|
360
|
+
gap: 6px;
|
|
361
|
+
max-height: 40vh;
|
|
362
|
+
overflow-y: auto;
|
|
363
|
+
}
|
|
364
|
+
.exposerow {
|
|
365
|
+
display: flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 8px;
|
|
368
|
+
border: 1px solid var(--line);
|
|
369
|
+
border-radius: 8px;
|
|
370
|
+
padding: 7px 9px;
|
|
371
|
+
}
|
|
372
|
+
.exposerow .exmeta {
|
|
373
|
+
flex: 1;
|
|
374
|
+
min-width: 0;
|
|
375
|
+
}
|
|
376
|
+
.exposerow .explabel {
|
|
377
|
+
font-weight: 600;
|
|
378
|
+
}
|
|
379
|
+
.exposerow .exurl {
|
|
380
|
+
font-size: 11px;
|
|
381
|
+
opacity: 0.7;
|
|
382
|
+
overflow: hidden;
|
|
383
|
+
text-overflow: ellipsis;
|
|
384
|
+
white-space: nowrap;
|
|
385
|
+
}
|
|
386
|
+
.exposerow .exhost {
|
|
387
|
+
font-size: 10px;
|
|
388
|
+
opacity: 0.55;
|
|
389
|
+
}
|
|
390
|
+
.exposePrompt {
|
|
391
|
+
border-bottom: 1px solid var(--line);
|
|
392
|
+
padding-bottom: 10px;
|
|
393
|
+
margin-bottom: 4px;
|
|
394
|
+
}
|
|
355
395
|
/* Share modal — QR + link for a single-agent view-only share. */
|
|
356
396
|
.modal-backdrop {
|
|
357
397
|
position: fixed;
|
|
@@ -1685,6 +1725,9 @@
|
|
|
1685
1725
|
<button id="foldbtn" class="viewbtn" title="fold subagent trees">⊞ subs</button>
|
|
1686
1726
|
<button id="sortbtn" class="viewbtn" title="cycle sort order">⇅ state</button>
|
|
1687
1727
|
<button id="viewbtn" class="viewbtn" title="toggle compact list">☰</button>
|
|
1728
|
+
<button id="portsbtn" class="viewbtn" title="manage exposed localhost ports">
|
|
1729
|
+
⇄ ports
|
|
1730
|
+
</button>
|
|
1688
1731
|
<button id="rguibtn" class="viewbtn" title="open the agent graph view (/r/) for this fleet">
|
|
1689
1732
|
⌗ graph
|
|
1690
1733
|
</button>
|
|
@@ -1890,6 +1933,32 @@
|
|
|
1890
1933
|
</div>
|
|
1891
1934
|
</div>
|
|
1892
1935
|
|
|
1936
|
+
<!-- Expose modal: prompt to publish a clicked localhost:PORT through
|
|
1937
|
+
agent-yes.com, plus the ports manager (list + revoke active exposures). -->
|
|
1938
|
+
<div class="modal-backdrop" id="exposeModal" hidden>
|
|
1939
|
+
<div class="sharebox" role="dialog" aria-modal="true" aria-label="Expose localhost port">
|
|
1940
|
+
<h3 id="exposeTitle">Exposed ports</h3>
|
|
1941
|
+
<div class="exposePrompt" id="exposePrompt" hidden>
|
|
1942
|
+
<p class="sub" id="exposePromptSub">Publish this local server on the internet?</p>
|
|
1943
|
+
<p class="note">
|
|
1944
|
+
A private link on <strong>agent-yes.com</strong> will tunnel to this port on the agent's
|
|
1945
|
+
machine. Only someone who opens the one-time claim link (which opens automatically for
|
|
1946
|
+
you) can reach it. Revoke anytime below.
|
|
1947
|
+
</p>
|
|
1948
|
+
<div class="srow">
|
|
1949
|
+
<button class="primary" id="exposeGo" type="button">Expose & open</button>
|
|
1950
|
+
<button id="exposeCancel" type="button">Cancel</button>
|
|
1951
|
+
</div>
|
|
1952
|
+
</div>
|
|
1953
|
+
<div class="exposeList" id="exposeList">
|
|
1954
|
+
<p class="sub" id="exposeEmpty">No ports are exposed right now.</p>
|
|
1955
|
+
</div>
|
|
1956
|
+
<div class="srow">
|
|
1957
|
+
<button id="exposeClose" type="button">Close</button>
|
|
1958
|
+
</div>
|
|
1959
|
+
</div>
|
|
1960
|
+
</div>
|
|
1961
|
+
|
|
1893
1962
|
<!-- Cmd/Ctrl+K omnibox: search agents by title (instant) then output (tail),
|
|
1894
1963
|
or spawn a new agent in the highlighted agent's cwd with the typed prompt. -->
|
|
1895
1964
|
<div class="omni" id="omni" style="display: none">
|
|
@@ -3242,6 +3311,157 @@
|
|
|
3242
3311
|
if (modal) modal.hidden = true;
|
|
3243
3312
|
pendingRw = null; // a cancelled read-write flow never minted anything
|
|
3244
3313
|
}
|
|
3314
|
+
|
|
3315
|
+
// ---- Port exposure: publish a clicked localhost:PORT through agent-yes.com ----
|
|
3316
|
+
|
|
3317
|
+
// Recognise a local-loopback URL and pull its port (default 80). Only these
|
|
3318
|
+
// are offered for exposure — a real public URL just opens normally.
|
|
3319
|
+
function localhostPort(uri) {
|
|
3320
|
+
let u;
|
|
3321
|
+
try {
|
|
3322
|
+
u = new URL(uri);
|
|
3323
|
+
} catch {
|
|
3324
|
+
return null;
|
|
3325
|
+
}
|
|
3326
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
3327
|
+
const h = u.hostname;
|
|
3328
|
+
if (h !== "localhost" && h !== "127.0.0.1" && h !== "0.0.0.0" && h !== "::1") return null;
|
|
3329
|
+
const port = Number(u.port) || (u.protocol === "https:" ? 443 : 80);
|
|
3330
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
// The tx the expose prompt should call (the daemon whose terminal was clicked).
|
|
3334
|
+
let exposePendingTx = null;
|
|
3335
|
+
let exposePendingPort = 0;
|
|
3336
|
+
|
|
3337
|
+
// Called by the terminal link handler when a localhost URL is clicked.
|
|
3338
|
+
function promptExpose(port, tx) {
|
|
3339
|
+
exposePendingTx = tx || localTx;
|
|
3340
|
+
exposePendingPort = port;
|
|
3341
|
+
$("exposeTitle").textContent = "Expose localhost:" + port + "?";
|
|
3342
|
+
$("exposePromptSub").textContent =
|
|
3343
|
+
"Publish this local server (port " + port + ") on the internet via agent-yes.com?";
|
|
3344
|
+
$("exposePrompt").hidden = false;
|
|
3345
|
+
openExposeManager(false);
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
// Open the ports manager (list of active exposures across every host).
|
|
3349
|
+
function openExposeManager(resetPrompt) {
|
|
3350
|
+
if (resetPrompt !== false) {
|
|
3351
|
+
$("exposePrompt").hidden = true;
|
|
3352
|
+
$("exposeTitle").textContent = "Exposed ports";
|
|
3353
|
+
}
|
|
3354
|
+
const modal = $("exposeModal");
|
|
3355
|
+
if (modal) modal.hidden = false;
|
|
3356
|
+
refreshExposeList();
|
|
3357
|
+
}
|
|
3358
|
+
function closeExposeModal() {
|
|
3359
|
+
const modal = $("exposeModal");
|
|
3360
|
+
if (modal) modal.hidden = true;
|
|
3361
|
+
exposePendingTx = null;
|
|
3362
|
+
exposePendingPort = 0;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
// Agree → mint the exposure on its host and open the claim link (which sets
|
|
3366
|
+
// the 8h cookie and redirects to the app), then refresh the manager list.
|
|
3367
|
+
async function doExpose() {
|
|
3368
|
+
const tx = exposePendingTx || localTx;
|
|
3369
|
+
const port = exposePendingPort;
|
|
3370
|
+
$("exposePrompt").hidden = true;
|
|
3371
|
+
if (!port) return;
|
|
3372
|
+
let info = null;
|
|
3373
|
+
try {
|
|
3374
|
+
const r = await tx.post("/api/expose", { port });
|
|
3375
|
+
if (r.ok) info = JSON.parse(r.text);
|
|
3376
|
+
} catch {}
|
|
3377
|
+
if (!info || !info.claim) {
|
|
3378
|
+
$("exposeEmpty").hidden = false;
|
|
3379
|
+
$("exposeEmpty").textContent = "Couldn't expose port " + port + " (is `ay serve` reachable?).";
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
window.open(info.claim, "_blank", "noopener,noreferrer");
|
|
3383
|
+
refreshExposeList();
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// Gather active exposures from every live host and render the manager rows.
|
|
3387
|
+
async function refreshExposeList() {
|
|
3388
|
+
const list = $("exposeList");
|
|
3389
|
+
if (!list) return;
|
|
3390
|
+
const rows = [];
|
|
3391
|
+
await Promise.all(
|
|
3392
|
+
[...sources.values()].map(async (s) => {
|
|
3393
|
+
const tx = s.tx || (s.id === "local" ? localTx : null);
|
|
3394
|
+
if (!tx) return;
|
|
3395
|
+
let arr;
|
|
3396
|
+
try {
|
|
3397
|
+
arr = await tx.fetchJSON("/api/exposes");
|
|
3398
|
+
} catch {
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
if (Array.isArray(arr))
|
|
3402
|
+
for (const ex of arr) rows.push({ ...ex, _srcName: s.name || s.id, _tx: tx });
|
|
3403
|
+
}),
|
|
3404
|
+
);
|
|
3405
|
+
list.textContent = "";
|
|
3406
|
+
if (!rows.length) {
|
|
3407
|
+
const p = document.createElement("p");
|
|
3408
|
+
p.className = "sub";
|
|
3409
|
+
p.id = "exposeEmpty";
|
|
3410
|
+
p.textContent = "No ports are exposed right now.";
|
|
3411
|
+
list.appendChild(p);
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
rows.sort((a, b) => b.createdAt - a.createdAt);
|
|
3415
|
+
for (const ex of rows) list.appendChild(renderExposeRow(ex));
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function renderExposeRow(ex) {
|
|
3419
|
+
const row = document.createElement("div");
|
|
3420
|
+
row.className = "exposerow";
|
|
3421
|
+
const meta = document.createElement("div");
|
|
3422
|
+
meta.className = "exmeta";
|
|
3423
|
+
const label = document.createElement("div");
|
|
3424
|
+
label.className = "explabel";
|
|
3425
|
+
label.textContent = "localhost:" + ex.port;
|
|
3426
|
+
const url = document.createElement("div");
|
|
3427
|
+
url.className = "exurl";
|
|
3428
|
+
url.textContent = ex.url;
|
|
3429
|
+
const host = document.createElement("div");
|
|
3430
|
+
host.className = "exhost";
|
|
3431
|
+
host.textContent = ex._srcName;
|
|
3432
|
+
meta.appendChild(label);
|
|
3433
|
+
meta.appendChild(url);
|
|
3434
|
+
meta.appendChild(host);
|
|
3435
|
+
row.appendChild(meta);
|
|
3436
|
+
|
|
3437
|
+
const openBtn = document.createElement("button");
|
|
3438
|
+
openBtn.className = "primary";
|
|
3439
|
+
openBtn.textContent = "Open";
|
|
3440
|
+
// Re-mint a fresh claim so the owner (or a guest) gets a usable link.
|
|
3441
|
+
openBtn.onclick = async () => {
|
|
3442
|
+
try {
|
|
3443
|
+
const r = await ex._tx.post("/api/expose", { port: ex.port });
|
|
3444
|
+
const info = r.ok ? JSON.parse(r.text) : null;
|
|
3445
|
+
window.open(info && info.claim ? info.claim : ex.url, "_blank", "noopener,noreferrer");
|
|
3446
|
+
} catch {
|
|
3447
|
+
window.open(ex.url, "_blank", "noopener,noreferrer");
|
|
3448
|
+
}
|
|
3449
|
+
};
|
|
3450
|
+
row.appendChild(openBtn);
|
|
3451
|
+
|
|
3452
|
+
const revoke = document.createElement("button");
|
|
3453
|
+
revoke.className = "danger";
|
|
3454
|
+
revoke.textContent = "Revoke";
|
|
3455
|
+
revoke.onclick = async () => {
|
|
3456
|
+
revoke.disabled = true;
|
|
3457
|
+
try {
|
|
3458
|
+
await ex._tx.del("/api/expose/" + ex.port);
|
|
3459
|
+
} catch {}
|
|
3460
|
+
refreshExposeList();
|
|
3461
|
+
};
|
|
3462
|
+
row.appendChild(revoke);
|
|
3463
|
+
return row;
|
|
3464
|
+
}
|
|
3245
3465
|
// Draw the QR onto a fresh canvas (white quiet-zone always, so it scans in
|
|
3246
3466
|
// dark mode too). `qrcode` is the vendored global from qrcode.js.
|
|
3247
3467
|
function renderQr(container, text) {
|
|
@@ -3291,6 +3511,18 @@
|
|
|
3291
3511
|
currentShare = null;
|
|
3292
3512
|
closeShareModal();
|
|
3293
3513
|
}
|
|
3514
|
+
(function exposeModalBoot() {
|
|
3515
|
+
$("portsbtn")?.addEventListener("click", () => openExposeManager(true));
|
|
3516
|
+
$("exposeGo")?.addEventListener("click", doExpose);
|
|
3517
|
+
$("exposeCancel")?.addEventListener("click", () => {
|
|
3518
|
+
$("exposePrompt").hidden = true;
|
|
3519
|
+
$("exposeTitle").textContent = "Exposed ports";
|
|
3520
|
+
});
|
|
3521
|
+
$("exposeClose")?.addEventListener("click", closeExposeModal);
|
|
3522
|
+
$("exposeModal")?.addEventListener("click", (e) => {
|
|
3523
|
+
if (e.target === $("exposeModal")) closeExposeModal();
|
|
3524
|
+
});
|
|
3525
|
+
})();
|
|
3294
3526
|
(function shareModalBoot() {
|
|
3295
3527
|
$("shareClose")?.addEventListener("click", closeShareModal);
|
|
3296
3528
|
$("shareCloseAlt")?.addEventListener("click", closeShareModal);
|
|
@@ -4256,9 +4488,13 @@
|
|
|
4256
4488
|
// new tab (noopener so the page can't be tampered with via window.opener).
|
|
4257
4489
|
try {
|
|
4258
4490
|
term.loadAddon(
|
|
4259
|
-
new WebLinksAddon.WebLinksAddon((
|
|
4260
|
-
|
|
4261
|
-
|
|
4491
|
+
new WebLinksAddon.WebLinksAddon((ev, uri) => {
|
|
4492
|
+
// A localhost URL isn't reachable from the viewer's browser — offer
|
|
4493
|
+
// to publish it through agent-yes.com (on THIS agent's host: txFor(e)).
|
|
4494
|
+
const port = localhostPort(uri);
|
|
4495
|
+
if (port) return promptExpose(port, txFor(e));
|
|
4496
|
+
window.open(uri, "_blank", "noopener,noreferrer");
|
|
4497
|
+
}),
|
|
4262
4498
|
);
|
|
4263
4499
|
} catch {
|
|
4264
4500
|
/* addon CDN blocked — terminal still works, just without auto-links */
|
package/package.json
CHANGED
package/ts/expose.ts
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The daemon dials OUT (wss://<relay>/_ay/tunnel/<id>), so it works behind any
|
|
5
5
|
// NAT, and runs the codehost tunnel protocol's host half against the local
|
|
6
|
-
// port. Private by default: visitors need
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// port. Private by default: visitors need a single-use claim link (it swaps
|
|
7
|
+
// for an 8h HttpOnly cookie at the edge; unauthenticated requests never reach
|
|
8
|
+
// this machine). See lab/ui/cf/exposure.ts for the edge.
|
|
9
|
+
//
|
|
10
|
+
// Two front doors share one implementation:
|
|
11
|
+
// - the CLI (`cmdExpose`), which starts one exposure and blocks; and
|
|
12
|
+
// - the in-process manager (`ensureExposure` / `listExposures` /
|
|
13
|
+
// `stopExposure`), which `ay serve` drives from POST /api/expose so the web
|
|
14
|
+
// console can expose a clicked localhost port and revoke it later.
|
|
9
15
|
|
|
10
16
|
import { randomBytes, createHash } from "node:crypto";
|
|
11
17
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -24,13 +30,41 @@ interface ExposureRecord {
|
|
|
24
30
|
key: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
/** Live handle for one exposed port. */
|
|
34
|
+
export interface ExposureHandle {
|
|
35
|
+
/** Opaque exposure id (also the subdomain label). */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Local loopback port being shared. */
|
|
38
|
+
port: number;
|
|
39
|
+
/** Public host, e.g. x….agent-yes.com (or the relay host for a dev relay). */
|
|
40
|
+
publicHost: string;
|
|
41
|
+
/** Public root URL. */
|
|
42
|
+
url: string;
|
|
43
|
+
/** Relay host this exposure is registered on. */
|
|
44
|
+
relayHost: string;
|
|
45
|
+
createdAt: number;
|
|
46
|
+
/** Mint a fresh single-use claim link and register it with the relay. The
|
|
47
|
+
* visitor opening it gets an 8h session cookie for this exposure. */
|
|
48
|
+
mintClaim(): string;
|
|
49
|
+
/** Stop sharing (closes the relay socket; the URL then answers 502). */
|
|
50
|
+
stop(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Serializable view of an active exposure (for the console's ports manager). */
|
|
54
|
+
export interface ExposureInfo {
|
|
55
|
+
id: string;
|
|
56
|
+
port: number;
|
|
57
|
+
url: string;
|
|
58
|
+
createdAt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
27
61
|
function exposuresPath(): string {
|
|
28
62
|
const home = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
|
|
29
63
|
mkdirSync(home, { recursive: true });
|
|
30
64
|
return path.join(home, "exposures.json");
|
|
31
65
|
}
|
|
32
66
|
|
|
33
|
-
/** Stable id+key per (relay, port): re-
|
|
67
|
+
/** Stable id+key per (relay, port): re-exposing a port keeps its URL. */
|
|
34
68
|
function loadOrCreateExposure(relayHost: string, port: number): ExposureRecord {
|
|
35
69
|
const file = exposuresPath();
|
|
36
70
|
let all: Record<string, ExposureRecord> = {};
|
|
@@ -79,6 +113,160 @@ function wsTransport(ws: WebSocket): TunnelTransport {
|
|
|
79
113
|
};
|
|
80
114
|
}
|
|
81
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Start (or fail) one exposure. Resolves once the relay has accepted the daemon
|
|
118
|
+
* and the tunnel is live; rejects if the relay refuses this exposure (bad key).
|
|
119
|
+
* Reconnects with backoff for the life of the handle.
|
|
120
|
+
*/
|
|
121
|
+
export function startExposure(opts: {
|
|
122
|
+
port: number;
|
|
123
|
+
relay?: string;
|
|
124
|
+
/** Log lifecycle transitions (CLI wants this; the manager stays quiet). */
|
|
125
|
+
log?: (msg: string) => void;
|
|
126
|
+
}): Promise<ExposureHandle> {
|
|
127
|
+
const relay = opts.relay ?? DEFAULT_RELAY;
|
|
128
|
+
const port = opts.port;
|
|
129
|
+
const log = opts.log ?? (() => {});
|
|
130
|
+
const relayUrl = new URL(relay);
|
|
131
|
+
const rec = loadOrCreateExposure(relayUrl.host, port);
|
|
132
|
+
const wsProto = relayUrl.protocol === "http:" ? "ws:" : "wss:";
|
|
133
|
+
const tunnelUrl = `${wsProto}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
|
|
134
|
+
// Public hostname: <id>.<zone> on the real relay; the relay host itself (with
|
|
135
|
+
// a Host-header spoof) when pointing at a dev relay (wrangler dev).
|
|
136
|
+
const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
|
|
137
|
+
const publicUrl = `https://${publicHost}/`;
|
|
138
|
+
|
|
139
|
+
let stopped = false;
|
|
140
|
+
let sock: WebSocket | null = null;
|
|
141
|
+
let ready = false;
|
|
142
|
+
let backoff = RECONNECT_MIN_MS;
|
|
143
|
+
|
|
144
|
+
const handle: ExposureHandle = {
|
|
145
|
+
id: rec.id,
|
|
146
|
+
port,
|
|
147
|
+
publicHost,
|
|
148
|
+
url: publicUrl,
|
|
149
|
+
relayHost: relayUrl.host,
|
|
150
|
+
createdAt: Date.now(),
|
|
151
|
+
mintClaim() {
|
|
152
|
+
const token = randomBytes(18).toString("base64url");
|
|
153
|
+
const hash = createHash("sha256").update(token).digest("hex");
|
|
154
|
+
if (sock && sock.readyState === WebSocket.OPEN) {
|
|
155
|
+
sock.send(JSON.stringify({ t: "claim", claims: [hash] }));
|
|
156
|
+
}
|
|
157
|
+
return `https://${publicHost}/_ay/claim?t=${token}`;
|
|
158
|
+
},
|
|
159
|
+
stop() {
|
|
160
|
+
stopped = true;
|
|
161
|
+
try {
|
|
162
|
+
sock?.close();
|
|
163
|
+
} catch {
|
|
164
|
+
/* ignore */
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return new Promise<ExposureHandle>((resolve, reject) => {
|
|
170
|
+
const connect = () => {
|
|
171
|
+
if (stopped) return;
|
|
172
|
+
sock = new WebSocket(tunnelUrl);
|
|
173
|
+
sock.binaryType = "arraybuffer";
|
|
174
|
+
const ws = sock;
|
|
175
|
+
let ping: ReturnType<typeof setInterval> | null = null;
|
|
176
|
+
|
|
177
|
+
ws.addEventListener("open", () => {
|
|
178
|
+
ws.send(JSON.stringify({ t: "hello", key: rec.key, port, v: 1 }));
|
|
179
|
+
});
|
|
180
|
+
ws.addEventListener("message", (ev) => {
|
|
181
|
+
if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
|
|
182
|
+
let msg: { t?: string };
|
|
183
|
+
try {
|
|
184
|
+
msg = JSON.parse(ev.data);
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (msg.t === "ready") {
|
|
189
|
+
backoff = RECONNECT_MIN_MS;
|
|
190
|
+
new TunnelHost(wsTransport(ws), { port });
|
|
191
|
+
ping = setInterval(() => {
|
|
192
|
+
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
|
|
193
|
+
}, PING_MS);
|
|
194
|
+
if (!ready) {
|
|
195
|
+
ready = true;
|
|
196
|
+
log(`sharing 127.0.0.1:${port} at ${publicUrl}`);
|
|
197
|
+
resolve(handle);
|
|
198
|
+
} else {
|
|
199
|
+
log(`reconnected`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
ws.addEventListener("close", (ev) => {
|
|
204
|
+
if (ping) clearInterval(ping);
|
|
205
|
+
if (stopped) return;
|
|
206
|
+
if (ev.code === 1008) {
|
|
207
|
+
const err = new Error(`relay refused exposure (${ev.reason || "forbidden"})`);
|
|
208
|
+
if (!ready) return reject(err);
|
|
209
|
+
log(err.message);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
log(`connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
|
|
213
|
+
setTimeout(connect, backoff);
|
|
214
|
+
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
215
|
+
});
|
|
216
|
+
ws.addEventListener("error", () => {
|
|
217
|
+
/* close fires right after; retry there */
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
connect();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- in-process manager (driven by `ay serve` POST /api/expose) ----
|
|
225
|
+
|
|
226
|
+
const active = new Map<number, ExposureHandle>();
|
|
227
|
+
/** In-flight starts, so concurrent POSTs for the same port share one dial. */
|
|
228
|
+
const starting = new Map<number, Promise<ExposureHandle>>();
|
|
229
|
+
|
|
230
|
+
/** Start an exposure for `port` (or reuse a running one). Idempotent per port. */
|
|
231
|
+
export async function ensureExposure(port: number, relay?: string): Promise<ExposureHandle> {
|
|
232
|
+
const existing = active.get(port);
|
|
233
|
+
if (existing) return existing;
|
|
234
|
+
const inflight = starting.get(port);
|
|
235
|
+
if (inflight) return inflight;
|
|
236
|
+
const p = startExposure({ port, relay })
|
|
237
|
+
.then((h) => {
|
|
238
|
+
active.set(port, h);
|
|
239
|
+
starting.delete(port);
|
|
240
|
+
return h;
|
|
241
|
+
})
|
|
242
|
+
.catch((e) => {
|
|
243
|
+
starting.delete(port);
|
|
244
|
+
throw e;
|
|
245
|
+
});
|
|
246
|
+
starting.set(port, p);
|
|
247
|
+
return p;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function listExposures(): ExposureInfo[] {
|
|
251
|
+
return [...active.values()]
|
|
252
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
253
|
+
.map((h) => ({ id: h.id, port: h.port, url: h.url, createdAt: h.createdAt }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function stopExposure(port: number): boolean {
|
|
257
|
+
const h = active.get(port);
|
|
258
|
+
if (!h) return false;
|
|
259
|
+
h.stop();
|
|
260
|
+
active.delete(port);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function stopAllExposures(): void {
|
|
265
|
+
for (const port of [...active.keys()]) stopExposure(port);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- CLI ----
|
|
269
|
+
|
|
82
270
|
export async function cmdExpose(args: string[]): Promise<number> {
|
|
83
271
|
let relay = DEFAULT_RELAY;
|
|
84
272
|
let port = 0;
|
|
@@ -102,83 +290,21 @@ export async function cmdExpose(args: string[]): Promise<number> {
|
|
|
102
290
|
return 1;
|
|
103
291
|
}
|
|
104
292
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let stopped = false;
|
|
118
|
-
let ws: WebSocket | null = null;
|
|
119
|
-
let backoff = RECONNECT_MIN_MS;
|
|
120
|
-
let announced = false;
|
|
121
|
-
|
|
122
|
-
const connect = () => {
|
|
123
|
-
if (stopped) return;
|
|
124
|
-
ws = new WebSocket(tunnelUrl);
|
|
125
|
-
ws.binaryType = "arraybuffer";
|
|
126
|
-
const sock = ws;
|
|
127
|
-
let ping: ReturnType<typeof setInterval> | null = null;
|
|
128
|
-
|
|
129
|
-
sock.addEventListener("open", () => {
|
|
130
|
-
sock.send(JSON.stringify({ t: "hello", key: rec.key, port, claims: [claimHash], v: 1 }));
|
|
131
|
-
});
|
|
132
|
-
sock.addEventListener("message", (ev) => {
|
|
133
|
-
if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
|
|
134
|
-
let msg: { t?: string };
|
|
135
|
-
try {
|
|
136
|
-
msg = JSON.parse(ev.data);
|
|
137
|
-
} catch {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (msg.t === "ready") {
|
|
141
|
-
backoff = RECONNECT_MIN_MS;
|
|
142
|
-
new TunnelHost(wsTransport(sock), { port });
|
|
143
|
-
ping = setInterval(() => {
|
|
144
|
-
if (sock.readyState === WebSocket.OPEN) sock.send("ping");
|
|
145
|
-
}, PING_MS);
|
|
146
|
-
if (!announced) {
|
|
147
|
-
announced = true;
|
|
148
|
-
console.log(`[ay expose] sharing 127.0.0.1:${port}`);
|
|
149
|
-
console.log(` url: https://${publicHost}/`);
|
|
150
|
-
console.log(` claim: https://${publicHost}/_ay/claim?t=${claimToken}`);
|
|
151
|
-
console.log(` (one-time link — opens access for 8h in that browser)`);
|
|
152
|
-
} else {
|
|
153
|
-
console.log(`[ay expose] reconnected`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
sock.addEventListener("close", (ev) => {
|
|
158
|
-
if (ping) clearInterval(ping);
|
|
159
|
-
if (stopped) return;
|
|
160
|
-
if (ev.code === 1008) {
|
|
161
|
-
console.error(`[ay expose] relay refused this exposure (${ev.reason || "forbidden"}) — giving up`);
|
|
162
|
-
process.exit(1);
|
|
163
|
-
}
|
|
164
|
-
console.log(`[ay expose] connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
|
|
165
|
-
setTimeout(connect, backoff);
|
|
166
|
-
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
167
|
-
});
|
|
168
|
-
sock.addEventListener("error", () => {
|
|
169
|
-
/* close fires right after; retry there */
|
|
170
|
-
});
|
|
171
|
-
};
|
|
172
|
-
connect();
|
|
293
|
+
let handle: ExposureHandle;
|
|
294
|
+
try {
|
|
295
|
+
handle = await startExposure({ port, relay, log: (m) => console.log(`[ay expose] ${m}`) });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(`[ay expose] ${(e as Error).message} — giving up`);
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
const claimUrl = handle.mintClaim();
|
|
301
|
+
console.log(` url: ${handle.url}`);
|
|
302
|
+
console.log(` claim: ${claimUrl}`);
|
|
303
|
+
console.log(` (one-time link — opens access for 8h in that browser)`);
|
|
173
304
|
|
|
174
305
|
const shutdown = () => {
|
|
175
|
-
stopped = true;
|
|
176
306
|
console.log("\n[ay expose] stopped — the URL now answers 502 until you expose again");
|
|
177
|
-
|
|
178
|
-
ws?.close();
|
|
179
|
-
} catch {
|
|
180
|
-
/* ignore */
|
|
181
|
-
}
|
|
307
|
+
handle.stop();
|
|
182
308
|
process.exit(0);
|
|
183
309
|
};
|
|
184
310
|
process.on("SIGINT", shutdown);
|
package/ts/serve.ts
CHANGED
|
@@ -2729,6 +2729,42 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2729
2729
|
return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
|
|
2730
2730
|
}
|
|
2731
2731
|
|
|
2732
|
+
// POST /api/expose body {port} → share 127.0.0.1:<port> through the edge
|
|
2733
|
+
// relay and return {url, claim} (a fresh single-use claim link each call).
|
|
2734
|
+
if (req.method === "POST" && p === "/api/expose") {
|
|
2735
|
+
let body: { port?: number; relay?: string };
|
|
2736
|
+
try {
|
|
2737
|
+
body = (await req.json()) as typeof body;
|
|
2738
|
+
} catch {
|
|
2739
|
+
return new Response("invalid JSON body", { status: 400 });
|
|
2740
|
+
}
|
|
2741
|
+
const port = Number(body.port);
|
|
2742
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
2743
|
+
return new Response("valid port required", { status: 400 });
|
|
2744
|
+
}
|
|
2745
|
+
try {
|
|
2746
|
+
const { ensureExposure } = await import("./expose.ts");
|
|
2747
|
+
const h = await ensureExposure(port, body.relay);
|
|
2748
|
+
return Response.json({ id: h.id, port: h.port, url: h.url, claim: h.mintClaim(), createdAt: h.createdAt });
|
|
2749
|
+
} catch (e) {
|
|
2750
|
+
return new Response(`expose failed: ${(e as Error).message}`, { status: 502 });
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// GET /api/exposes → active port exposures (for the console's ports manager).
|
|
2755
|
+
if (req.method === "GET" && p === "/api/exposes") {
|
|
2756
|
+
const { listExposures } = await import("./expose.ts");
|
|
2757
|
+
return Response.json(listExposures());
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// DELETE /api/expose/:port → revoke (the URL then answers 502).
|
|
2761
|
+
const unexposeM = /^\/api\/expose\/(\d+)$/.exec(p);
|
|
2762
|
+
if (req.method === "DELETE" && unexposeM) {
|
|
2763
|
+
const { stopExposure } = await import("./expose.ts");
|
|
2764
|
+
const ok = stopExposure(Number(unexposeM[1]));
|
|
2765
|
+
return new Response(ok ? "revoked" : "no such exposure", { status: ok ? 200 : 404 });
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2732
2768
|
return new Response("Not Found", { status: 404 });
|
|
2733
2769
|
};
|
|
2734
2770
|
|
|
@@ -2793,10 +2829,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2793
2829
|
return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
|
|
2794
2830
|
if (req.method === "GET" && p === "/console-logic.js")
|
|
2795
2831
|
return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
|
|
2832
|
+
// rtc.js is a STATIC import of the console module (import { RTCClient }) — a
|
|
2833
|
+
// 401 here fails the whole module link and the console never boots.
|
|
2834
|
+
if (req.method === "GET" && p === "/rtc.js")
|
|
2835
|
+
return serveUiFile("rtc.js", "text/javascript; charset=utf-8");
|
|
2796
2836
|
if (req.method === "GET" && p === "/e2e.js")
|
|
2797
2837
|
return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
|
|
2798
2838
|
if (req.method === "GET" && p === "/qrcode.js")
|
|
2799
2839
|
return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
|
|
2840
|
+
if (req.method === "GET" && p === "/manifest.webmanifest")
|
|
2841
|
+
return serveUiFile("manifest.webmanifest", "application/manifest+json");
|
|
2842
|
+
if (req.method === "GET" && p === "/icon.svg")
|
|
2843
|
+
return serveUiFile("icon.svg", "image/svg+xml");
|
|
2800
2844
|
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
2801
2845
|
return apiFetch(req);
|
|
2802
2846
|
};
|