@monster-orange/den 0.4.4 → 0.4.5
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/CHANGELOG.md +169 -0
- package/README.md +10 -9
- package/docs/distributions.md +223 -26
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +102 -35
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -2,6 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { execFileSync, spawn, spawnSync } from "child_process";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as http from "http";
|
|
5
|
+
import * as net from "net";
|
|
5
6
|
import * as path from "path";
|
|
6
7
|
import { pathToFileURL } from "url";
|
|
7
8
|
import { CLI_SPAWN_TIMEOUT_MS, DEFAULT_PORT } from "../../shared/constants.js";
|
|
@@ -20,7 +21,9 @@ import {
|
|
|
20
21
|
readWebSecret,
|
|
21
22
|
rotateWebSecret,
|
|
22
23
|
} from "../../shared/web-secret.js";
|
|
24
|
+
import { newConfirmCode } from "../../shared/confirm-code.js";
|
|
23
25
|
import {
|
|
26
|
+
checkLoginCode,
|
|
24
27
|
ignoredConfigWarning,
|
|
25
28
|
localhostOrigin,
|
|
26
29
|
loginLink,
|
|
@@ -35,6 +38,24 @@ import { revokeAllActorTokens } from "../../shared/actor-token.js";
|
|
|
35
38
|
import { engineBin, engineModuleFile } from "../../shared/engine.js";
|
|
36
39
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
37
40
|
import { webRouteHosts } from "../../shared/edge/render.js";
|
|
41
|
+
import {
|
|
42
|
+
expiringTokenWarning,
|
|
43
|
+
GITHUB_DEVICE_PAGE,
|
|
44
|
+
githubClientId,
|
|
45
|
+
githubEndpoints,
|
|
46
|
+
noClientIdMessage,
|
|
47
|
+
} from "../../shared/github-app.js";
|
|
48
|
+
import {
|
|
49
|
+
githubDeviceClient,
|
|
50
|
+
githubPrincipal,
|
|
51
|
+
runDeviceFlow,
|
|
52
|
+
type GithubDeviceClient,
|
|
53
|
+
} from "../../shared/github-device.js";
|
|
54
|
+
import {
|
|
55
|
+
clearWebOwner,
|
|
56
|
+
readWebOwner,
|
|
57
|
+
writeWebOwner,
|
|
58
|
+
} from "../../shared/web-owner.js";
|
|
38
59
|
import {
|
|
39
60
|
ownServer,
|
|
40
61
|
processCwd,
|
|
@@ -225,6 +246,24 @@ export function buildDenWebUrl(
|
|
|
225
246
|
return `http://${resolveDenWebHost(hostsContent)}:${port}`;
|
|
226
247
|
}
|
|
227
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The address `web up` names: the bound one when DEN_BIND_HOST is a
|
|
251
|
+
* specific address beyond loopback (a LAN-only bind doesn't answer on
|
|
252
|
+
* localhost), else buildDenWebUrl's.
|
|
253
|
+
*/
|
|
254
|
+
export function webUpUrl(
|
|
255
|
+
port: number,
|
|
256
|
+
bind: string | undefined = process.env.DEN_BIND_HOST,
|
|
257
|
+
hostsContent?: string,
|
|
258
|
+
): string {
|
|
259
|
+
const to = webProbeTarget(bind, port);
|
|
260
|
+
const loopback =
|
|
261
|
+
to.host === "::1" || /^127\./.test(to.host) || to.host === "localhost";
|
|
262
|
+
return loopback
|
|
263
|
+
? buildDenWebUrl(port, hostsContent)
|
|
264
|
+
: `http://${to.hostHeader}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
export function denWebUrlAliases(port: number): string[] {
|
|
229
268
|
return [
|
|
230
269
|
`http://${product().cli}:${port}`,
|
|
@@ -252,6 +291,29 @@ export function isDenWebTabUrl(rawUrl: string, port: number): boolean {
|
|
|
252
291
|
}
|
|
253
292
|
}
|
|
254
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Where `web up`'s probes (readiness, the CLI's own calls) reach the
|
|
296
|
+
* server, and the Host they send: the address DEN_BIND_HOST binds. Unset,
|
|
297
|
+
* 0.0.0.0 or `::` answer on loopback, so 127.0.0.1; one specific address
|
|
298
|
+
* (a LAN IP with web.allow_plain_http_remote, or ::1) answers only there.
|
|
299
|
+
* The Host names that address (an IPv6 one bracketed): the Host allowlist
|
|
300
|
+
* accepts it (a loopback name, or the bound LAN address).
|
|
301
|
+
*/
|
|
302
|
+
export function webProbeTarget(
|
|
303
|
+
bind: string | undefined,
|
|
304
|
+
port: number,
|
|
305
|
+
): { host: string; hostHeader: string } {
|
|
306
|
+
const b = (bind ?? "")
|
|
307
|
+
.trim()
|
|
308
|
+
.toLowerCase()
|
|
309
|
+
.replace(/^\[|\]$/g, "");
|
|
310
|
+
const host = b === "" || b === "0.0.0.0" || b === "::" ? "127.0.0.1" : b;
|
|
311
|
+
return {
|
|
312
|
+
host,
|
|
313
|
+
hostHeader: `${net.isIPv6(host) ? `[${host}]` : host}:${port}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
255
317
|
async function getHttpText(
|
|
256
318
|
port: number,
|
|
257
319
|
requestPath: string,
|
|
@@ -266,12 +328,18 @@ async function getHttpText(
|
|
|
266
328
|
resolve(value);
|
|
267
329
|
};
|
|
268
330
|
|
|
331
|
+
const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
|
|
269
332
|
const req = http.get(
|
|
270
333
|
{
|
|
271
|
-
host:
|
|
334
|
+
host: to.host,
|
|
272
335
|
port,
|
|
273
336
|
path: requestPath,
|
|
274
337
|
timeout: 1_500,
|
|
338
|
+
agent: false,
|
|
339
|
+
setHost: false,
|
|
340
|
+
// The CLI's bearer, as serverCall sends: an address beyond loopback
|
|
341
|
+
// answers an unsigned probe 401 signin_required, never ready.
|
|
342
|
+
headers: { host: to.hostHeader, ...cliAuthHeaders() },
|
|
275
343
|
},
|
|
276
344
|
(res) => {
|
|
277
345
|
let body = "";
|
|
@@ -1072,6 +1140,8 @@ export const WEB_SUBCOMMANDS_WITH_HELP = [
|
|
|
1072
1140
|
"login",
|
|
1073
1141
|
"sessions",
|
|
1074
1142
|
"logout",
|
|
1143
|
+
"owner",
|
|
1144
|
+
"approve",
|
|
1075
1145
|
] as const;
|
|
1076
1146
|
|
|
1077
1147
|
export function displayWebHelp(): void {
|
|
@@ -1082,12 +1152,16 @@ export function displayWebHelp(): void {
|
|
|
1082
1152
|
console.log(` ${product().cli} web --setup-browser-access`);
|
|
1083
1153
|
console.log(` ${product().cli} web token --print`);
|
|
1084
1154
|
console.log(
|
|
1085
|
-
` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--port <port>]`,
|
|
1155
|
+
` ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]`,
|
|
1086
1156
|
);
|
|
1087
1157
|
console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
|
|
1088
1158
|
console.log(
|
|
1089
1159
|
` ${product().cli} web logout [<id prefix>] [--all] [--port <port>]`,
|
|
1090
1160
|
);
|
|
1161
|
+
console.log(` ${product().cli} web owner [link|show|unlink] [--json]`);
|
|
1162
|
+
console.log(
|
|
1163
|
+
` ${product().cli} web approve <code> | --list [--json] [--port <port>]`,
|
|
1164
|
+
);
|
|
1091
1165
|
console.log();
|
|
1092
1166
|
console.log(chalk.bold("Actions:"));
|
|
1093
1167
|
console.log(
|
|
@@ -1101,7 +1175,7 @@ export function displayWebHelp(): void {
|
|
|
1101
1175
|
" open Start if needed, and sign this browser in (a new tab)",
|
|
1102
1176
|
);
|
|
1103
1177
|
console.log(
|
|
1104
|
-
" login Print a one-time sign-in link (60 s),
|
|
1178
|
+
" login Print a one-time sign-in link (60 s) and its code, then wait until it's used",
|
|
1105
1179
|
);
|
|
1106
1180
|
console.log(" sessions List the signed-in browsers");
|
|
1107
1181
|
console.log(
|
|
@@ -1110,6 +1184,12 @@ export function displayWebHelp(): void {
|
|
|
1110
1184
|
console.log(
|
|
1111
1185
|
" token --print Print your CLI token, for curl (Authorization: Bearer …)",
|
|
1112
1186
|
);
|
|
1187
|
+
console.log(
|
|
1188
|
+
" owner link Link your GitHub account as this Den's owner (Sign in with GitHub)",
|
|
1189
|
+
);
|
|
1190
|
+
console.log(
|
|
1191
|
+
" approve <code> Let in a computer whose signed-out page shows this code",
|
|
1192
|
+
);
|
|
1113
1193
|
console.log(" (start/stop remain accepted as legacy aliases for up/down)");
|
|
1114
1194
|
console.log();
|
|
1115
1195
|
console.log(chalk.bold("Options:"));
|
|
@@ -1174,7 +1254,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1174
1254
|
return;
|
|
1175
1255
|
}
|
|
1176
1256
|
if (args[0] === "login") {
|
|
1177
|
-
process.exitCode =
|
|
1257
|
+
process.exitCode = await cmdWebLoginWait(args.slice(1));
|
|
1178
1258
|
return;
|
|
1179
1259
|
}
|
|
1180
1260
|
if (args[0] === "sessions") {
|
|
@@ -1185,6 +1265,14 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1185
1265
|
process.exitCode = await cmdWebLogout(args.slice(1));
|
|
1186
1266
|
return;
|
|
1187
1267
|
}
|
|
1268
|
+
if (args[0] === "owner") {
|
|
1269
|
+
process.exitCode = await cmdWebOwner(args.slice(1));
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (args[0] === "approve") {
|
|
1273
|
+
process.exitCode = await cmdWebApprove(args.slice(1));
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1188
1276
|
if (args.includes("--help") || args.includes("-h")) {
|
|
1189
1277
|
displayWebHelp();
|
|
1190
1278
|
return;
|
|
@@ -1196,7 +1284,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1196
1284
|
return;
|
|
1197
1285
|
}
|
|
1198
1286
|
|
|
1199
|
-
const url =
|
|
1287
|
+
const url = webUpUrl(options.port);
|
|
1200
1288
|
|
|
1201
1289
|
// Never act on another distribution's server: not "already running", and
|
|
1202
1290
|
// never a signal to it (down, restart).
|
|
@@ -1392,31 +1480,19 @@ function takePort(args: string[]): { port: number; rest: string[] } {
|
|
|
1392
1480
|
return { port, rest };
|
|
1393
1481
|
}
|
|
1394
1482
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
io.out(
|
|
1406
|
-
usage +
|
|
1407
|
-
" Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
|
|
1408
|
-
" On localhost nothing asks for a sign-in; it is needed when this server is\n" +
|
|
1409
|
-
" reached from another machine (pass the edge hostname as --host, or a LAN\n" +
|
|
1410
|
-
" address as --origin).\n" +
|
|
1411
|
-
" --next <path> where the browser lands after signing in (same origin only)\n" +
|
|
1412
|
-
" --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
|
|
1413
|
-
" a VPN name among them): the link uses its edge URL\n" +
|
|
1414
|
-
" --origin <url> the address the browser uses, http(s)://host[:port]\n",
|
|
1415
|
-
);
|
|
1416
|
-
return 0;
|
|
1417
|
-
}
|
|
1483
|
+
const LOGIN_USAGE = () =>
|
|
1484
|
+
`Usage: ${product().cli} web login [--next <path>] [--host <name> | --origin <url>] [--no-wait] [--port <port>]\n`;
|
|
1485
|
+
|
|
1486
|
+
/** `web login`'s arguments, or an exit code (usage or a bad value, already said). */
|
|
1487
|
+
function parseLoginArgs(
|
|
1488
|
+
args: string[],
|
|
1489
|
+
io: CliIo,
|
|
1490
|
+
):
|
|
1491
|
+
| { port: number; next: string; origin: string | null; noWait: boolean }
|
|
1492
|
+
| number {
|
|
1418
1493
|
let next = "/";
|
|
1419
1494
|
let origin: string | null = null;
|
|
1495
|
+
let noWait = false;
|
|
1420
1496
|
let port: number;
|
|
1421
1497
|
let rest: string[];
|
|
1422
1498
|
try {
|
|
@@ -1433,6 +1509,10 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
|
1433
1509
|
: a.startsWith(`${flag}=`)
|
|
1434
1510
|
? a.slice(flag.length + 1)
|
|
1435
1511
|
: undefined;
|
|
1512
|
+
if (a === "--no-wait") {
|
|
1513
|
+
noWait = true;
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1436
1516
|
const n = val("--next");
|
|
1437
1517
|
const o = val("--origin");
|
|
1438
1518
|
const h = val("--host");
|
|
@@ -1453,14 +1533,46 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
|
1453
1533
|
return 2;
|
|
1454
1534
|
}
|
|
1455
1535
|
} else {
|
|
1456
|
-
io.err(
|
|
1536
|
+
io.err(LOGIN_USAGE());
|
|
1457
1537
|
return 2;
|
|
1458
1538
|
}
|
|
1459
1539
|
}
|
|
1540
|
+
return { port, next, origin, noWait };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const LOGIN_HELP = () =>
|
|
1544
|
+
LOGIN_USAGE() +
|
|
1545
|
+
" Prints a one-time sign-in link (open it within 60 s) for one browser, and a\n" +
|
|
1546
|
+
" code the page behind the link asks for, then waits until the link is used:\n" +
|
|
1547
|
+
' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
|
|
1548
|
+
' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
|
|
1549
|
+
" this server is reached from another machine (pass the edge hostname as\n" +
|
|
1550
|
+
" --host, or a LAN address as --origin).\n" +
|
|
1551
|
+
" --next <path> where the browser lands after signing in (same origin only)\n" +
|
|
1552
|
+
" --host <name> an edge hostname that routes here (an edge domain or alias,\n" +
|
|
1553
|
+
" a VPN name among them): the link uses its edge URL\n" +
|
|
1554
|
+
" --origin <url> the address the browser uses, http(s)://host[:port]\n" +
|
|
1555
|
+
" --no-wait print the link and exit; the link signs in by itself, no\n" +
|
|
1556
|
+
" code (for scripts)\n";
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* `<cli> web login --no-wait`: a fresh sign-in link on stdout, alone (the
|
|
1560
|
+
* only place a login code is ever printed); a note on stderr. `--host` for
|
|
1561
|
+
* an edge hostname (its URL from the edge config), `--origin` for any
|
|
1562
|
+
* allowed origin (a LAN name), where the cookie must be set (the
|
|
1563
|
+
* signed-out page there says which). The link signs in when opened.
|
|
1564
|
+
*/
|
|
1565
|
+
export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
1566
|
+
if (isHelp(args)) {
|
|
1567
|
+
io.out(LOGIN_HELP());
|
|
1568
|
+
return 0;
|
|
1569
|
+
}
|
|
1570
|
+
const a = parseLoginArgs(args, io);
|
|
1571
|
+
if (typeof a === "number") return a;
|
|
1460
1572
|
const link = freshLoginLink(
|
|
1461
|
-
port,
|
|
1462
|
-
next,
|
|
1463
|
-
origin ?? localhostOrigin(port),
|
|
1573
|
+
a.port,
|
|
1574
|
+
a.next,
|
|
1575
|
+
a.origin ?? localhostOrigin(a.port),
|
|
1464
1576
|
io.stateDir ?? getStateDir(),
|
|
1465
1577
|
);
|
|
1466
1578
|
io.err(
|
|
@@ -1470,6 +1582,121 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
|
|
|
1470
1582
|
return 0;
|
|
1471
1583
|
}
|
|
1472
1584
|
|
|
1585
|
+
/** "10:42" in this machine's time. */
|
|
1586
|
+
function clockTime(iso: string): string {
|
|
1587
|
+
const d = new Date(iso);
|
|
1588
|
+
return Number.isNaN(d.getTime())
|
|
1589
|
+
? iso
|
|
1590
|
+
: `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* `<cli> web login` (SEC-2a): the link and an 8-character code, then a
|
|
1595
|
+
* wait until the link is used or expires. The page behind the link asks
|
|
1596
|
+
* for the code before it signs anything in. The CLI registers the link's
|
|
1597
|
+
* nonce and the code with the running server (its bearer) and polls that
|
|
1598
|
+
* wait; the code is shown here only, never logged. With no server running
|
|
1599
|
+
* (or `--no-wait`) it prints the link alone, as before.
|
|
1600
|
+
*/
|
|
1601
|
+
export async function cmdWebLoginWait(
|
|
1602
|
+
args: string[],
|
|
1603
|
+
io: CliIo = stdIo,
|
|
1604
|
+
opts: { pollMs?: number; maxWaitMs?: number } = {},
|
|
1605
|
+
): Promise<number> {
|
|
1606
|
+
if (isHelp(args)) {
|
|
1607
|
+
io.out(LOGIN_HELP());
|
|
1608
|
+
return 0;
|
|
1609
|
+
}
|
|
1610
|
+
const a = parseLoginArgs(args, io);
|
|
1611
|
+
if (typeof a === "number") return a;
|
|
1612
|
+
if (a.noWait)
|
|
1613
|
+
return cmdWebLogin(
|
|
1614
|
+
args.filter((x) => x !== "--no-wait"),
|
|
1615
|
+
io,
|
|
1616
|
+
);
|
|
1617
|
+
const stateDir = io.stateDir ?? getStateDir();
|
|
1618
|
+
const secret = ensureWebSecret(stateDir);
|
|
1619
|
+
const now = Date.now();
|
|
1620
|
+
const loginCode = mintLoginCode(secret, now);
|
|
1621
|
+
const checked = checkLoginCode(loginCode, secret, now);
|
|
1622
|
+
if (!checked.ok) {
|
|
1623
|
+
io.err(`${product().cli}: could not make a sign-in link\n`);
|
|
1624
|
+
return 1;
|
|
1625
|
+
}
|
|
1626
|
+
const confirm = newConfirmCode();
|
|
1627
|
+
const reg = await serverCall(stateDir, a.port, "POST", "/api/web/logins", {
|
|
1628
|
+
nonce: checked.nonce,
|
|
1629
|
+
code: confirm,
|
|
1630
|
+
expiresAt: checked.expiresAt,
|
|
1631
|
+
});
|
|
1632
|
+
const link = loginLink(
|
|
1633
|
+
a.origin ?? localhostOrigin(a.port),
|
|
1634
|
+
loginCode,
|
|
1635
|
+
a.next,
|
|
1636
|
+
);
|
|
1637
|
+
if (!reg) {
|
|
1638
|
+
io.err(
|
|
1639
|
+
`${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
|
|
1640
|
+
"doesn't wait and the link asks for no code (as --no-wait). Start the server " +
|
|
1641
|
+
`(\`${product().cli} web up\`), then open this link within 60 seconds; it signs in one browser, once.\n`,
|
|
1642
|
+
);
|
|
1643
|
+
io.out(`${link}\n`);
|
|
1644
|
+
return 0;
|
|
1645
|
+
}
|
|
1646
|
+
const id = (reg.json as { id?: unknown } | null)?.id;
|
|
1647
|
+
if (reg.status !== 200 || typeof id !== "string") {
|
|
1648
|
+
io.err(`${product().cli}: ${apiError(reg)}\n`);
|
|
1649
|
+
return 1;
|
|
1650
|
+
}
|
|
1651
|
+
io.err(
|
|
1652
|
+
`${product().cli}: open this link within 60 seconds, in the browser to sign in. ` +
|
|
1653
|
+
`It asks for this code: ${confirm}\n`,
|
|
1654
|
+
);
|
|
1655
|
+
io.out(`${link}\n`);
|
|
1656
|
+
io.err(`${product().cli}: waiting for the link to be used…\n`);
|
|
1657
|
+
const pollMs = opts.pollMs ?? 1000;
|
|
1658
|
+
const deadline = Date.now() + (opts.maxWaitMs ?? 7 * 60_000);
|
|
1659
|
+
for (;;) {
|
|
1660
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
1661
|
+
const r = await serverCall(
|
|
1662
|
+
stateDir,
|
|
1663
|
+
a.port,
|
|
1664
|
+
"GET",
|
|
1665
|
+
`/api/web/logins/${id}`,
|
|
1666
|
+
);
|
|
1667
|
+
const j = (r?.json ?? {}) as {
|
|
1668
|
+
state?: string;
|
|
1669
|
+
device?: string;
|
|
1670
|
+
client?: string;
|
|
1671
|
+
usedAt?: string;
|
|
1672
|
+
};
|
|
1673
|
+
if (r && j.state === "used") {
|
|
1674
|
+
io.out(
|
|
1675
|
+
`Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
|
|
1676
|
+
` at ${clockTime(j.usedAt ?? new Date().toISOString())}\n`,
|
|
1677
|
+
);
|
|
1678
|
+
return 0;
|
|
1679
|
+
}
|
|
1680
|
+
if (r && j.state === "refused") {
|
|
1681
|
+
io.err("Refused: too many wrong codes; the link no longer works\n");
|
|
1682
|
+
return 1;
|
|
1683
|
+
}
|
|
1684
|
+
if (
|
|
1685
|
+
(r && (j.state === "expired" || r.status === 404)) ||
|
|
1686
|
+
Date.now() > deadline
|
|
1687
|
+
) {
|
|
1688
|
+
io.err("Expired: nobody used the link\n");
|
|
1689
|
+
return 1;
|
|
1690
|
+
}
|
|
1691
|
+
if (!r) {
|
|
1692
|
+
io.err(
|
|
1693
|
+
`${product().cli}: the server stopped answering; the link can't be used now\n`,
|
|
1694
|
+
);
|
|
1695
|
+
return 1;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1473
1700
|
/**
|
|
1474
1701
|
* `web login --host <name>`: the origin a browser uses for an edge hostname
|
|
1475
1702
|
* that routes to this server (edge.routes under any edge domain or alias),
|
|
@@ -1523,14 +1750,17 @@ function serverCall(
|
|
|
1523
1750
|
): Promise<{ status: number; json: unknown } | null> {
|
|
1524
1751
|
return new Promise((resolve) => {
|
|
1525
1752
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
1753
|
+
const to = webProbeTarget(process.env.DEN_BIND_HOST, port);
|
|
1526
1754
|
const req = http.request(
|
|
1527
1755
|
{
|
|
1528
|
-
host:
|
|
1756
|
+
host: to.host,
|
|
1529
1757
|
port,
|
|
1530
1758
|
path: p,
|
|
1531
1759
|
method,
|
|
1532
1760
|
timeout: 10_000,
|
|
1761
|
+
setHost: false,
|
|
1533
1762
|
headers: {
|
|
1763
|
+
host: to.hostHeader,
|
|
1534
1764
|
...cliAuthHeaders(stateDir),
|
|
1535
1765
|
...(payload !== undefined
|
|
1536
1766
|
? {
|
|
@@ -1737,3 +1967,252 @@ export async function cmdWebLogout(
|
|
|
1737
1967
|
);
|
|
1738
1968
|
return 0;
|
|
1739
1969
|
}
|
|
1970
|
+
|
|
1971
|
+
// ── SEC-2a: approve this computer with a code (`web approve`) ──────────────
|
|
1972
|
+
|
|
1973
|
+
interface WaitingRow {
|
|
1974
|
+
code: string;
|
|
1975
|
+
device: string;
|
|
1976
|
+
client?: string;
|
|
1977
|
+
createdAt: string;
|
|
1978
|
+
expiresAt: string;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/** "3 min ago" for a list: whole minutes, or "just now". */
|
|
1982
|
+
function agoText(iso: string, now = Date.now()): string {
|
|
1983
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
1984
|
+
return s < 60 ? "just now" : `${Math.round(s / 60)} min ago`;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* `<cli> web approve <code> | --list [--json]`: let in a computer whose
|
|
1989
|
+
* signed-out page shows `<code>` (case and dashes don't matter), or list
|
|
1990
|
+
* the ones waiting. It asks the running server, with this CLI's bearer:
|
|
1991
|
+
* the requests live in its memory only, so with no server there is
|
|
1992
|
+
* nothing to approve.
|
|
1993
|
+
*/
|
|
1994
|
+
export async function cmdWebApprove(
|
|
1995
|
+
args: string[],
|
|
1996
|
+
io: CliIo = stdIo,
|
|
1997
|
+
): Promise<number> {
|
|
1998
|
+
let port: number;
|
|
1999
|
+
let rest: string[];
|
|
2000
|
+
try {
|
|
2001
|
+
({ port, rest } = takePort(args));
|
|
2002
|
+
} catch (e) {
|
|
2003
|
+
io.err(`${(e as Error).message}
|
|
2004
|
+
`);
|
|
2005
|
+
return 2;
|
|
2006
|
+
}
|
|
2007
|
+
const usage = `Usage: ${product().cli} web approve <code> | --list [--json] [--port <port>]\n`;
|
|
2008
|
+
if (isHelp(rest)) {
|
|
2009
|
+
io.out(
|
|
2010
|
+
usage +
|
|
2011
|
+
" Lets in the computer whose signed-out page shows <code> (like K7Q-42M; case\n" +
|
|
2012
|
+
" and dashes don't matter). That page then signs in by itself. A code lasts\n" +
|
|
2013
|
+
" 10 minutes and works once.\n" +
|
|
2014
|
+
" --list the computers waiting: code, browser, address, age\n" +
|
|
2015
|
+
" --json with --list: as JSON\n",
|
|
2016
|
+
);
|
|
2017
|
+
return 0;
|
|
2018
|
+
}
|
|
2019
|
+
const list = rest.includes("--list");
|
|
2020
|
+
const asJson = rest.includes("--json");
|
|
2021
|
+
const codes = rest.filter((a) => a !== "--list" && a !== "--json");
|
|
2022
|
+
if (
|
|
2023
|
+
codes.some((a) => a.startsWith("-")) ||
|
|
2024
|
+
(list ? codes.length !== 0 : codes.length !== 1 || asJson)
|
|
2025
|
+
) {
|
|
2026
|
+
io.err(usage);
|
|
2027
|
+
return 2;
|
|
2028
|
+
}
|
|
2029
|
+
const stateDir = io.stateDir ?? getStateDir();
|
|
2030
|
+
const r = list
|
|
2031
|
+
? await serverCall(stateDir, port, "GET", "/api/web/waiting")
|
|
2032
|
+
: await serverCall(stateDir, port, "POST", "/api/web/approve", {
|
|
2033
|
+
code: codes[0],
|
|
2034
|
+
});
|
|
2035
|
+
if (!r) {
|
|
2036
|
+
io.err(
|
|
2037
|
+
`${product().cli}: no ${product().title} web server answers on port ${port}. ` +
|
|
2038
|
+
"A waiting computer's code lives in the running server: start it " +
|
|
2039
|
+
`(\`${product().cli} web up\`) and reload the page on the other computer.\n`,
|
|
2040
|
+
);
|
|
2041
|
+
return 1;
|
|
2042
|
+
}
|
|
2043
|
+
if (r.status !== 200) {
|
|
2044
|
+
io.err(`${product().cli}: ${apiError(r)}\n`);
|
|
2045
|
+
return 1;
|
|
2046
|
+
}
|
|
2047
|
+
if (list) {
|
|
2048
|
+
const rows = ((r.json as { waiting?: WaitingRow[] }).waiting ?? []).map(
|
|
2049
|
+
(w) => ({ ...w }),
|
|
2050
|
+
);
|
|
2051
|
+
if (asJson) {
|
|
2052
|
+
io.out(`${JSON.stringify(rows, null, 2)}\n`);
|
|
2053
|
+
return 0;
|
|
2054
|
+
}
|
|
2055
|
+
if (rows.length === 0) {
|
|
2056
|
+
io.out("No computers are waiting for approval.\n");
|
|
2057
|
+
return 0;
|
|
2058
|
+
}
|
|
2059
|
+
const table = [
|
|
2060
|
+
["CODE", "BROWSER", "ADDRESS", "WAITING"],
|
|
2061
|
+
...rows.map((w) => [
|
|
2062
|
+
w.code,
|
|
2063
|
+
w.device,
|
|
2064
|
+
w.client ?? "",
|
|
2065
|
+
agoText(w.createdAt),
|
|
2066
|
+
]),
|
|
2067
|
+
];
|
|
2068
|
+
const widths = table[0].map((_, c) =>
|
|
2069
|
+
Math.max(...table.map((row) => row[c].length)),
|
|
2070
|
+
);
|
|
2071
|
+
for (const row of table)
|
|
2072
|
+
io.out(
|
|
2073
|
+
`${row
|
|
2074
|
+
.map((cell, c) =>
|
|
2075
|
+
c === row.length - 1 ? cell : cell.padEnd(widths[c]),
|
|
2076
|
+
)
|
|
2077
|
+
.join(" ")
|
|
2078
|
+
.trimEnd()}\n`,
|
|
2079
|
+
);
|
|
2080
|
+
return 0;
|
|
2081
|
+
}
|
|
2082
|
+
const w = r.json as WaitingRow;
|
|
2083
|
+
io.out(
|
|
2084
|
+
`Approved: ${w.device}${w.client ? ` (${w.client})` : ""}. ` +
|
|
2085
|
+
"Its page signs in by itself.\n",
|
|
2086
|
+
);
|
|
2087
|
+
return 0;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// ── SEC-2a: the owner (`web owner link`, `show`, `unlink`) ─────────────────
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* `<cli> web owner [link|show|unlink] [--json]`: the GitHub account this
|
|
2094
|
+
* Den belongs to, which Sign in with GitHub admits.
|
|
2095
|
+
* - link: GitHub's device flow, here in the terminal: the code and
|
|
2096
|
+
* github.com/login/device, then a wait while you approve it. Den keeps
|
|
2097
|
+
* `github:<id>` and the login (a 0600 file in the state dir) and drops
|
|
2098
|
+
* the token at once. Refused while an owner is linked.
|
|
2099
|
+
* - show (the default): who it is.
|
|
2100
|
+
* - unlink: forget it. Signed-in browsers stay signed in.
|
|
2101
|
+
* `client` and `sleep` are a test's.
|
|
2102
|
+
*/
|
|
2103
|
+
export async function cmdWebOwner(
|
|
2104
|
+
args: string[],
|
|
2105
|
+
io: CliIo & {
|
|
2106
|
+
client?: GithubDeviceClient;
|
|
2107
|
+
sleep?: (ms: number) => Promise<void>;
|
|
2108
|
+
clientId?: string;
|
|
2109
|
+
} = stdIo,
|
|
2110
|
+
): Promise<number> {
|
|
2111
|
+
const usage = `Usage: ${product().cli} web owner [link|show|unlink] [--json]\n`;
|
|
2112
|
+
if (isHelp(args)) {
|
|
2113
|
+
io.out(
|
|
2114
|
+
usage +
|
|
2115
|
+
" link link your GitHub account as this Den's owner (GitHub's device flow:\n" +
|
|
2116
|
+
` enter the code it shows at ${GITHUB_DEVICE_PAGE}); only its id is kept\n` +
|
|
2117
|
+
" show who the owner is (the default)\n" +
|
|
2118
|
+
" unlink forget the owner; signed-in browsers stay signed in\n" +
|
|
2119
|
+
" The owner can then sign in from any device: the signed-out page's\n" +
|
|
2120
|
+
" Sign in with GitHub. No token is stored.\n",
|
|
2121
|
+
);
|
|
2122
|
+
return 0;
|
|
2123
|
+
}
|
|
2124
|
+
const asJson = args.includes("--json");
|
|
2125
|
+
const rest = args.filter((a) => a !== "--json");
|
|
2126
|
+
const sub = rest[0] ?? "show";
|
|
2127
|
+
if (rest.length > 1 || !["link", "show", "unlink"].includes(sub)) {
|
|
2128
|
+
io.err(usage);
|
|
2129
|
+
return 2;
|
|
2130
|
+
}
|
|
2131
|
+
const stateDir = io.stateDir ?? getStateDir();
|
|
2132
|
+
const cli = product().cli;
|
|
2133
|
+
if (sub === "show") {
|
|
2134
|
+
const o = readWebOwner(stateDir);
|
|
2135
|
+
if (asJson) io.out(`${JSON.stringify({ owner: o }, null, 2)}\n`);
|
|
2136
|
+
else
|
|
2137
|
+
io.out(
|
|
2138
|
+
o
|
|
2139
|
+
? `Owner: @${o.login} (${o.owner}), linked ${o.linkedAt.replace("T", " ").replace(/\.\d+Z$/, "Z")}.\n`
|
|
2140
|
+
: `No owner is linked. Run \`${cli} web owner link\`.\n`,
|
|
2141
|
+
);
|
|
2142
|
+
return 0;
|
|
2143
|
+
}
|
|
2144
|
+
if (sub === "unlink") {
|
|
2145
|
+
const was = clearWebOwner(stateDir);
|
|
2146
|
+
if (asJson) io.out(`${JSON.stringify({ unlinked: was }, null, 2)}\n`);
|
|
2147
|
+
else
|
|
2148
|
+
io.out(
|
|
2149
|
+
was
|
|
2150
|
+
? `Unlinked @${was.login}. Signed-in browsers stay signed in (\`${cli} web logout\` ends them).\n`
|
|
2151
|
+
: "No owner was linked.\n",
|
|
2152
|
+
);
|
|
2153
|
+
return 0;
|
|
2154
|
+
}
|
|
2155
|
+
const linked = readWebOwner(stateDir);
|
|
2156
|
+
if (linked) {
|
|
2157
|
+
io.err(
|
|
2158
|
+
`${cli}: this Den's owner is @${linked.login} already; \`${cli} web owner unlink\` first.\n`,
|
|
2159
|
+
);
|
|
2160
|
+
return 1;
|
|
2161
|
+
}
|
|
2162
|
+
let clientId = io.clientId;
|
|
2163
|
+
if (clientId === undefined) {
|
|
2164
|
+
try {
|
|
2165
|
+
clientId = githubClientId(
|
|
2166
|
+
readWebAuthConfig(getDistRoot(), product().configFile).githubClientId,
|
|
2167
|
+
);
|
|
2168
|
+
} catch (e) {
|
|
2169
|
+
io.err(`${cli}: ${(e as Error).message}\n`);
|
|
2170
|
+
return 1;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
if (!clientId) {
|
|
2174
|
+
io.err(`${cli}: ${noClientIdMessage(cli)}\n`);
|
|
2175
|
+
return 1;
|
|
2176
|
+
}
|
|
2177
|
+
const ep = githubEndpoints();
|
|
2178
|
+
let outcome;
|
|
2179
|
+
try {
|
|
2180
|
+
outcome = await runDeviceFlow({
|
|
2181
|
+
clientId,
|
|
2182
|
+
client: io.client ?? githubDeviceClient(ep),
|
|
2183
|
+
...(io.sleep ? { sleep: io.sleep } : {}),
|
|
2184
|
+
minIntervalMs: ep.fake || io.client ? 0 : 1000,
|
|
2185
|
+
onCode: (c) =>
|
|
2186
|
+
io.out(
|
|
2187
|
+
`Open ${GITHUB_DEVICE_PAGE} and enter this code:\n\n ${c.userCode}\n\n` +
|
|
2188
|
+
`Waiting for you to approve it on GitHub (it asks for no permissions; ` +
|
|
2189
|
+
`the code lasts ${Math.round(c.expiresIn / 60)} min)…\n`,
|
|
2190
|
+
),
|
|
2191
|
+
});
|
|
2192
|
+
} catch (e) {
|
|
2193
|
+
io.err(`${cli}: ${(e as Error).message}\n`);
|
|
2194
|
+
return 1;
|
|
2195
|
+
}
|
|
2196
|
+
if (outcome.kind !== "ok") {
|
|
2197
|
+
io.err(
|
|
2198
|
+
`${cli}: ${
|
|
2199
|
+
outcome.kind === "expired"
|
|
2200
|
+
? "the code expired before it was approved; run it again"
|
|
2201
|
+
: outcome.kind === "denied"
|
|
2202
|
+
? "the request was cancelled on GitHub"
|
|
2203
|
+
: outcome.message
|
|
2204
|
+
}\n`,
|
|
2205
|
+
);
|
|
2206
|
+
return 1;
|
|
2207
|
+
}
|
|
2208
|
+
const rec = writeWebOwner(outcome.identity, stateDir);
|
|
2209
|
+
if (outcome.expiringToken)
|
|
2210
|
+
io.err(`${product().cli}: warning: ${expiringTokenWarning()}\n`);
|
|
2211
|
+
if (asJson) io.out(`${JSON.stringify({ owner: rec }, null, 2)}\n`);
|
|
2212
|
+
else
|
|
2213
|
+
io.out(
|
|
2214
|
+
`Linked @${rec.login} (${githubPrincipal(outcome.identity.id)}) as this Den's owner; ` +
|
|
2215
|
+
"the token was not kept. Sign in with GitHub from your other devices now works.\n",
|
|
2216
|
+
);
|
|
2217
|
+
return 0;
|
|
2218
|
+
}
|