@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
package/src/ui/server/index.ts
CHANGED
|
@@ -211,8 +211,13 @@ import {
|
|
|
211
211
|
} from "../../shared/actor-token.js";
|
|
212
212
|
import {
|
|
213
213
|
checkLoginCode,
|
|
214
|
+
deviceName,
|
|
215
|
+
loginCodeNonce,
|
|
214
216
|
ignoredConfigWarning,
|
|
215
217
|
localhostOrigin,
|
|
218
|
+
loginLink,
|
|
219
|
+
LOGIN_CODE_TTL_MS,
|
|
220
|
+
mintLoginCode,
|
|
216
221
|
readWebAuthConfig,
|
|
217
222
|
safeNext,
|
|
218
223
|
SESSION_COOKIE_MAX_AGE_S,
|
|
@@ -222,6 +227,32 @@ import {
|
|
|
222
227
|
type WebAuthConfig,
|
|
223
228
|
} from "../../shared/web-session.js";
|
|
224
229
|
import { serveUiAsset } from "./ui-assets.js";
|
|
230
|
+
import {
|
|
231
|
+
expiringTokenWarning,
|
|
232
|
+
githubClientId,
|
|
233
|
+
githubEndpoints,
|
|
234
|
+
noClientIdMessage,
|
|
235
|
+
} from "../../shared/github-app.js";
|
|
236
|
+
import { githubDeviceClient } from "../../shared/github-device.js";
|
|
237
|
+
import { readWebOwner } from "../../shared/web-owner.js";
|
|
238
|
+
import { DeviceFlows } from "./device-flows.js";
|
|
239
|
+
import {
|
|
240
|
+
decideFlow,
|
|
241
|
+
handleGithubRoutes,
|
|
242
|
+
isGithubRoute,
|
|
243
|
+
} from "./github-routes.js";
|
|
244
|
+
import { PairRequests } from "./pair-requests.js";
|
|
245
|
+
import { LoginWaits } from "./login-waits.js";
|
|
246
|
+
import { handlePairRoutes, isPairRoute } from "./pair-routes.js";
|
|
247
|
+
import {
|
|
248
|
+
cliCommand,
|
|
249
|
+
cliPrefix,
|
|
250
|
+
isIpLiteral,
|
|
251
|
+
machineLabel,
|
|
252
|
+
parseMachineName,
|
|
253
|
+
sshCommand,
|
|
254
|
+
sshTarget,
|
|
255
|
+
} from "../../shared/cli-command.js";
|
|
225
256
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
226
257
|
import { graphPageHtml } from "./graph-page.js";
|
|
227
258
|
import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
|
|
@@ -562,7 +593,7 @@ setHtmlFill((html) => {
|
|
|
562
593
|
out = out.replace(
|
|
563
594
|
'<div id="den-shell"',
|
|
564
595
|
() =>
|
|
565
|
-
`<div id="den-shell" data-edge-exposed="${escAttr(
|
|
596
|
+
`<div id="den-shell" data-edge-exposed="${escAttr(edgeExposedMessage(WEB_AUTH.edgeUnattested))}"`,
|
|
566
597
|
);
|
|
567
598
|
// MD-2: every page says when the engine on disk is not the one running.
|
|
568
599
|
const engine = engineCheck();
|
|
@@ -799,7 +830,7 @@ import {
|
|
|
799
830
|
webRouteHosts,
|
|
800
831
|
} from "../../shared/edge/render.js";
|
|
801
832
|
import {
|
|
802
|
-
|
|
833
|
+
edgeExposedMessage,
|
|
803
834
|
edgeExposure,
|
|
804
835
|
edgeNetworkSubnets,
|
|
805
836
|
} from "./edge-exposure.js";
|
|
@@ -1088,13 +1119,154 @@ const GUARD: GuardContext = {
|
|
|
1088
1119
|
peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
|
|
1089
1120
|
// The edge hostname only from the edge's own peers (loopback, Docker).
|
|
1090
1121
|
peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
|
|
1091
|
-
//
|
|
1122
|
+
// This machine's addresses and bridges, for edgeClient (SEC-2a).
|
|
1092
1123
|
localNet: currentLocalNet,
|
|
1093
1124
|
// A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
|
|
1094
1125
|
// Linux edge's Docker bridge): the edge needs a sign-in too.
|
|
1095
1126
|
exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
|
|
1127
|
+
// SEC-2a: an edge client Den can't place signs in (web.edge_unattested).
|
|
1128
|
+
edgeUnattested: WEB_AUTH.edgeUnattested,
|
|
1096
1129
|
};
|
|
1097
1130
|
|
|
1131
|
+
// ── SEC-2a: Sign in with GitHub, the owner, pairing ──────────────────────────
|
|
1132
|
+
const GITHUB_CLIENT_ID = githubClientId(WEB_AUTH.githubClientId);
|
|
1133
|
+
const GITHUB = githubEndpoints();
|
|
1134
|
+
const DEVICE_FLOWS = new DeviceFlows({
|
|
1135
|
+
client: githubDeviceClient(GITHUB),
|
|
1136
|
+
clientId: () => GITHUB_CLIENT_ID,
|
|
1137
|
+
decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
|
|
1138
|
+
// A test's fake GitHub may answer interval 0; the real one never does.
|
|
1139
|
+
minIntervalMs: GITHUB.fake ? 50 : 1000,
|
|
1140
|
+
onExpiringToken: noteExpiringToken,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* The GitHub App has "Expire user authorization tokens" on (a token answer
|
|
1145
|
+
* carried expires_in or a refresh_token). Sign-in still completes; Den
|
|
1146
|
+
* warns once, naming the fix, and Settings › Access (GET /api/web/owner)
|
|
1147
|
+
* says so as a boolean. The token itself is never logged or kept.
|
|
1148
|
+
*/
|
|
1149
|
+
let GITHUB_TOKENS_EXPIRE = false;
|
|
1150
|
+
function noteExpiringToken(): void {
|
|
1151
|
+
if (GITHUB_TOKENS_EXPIRE) return;
|
|
1152
|
+
GITHUB_TOKENS_EXPIRE = true;
|
|
1153
|
+
// The in-app log, and stderr (web.log), as the edge's exposure warning.
|
|
1154
|
+
const line = ` ⚠ ${expiringTokenWarning()}`;
|
|
1155
|
+
pushLog("warn", line);
|
|
1156
|
+
if (!outputLost()) process.stderr.write(line + "\n");
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ── SEC-2a: approve this computer with a code (pair-requests.ts) ──────────
|
|
1160
|
+
const PAIRS = new PairRequests();
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* What every printed command starts with (cli-command.ts): `<cli>` when the
|
|
1164
|
+
* first one on this server's PATH is this distribution's, else its
|
|
1165
|
+
* absolute path. PATH doesn't change while the server runs: once.
|
|
1166
|
+
*/
|
|
1167
|
+
let CLI_PREFIX: string | null = null;
|
|
1168
|
+
function printedCli(): string {
|
|
1169
|
+
CLI_PREFIX ??= cliPrefix(product().cli, getDistRoot(), getEngineRoot());
|
|
1170
|
+
return CLI_PREFIX;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* The name people read for this machine: `web.machine_name` (user.yml,
|
|
1175
|
+
* read now, so a change in Settings shows at once), else the short
|
|
1176
|
+
* hostname. Never the browser's Host.
|
|
1177
|
+
*/
|
|
1178
|
+
function machineName(): string {
|
|
1179
|
+
let set: string | undefined;
|
|
1180
|
+
try {
|
|
1181
|
+
set = readWebAuthConfig(getDistRoot(), product().configFile).machineName;
|
|
1182
|
+
} catch {
|
|
1183
|
+
set = WEB_AUTH.machineName;
|
|
1184
|
+
}
|
|
1185
|
+
return machineLabel(set, os.hostname());
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** `web.machine_name` as user.yml holds it, or null (unset or unreadable). */
|
|
1189
|
+
function machineNameSetting(user: {
|
|
1190
|
+
web?: Record<string, unknown>;
|
|
1191
|
+
}): string | null {
|
|
1192
|
+
try {
|
|
1193
|
+
return parseMachineName(user.web?.machine_name);
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* A path as the request log writes it: a sign-in poll's id is its
|
|
1201
|
+
* credential (SEC-2a: the code request's, a GitHub flow's), so it shows
|
|
1202
|
+
* as `:id`.
|
|
1203
|
+
*/
|
|
1204
|
+
function loggedPath(pathname: string): string {
|
|
1205
|
+
return pathname.replace(
|
|
1206
|
+
/^(\/api\/(?:signin\/code|github\/signin|web\/owner\/link))\/[^/]+$/,
|
|
1207
|
+
"$1/:id",
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The ssh target for this request: the Host it used, minus the port. */
|
|
1212
|
+
function sshTargetOf(req: http.IncomingMessage): string {
|
|
1213
|
+
return sshTarget(req.headers.host, os.hostname());
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* A browser's live WebSockets (a session's terminal), by its session's
|
|
1218
|
+
* key: revoking a device closes them at once (SEC-2a).
|
|
1219
|
+
*/
|
|
1220
|
+
const SESSION_SOCKETS = new Map<string, Set<net.Socket>>();
|
|
1221
|
+
function trackSessionSocket(key: string, socket: net.Socket): void {
|
|
1222
|
+
const set = SESSION_SOCKETS.get(key) ?? new Set<net.Socket>();
|
|
1223
|
+
set.add(socket);
|
|
1224
|
+
SESSION_SOCKETS.set(key, set);
|
|
1225
|
+
socket.once("close", () => {
|
|
1226
|
+
set.delete(socket);
|
|
1227
|
+
if (set.size === 0) SESSION_SOCKETS.delete(key);
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
function closeSessionSockets(keys: Iterable<string>): void {
|
|
1231
|
+
for (const k of keys) {
|
|
1232
|
+
for (const sock of SESSION_SOCKETS.get(k) ?? []) sock.destroy();
|
|
1233
|
+
SESSION_SOCKETS.delete(k);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** A new session for this request and its Set-Cookie line (GitHub or a link). */
|
|
1238
|
+
function startSession(
|
|
1239
|
+
req: http.IncomingMessage,
|
|
1240
|
+
via: "github" | "link" | "code",
|
|
1241
|
+
login?: string,
|
|
1242
|
+
): string {
|
|
1243
|
+
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1244
|
+
const named = g.ok ? g.via : null;
|
|
1245
|
+
const { id } = webSessions.create({
|
|
1246
|
+
userAgent: req.headers["user-agent"],
|
|
1247
|
+
host: req.headers.host,
|
|
1248
|
+
via,
|
|
1249
|
+
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1250
|
+
...(login ? { login } : {}),
|
|
1251
|
+
});
|
|
1252
|
+
return sessionSetCookie(id, requestIsHttps(req, named));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** The edge hostnames that route here, and their URLs (Pair a device). */
|
|
1256
|
+
function denEdgeRoutes(): { host: string; url: string }[] {
|
|
1257
|
+
try {
|
|
1258
|
+
return webRouteHosts(readEdgeConfig(), new Set([PORT, readWebPortSafe()]));
|
|
1259
|
+
} catch {
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** What the signed-out page says about Sign in with GitHub. */
|
|
1265
|
+
function githubSigninState(): "ready" | "no_client_id" | "no_owner" {
|
|
1266
|
+
if (!GITHUB_CLIENT_ID) return "no_client_id";
|
|
1267
|
+
return readWebOwner(getStateDir()) ? "ready" : "no_owner";
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1098
1270
|
/**
|
|
1099
1271
|
* 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
|
|
1100
1272
|
* once at start; null until known, or when Den can't tell.
|
|
@@ -1118,7 +1290,7 @@ function checkEdgeExposure(): void {
|
|
|
1118
1290
|
EDGE_EXPOSED = why;
|
|
1119
1291
|
// The in-app log, and stderr, which is web.log even when `web up`
|
|
1120
1292
|
// starts this --silent (stdout is not written then).
|
|
1121
|
-
const line = ` ⚠ ${
|
|
1293
|
+
const line = ` ⚠ ${edgeExposedMessage(WEB_AUTH.edgeUnattested)} (${why})`;
|
|
1122
1294
|
pushLog("warn", line);
|
|
1123
1295
|
if (!outputLost()) process.stderr.write(line + "\n");
|
|
1124
1296
|
})
|
|
@@ -1166,6 +1338,8 @@ function sendSignedOut(
|
|
|
1166
1338
|
search: string;
|
|
1167
1339
|
scope: "terminal" | "agents" | "den";
|
|
1168
1340
|
expired?: boolean;
|
|
1341
|
+
/** SEC-2a: a waited-on link: ask for the code `web login` printed. */
|
|
1342
|
+
confirm?: boolean;
|
|
1169
1343
|
requestId: string;
|
|
1170
1344
|
message: string;
|
|
1171
1345
|
},
|
|
@@ -1176,9 +1350,11 @@ function sendSignedOut(
|
|
|
1176
1350
|
? "edge"
|
|
1177
1351
|
: "other";
|
|
1178
1352
|
const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1353
|
+
const bin = printedCli();
|
|
1354
|
+
const loginArgs =
|
|
1355
|
+
toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
|
|
1356
|
+
const cmd = cliCommand(bin, "web", "login", ...loginArgs);
|
|
1357
|
+
const ssh = sshTargetOf(req);
|
|
1182
1358
|
res.setHeader("Cache-Control", "no-store");
|
|
1183
1359
|
sendErrorPage(req, res, {
|
|
1184
1360
|
status: 401,
|
|
@@ -1190,7 +1366,24 @@ function sendSignedOut(
|
|
|
1190
1366
|
scope: opts.scope,
|
|
1191
1367
|
name: product().title,
|
|
1192
1368
|
cmd,
|
|
1369
|
+
// SEC-2a: which machine runs the commands, and how to reach it.
|
|
1370
|
+
machine: machineName(),
|
|
1371
|
+
ssh: {
|
|
1372
|
+
target: ssh,
|
|
1373
|
+
// Open Terminal (ssh://) only for a name, never an IP literal.
|
|
1374
|
+
name: !isIpLiteral(ssh),
|
|
1375
|
+
login: sshCommand(ssh, cmd),
|
|
1376
|
+
},
|
|
1377
|
+
// SEC-2a: Sign in with GitHub, and why it can't be offered yet.
|
|
1378
|
+
github: {
|
|
1379
|
+
state: githubSigninState(),
|
|
1380
|
+
owner: cliCommand(bin, "web", "owner", "link"),
|
|
1381
|
+
...(GITHUB_CLIENT_ID
|
|
1382
|
+
? {}
|
|
1383
|
+
: { message: noClientIdMessage(product().cli) }),
|
|
1384
|
+
},
|
|
1193
1385
|
...(opts.expired ? { expired: true } : {}),
|
|
1386
|
+
...(opts.confirm ? { confirm: true } : {}),
|
|
1194
1387
|
...(toLocal && !opts.expired
|
|
1195
1388
|
? {
|
|
1196
1389
|
localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
|
|
@@ -1265,19 +1458,34 @@ function sendGuardRefusal(
|
|
|
1265
1458
|
* 303 to `next` when it is a same-origin path, else `/`. Anything else gets
|
|
1266
1459
|
* the signed-out page, which never echoes the code.
|
|
1267
1460
|
*/
|
|
1268
|
-
function handleAuth(
|
|
1461
|
+
async function handleAuth(
|
|
1269
1462
|
req: http.IncomingMessage,
|
|
1270
1463
|
res: http.ServerResponse,
|
|
1271
1464
|
url: URL,
|
|
1272
1465
|
requestId: string,
|
|
1273
|
-
|
|
1274
|
-
): void {
|
|
1466
|
+
): Promise<void> {
|
|
1275
1467
|
const secret = webSecret.current();
|
|
1468
|
+
const c = url.searchParams.get("c") ?? "";
|
|
1469
|
+
const nonce = loginCodeNonce(c);
|
|
1470
|
+
// SEC-2a: a link `web login` waits on asks for its code first. Opened,
|
|
1471
|
+
// its code is checked as of the moment it was opened (then 5 minutes).
|
|
1472
|
+
const wait = nonce ? LOGIN_WAITS.forNonce(nonce) : null;
|
|
1276
1473
|
const check = secret
|
|
1277
|
-
? checkLoginCode(
|
|
1474
|
+
? checkLoginCode(c, secret, wait?.openedAt ?? Date.now())
|
|
1278
1475
|
: null;
|
|
1279
|
-
|
|
1476
|
+
const post = req.method === "POST";
|
|
1477
|
+
const gone = () => {
|
|
1280
1478
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1479
|
+
if (post)
|
|
1480
|
+
return sendJson(
|
|
1481
|
+
res,
|
|
1482
|
+
{
|
|
1483
|
+
ok: false,
|
|
1484
|
+
code: "expired",
|
|
1485
|
+
error: `This link no longer works. On ${machineName()}, run web login again for a new one.`,
|
|
1486
|
+
},
|
|
1487
|
+
410,
|
|
1488
|
+
);
|
|
1281
1489
|
return sendSignedOut(req, res, {
|
|
1282
1490
|
pathname: "/auth",
|
|
1283
1491
|
search: "",
|
|
@@ -1286,20 +1494,90 @@ function handleAuth(
|
|
|
1286
1494
|
requestId,
|
|
1287
1495
|
message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
|
|
1288
1496
|
});
|
|
1497
|
+
};
|
|
1498
|
+
if (!check?.ok) return gone();
|
|
1499
|
+
if (wait && nonce) {
|
|
1500
|
+
if (!post) {
|
|
1501
|
+
if (wait.state !== "waiting" && wait.state !== "opened") return gone();
|
|
1502
|
+
LOGIN_WAITS.open(nonce);
|
|
1503
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1504
|
+
return sendSignedOut(req, res, {
|
|
1505
|
+
pathname: "/auth",
|
|
1506
|
+
search: "",
|
|
1507
|
+
scope: "den",
|
|
1508
|
+
confirm: true,
|
|
1509
|
+
requestId,
|
|
1510
|
+
message: "Enter the code `web login` printed.",
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
let body: { code?: unknown } = {};
|
|
1514
|
+
try {
|
|
1515
|
+
body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return sendJson(
|
|
1518
|
+
res,
|
|
1519
|
+
{ ok: false, error: "the body must be JSON: { code }" },
|
|
1520
|
+
400,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
const r = LOGIN_WAITS.confirm(nonce, String(body.code ?? ""));
|
|
1524
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1525
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1526
|
+
if (!r.ok) {
|
|
1527
|
+
if (r.error === "expired") return gone();
|
|
1528
|
+
return sendJson(
|
|
1529
|
+
res,
|
|
1530
|
+
r.error === "wrong"
|
|
1531
|
+
? {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
code: "wrong_code",
|
|
1534
|
+
left: r.left,
|
|
1535
|
+
error:
|
|
1536
|
+
"That code isn't right. Check what web login printed and try again.",
|
|
1537
|
+
}
|
|
1538
|
+
: r.error === "rate_limited"
|
|
1539
|
+
? {
|
|
1540
|
+
ok: false,
|
|
1541
|
+
code: "rate_limited",
|
|
1542
|
+
error: "Too many wrong codes. Wait a minute and try again.",
|
|
1543
|
+
}
|
|
1544
|
+
: {
|
|
1545
|
+
ok: false,
|
|
1546
|
+
code: "refused",
|
|
1547
|
+
error: `Too many wrong codes: this link no longer works. On ${machineName()}, run web login again.`,
|
|
1548
|
+
},
|
|
1549
|
+
r.error === "wrong" ? 401 : r.error === "rate_limited" ? 429 : 410,
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
if (!webSessions.consumeCode(check.nonce, check.expiresAt)) return gone();
|
|
1553
|
+
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1554
|
+
LOGIN_WAITS.used(nonce, {
|
|
1555
|
+
device: deviceName(req.headers["user-agent"]),
|
|
1556
|
+
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1557
|
+
});
|
|
1558
|
+
res.setHeader("Set-Cookie", startSession(req, "link"));
|
|
1559
|
+
return sendJson(res, {
|
|
1560
|
+
ok: true,
|
|
1561
|
+
next: safeNext(url.searchParams.get("next")),
|
|
1562
|
+
});
|
|
1289
1563
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1564
|
+
// A link nobody waits on (`web login --no-wait`, or minted with no
|
|
1565
|
+
// server running): it signs in when opened, once, as before.
|
|
1566
|
+
if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
|
|
1567
|
+
return gone();
|
|
1294
1568
|
res.writeHead(303, {
|
|
1295
1569
|
Location: safeNext(url.searchParams.get("next")),
|
|
1296
|
-
"Set-Cookie":
|
|
1570
|
+
"Set-Cookie": startSession(req, "link"),
|
|
1297
1571
|
"Cache-Control": "no-store",
|
|
1298
1572
|
"Referrer-Policy": "no-referrer",
|
|
1299
1573
|
});
|
|
1300
1574
|
res.end();
|
|
1301
1575
|
}
|
|
1302
1576
|
|
|
1577
|
+
/** `web login`'s wait (login-waits.ts), by the CLI's id. */
|
|
1578
|
+
const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
|
|
1579
|
+
const LOGIN_WAITS = new LoginWaits();
|
|
1580
|
+
|
|
1303
1581
|
/**
|
|
1304
1582
|
* A service's declared command `cmd` for `env`, own properties only
|
|
1305
1583
|
* (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
|
|
@@ -1350,7 +1628,7 @@ async function handleRequest(
|
|
|
1350
1628
|
res.on("finish", () => {
|
|
1351
1629
|
const ms = dim(`${Date.now() - start}ms`);
|
|
1352
1630
|
serverLog(
|
|
1353
|
-
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
|
|
1631
|
+
` ${logTimestamp()} ${method} ${loggedPath(pathname)} → ${logStatus(res.statusCode)} ${ms}` +
|
|
1354
1632
|
(res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
|
|
1355
1633
|
"request",
|
|
1356
1634
|
);
|
|
@@ -1379,8 +1657,51 @@ async function handleRequest(
|
|
|
1379
1657
|
|
|
1380
1658
|
// ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
|
|
1381
1659
|
// Before config validation: signing in never depends on the config.
|
|
1382
|
-
if (
|
|
1383
|
-
|
|
1660
|
+
if (
|
|
1661
|
+
pathname === "/auth" &&
|
|
1662
|
+
(req.method === "GET" || req.method === "HEAD" || req.method === "POST")
|
|
1663
|
+
)
|
|
1664
|
+
return handleAuth(req, res, url, requestId);
|
|
1665
|
+
// SEC-2a: `web login` registers its link's wait, then polls it
|
|
1666
|
+
// (shell-grade: the CLI's bearer).
|
|
1667
|
+
if (pathname === "/api/web/logins" && req.method === "POST") {
|
|
1668
|
+
let body: { nonce?: unknown; code?: unknown; expiresAt?: unknown } = {};
|
|
1669
|
+
try {
|
|
1670
|
+
body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
|
|
1671
|
+
} catch {
|
|
1672
|
+
return send400(res, "the body must be JSON: { nonce, code, expiresAt }");
|
|
1673
|
+
}
|
|
1674
|
+
const exp = Number(body.expiresAt);
|
|
1675
|
+
const id =
|
|
1676
|
+
Number.isFinite(exp) &&
|
|
1677
|
+
exp > Date.now() &&
|
|
1678
|
+
exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
|
|
1679
|
+
? LOGIN_WAITS.register(
|
|
1680
|
+
String(body.nonce ?? ""),
|
|
1681
|
+
String(body.code ?? ""),
|
|
1682
|
+
exp,
|
|
1683
|
+
)
|
|
1684
|
+
: null;
|
|
1685
|
+
if (!id) return send400(res, "not a login link's nonce, code and expiry");
|
|
1686
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1687
|
+
return sendJson(res, { ok: true, id });
|
|
1688
|
+
}
|
|
1689
|
+
{
|
|
1690
|
+
const w = LOGIN_WAIT_PATH.exec(pathname);
|
|
1691
|
+
if (w && (req.method === "GET" || req.method === "HEAD")) {
|
|
1692
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1693
|
+
const v = LOGIN_WAITS.get(w[1]);
|
|
1694
|
+
if (!v) return sendJson(res, { ok: false, state: "expired" }, 404);
|
|
1695
|
+
return sendJson(res, {
|
|
1696
|
+
ok: true,
|
|
1697
|
+
state: v.state,
|
|
1698
|
+
...(v.device ? { device: v.device } : {}),
|
|
1699
|
+
...(v.client ? { client: v.client } : {}),
|
|
1700
|
+
...(v.usedAt ? { usedAt: new Date(v.usedAt).toISOString() } : {}),
|
|
1701
|
+
expiresAt: new Date(v.expiresAt).toISOString(),
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1384
1705
|
if (
|
|
1385
1706
|
pathname === "/signin" &&
|
|
1386
1707
|
(req.method === "GET" || req.method === "HEAD")
|
|
@@ -1410,6 +1731,65 @@ async function handleRequest(
|
|
|
1410
1731
|
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
1411
1732
|
if (pathname.startsWith("/api/sessions/"))
|
|
1412
1733
|
return sendSessionHttp(req, res, pathname);
|
|
1734
|
+
// SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
|
|
1735
|
+
// config validation, like the sign-in above.
|
|
1736
|
+
if (
|
|
1737
|
+
isGithubRoute(pathname) &&
|
|
1738
|
+
(await handleGithubRoutes(req, res, pathname, {
|
|
1739
|
+
flows: DEVICE_FLOWS,
|
|
1740
|
+
clientId: () => GITHUB_CLIENT_ID,
|
|
1741
|
+
tokensExpire: () => GITHUB_TOKENS_EXPIRE,
|
|
1742
|
+
cli: product().cli,
|
|
1743
|
+
sendJson,
|
|
1744
|
+
readBody,
|
|
1745
|
+
signIn: startSession,
|
|
1746
|
+
edgeRoutes: denEdgeRoutes,
|
|
1747
|
+
mintLink: (origin) => {
|
|
1748
|
+
const secret = webSecret.current();
|
|
1749
|
+
if (!secret) return null;
|
|
1750
|
+
const now = Date.now();
|
|
1751
|
+
return {
|
|
1752
|
+
url: loginLink(origin, mintLoginCode(secret, now)),
|
|
1753
|
+
expiresAt: now + LOGIN_CODE_TTL_MS,
|
|
1754
|
+
};
|
|
1755
|
+
},
|
|
1756
|
+
log: (l) => serverLog(l, "lifecycle"),
|
|
1757
|
+
stateDir: getStateDir,
|
|
1758
|
+
}))
|
|
1759
|
+
)
|
|
1760
|
+
return;
|
|
1761
|
+
|
|
1762
|
+
// SEC-2a: approve this computer with a code. Before config validation.
|
|
1763
|
+
if (
|
|
1764
|
+
isPairRoute(pathname) &&
|
|
1765
|
+
(await handlePairRoutes(req, res, pathname, {
|
|
1766
|
+
pairs: PAIRS,
|
|
1767
|
+
sendJson,
|
|
1768
|
+
readBody,
|
|
1769
|
+
signIn: (r) => startSession(r, "code"),
|
|
1770
|
+
client: (r) => {
|
|
1771
|
+
const g = checkRequest(r, GUARD, product().cli, product().title);
|
|
1772
|
+
return g.ok ? g.client : undefined;
|
|
1773
|
+
},
|
|
1774
|
+
commands: (r, code) => {
|
|
1775
|
+
const approve = cliCommand(printedCli(), "web", "approve", code);
|
|
1776
|
+
return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
|
|
1777
|
+
},
|
|
1778
|
+
audit: (r, verb, detail, refused) =>
|
|
1779
|
+
recordAction(
|
|
1780
|
+
auditActorOf(r),
|
|
1781
|
+
{
|
|
1782
|
+
verb,
|
|
1783
|
+
detail,
|
|
1784
|
+
...(refused
|
|
1785
|
+
? { outcome: "refused" as const, reason: refused }
|
|
1786
|
+
: {}),
|
|
1787
|
+
},
|
|
1788
|
+
principalOf(r),
|
|
1789
|
+
),
|
|
1790
|
+
}))
|
|
1791
|
+
)
|
|
1792
|
+
return;
|
|
1413
1793
|
|
|
1414
1794
|
// A mutating request may change what /api/services would list (exec and
|
|
1415
1795
|
// sync stream over GET, for EventSource).
|
|
@@ -3385,22 +3765,41 @@ async function handleRequest(
|
|
|
3385
3765
|
actor: g.principal.actor,
|
|
3386
3766
|
signedIn: false,
|
|
3387
3767
|
});
|
|
3388
|
-
|
|
3768
|
+
// SEC-2a: who this browser is (the rail's identity chip), and, once
|
|
3769
|
+
// after a sign-in, that it just signed in (the shell's toast).
|
|
3770
|
+
if (g?.session) {
|
|
3771
|
+
const rec = webSessions.list().find((x) => x.key === g.session!.key);
|
|
3772
|
+
const github = rec?.via === "github";
|
|
3389
3773
|
return sendJson(res, {
|
|
3390
3774
|
csrf: g.session.csrf,
|
|
3391
3775
|
actor: "person-browser",
|
|
3392
3776
|
signedIn: true,
|
|
3777
|
+
identity: {
|
|
3778
|
+
kind: github ? "github" : "device",
|
|
3779
|
+
via: rec?.via ?? "link",
|
|
3780
|
+
...(github && rec?.login ? { login: rec.login } : {}),
|
|
3781
|
+
},
|
|
3782
|
+
...(webSessions.takeWelcome(g.session.key)
|
|
3783
|
+
? {
|
|
3784
|
+
justSignedIn: true,
|
|
3785
|
+
product: product().title,
|
|
3786
|
+
machine: machineName(),
|
|
3787
|
+
}
|
|
3788
|
+
: {}),
|
|
3393
3789
|
});
|
|
3790
|
+
}
|
|
3394
3791
|
return sendJson(res, {
|
|
3395
3792
|
csrf: g?.beyond ? null : GUARD.csrfToken,
|
|
3396
3793
|
actor: null,
|
|
3397
3794
|
signedIn: false,
|
|
3795
|
+
...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
|
|
3398
3796
|
});
|
|
3399
3797
|
}
|
|
3400
3798
|
// Sign out: this browser's session ends and its cookie is cleared.
|
|
3401
3799
|
if (pathname === "/api/session/logout" && req.method === "POST") {
|
|
3402
3800
|
const g = guard.ok ? guard : null;
|
|
3403
3801
|
const ended = g?.session ? webSessions.end(g.session.key) : false;
|
|
3802
|
+
if (g?.session) closeSessionSockets([g.session.key]);
|
|
3404
3803
|
res.setHeader(
|
|
3405
3804
|
"Set-Cookie",
|
|
3406
3805
|
clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
|
|
@@ -3417,6 +3816,11 @@ async function handleRequest(
|
|
|
3417
3816
|
lastSeenAt: new Date(x.lastSeenAt).toISOString(),
|
|
3418
3817
|
userAgent: x.userAgent,
|
|
3419
3818
|
host: x.host,
|
|
3819
|
+
// SEC-2a: a linked device's name, how it signed in, its address.
|
|
3820
|
+
name: deviceName(x.userAgent),
|
|
3821
|
+
via: x.via ?? "link",
|
|
3822
|
+
...(x.login ? { login: x.login } : {}),
|
|
3823
|
+
...(x.client ? { client: x.client } : {}),
|
|
3420
3824
|
...(x.key === mine ? { current: true } : {}),
|
|
3421
3825
|
})),
|
|
3422
3826
|
});
|
|
@@ -3437,11 +3841,14 @@ async function handleRequest(
|
|
|
3437
3841
|
if (body.id !== undefined && typeof body.id !== "string")
|
|
3438
3842
|
return send400(res, "id must be a session id prefix");
|
|
3439
3843
|
try {
|
|
3440
|
-
const
|
|
3844
|
+
const endedKeys =
|
|
3441
3845
|
typeof body.id === "string"
|
|
3442
|
-
? [
|
|
3443
|
-
: webSessions.list().map((x) =>
|
|
3846
|
+
? [webSessions.endByPrefix(body.id).key]
|
|
3847
|
+
: webSessions.list().map((x) => x.key);
|
|
3444
3848
|
if (typeof body.id !== "string") webSessions.endAll();
|
|
3849
|
+
// SEC-2a: a revoked device's open terminals close now, too.
|
|
3850
|
+
closeSessionSockets(endedKeys);
|
|
3851
|
+
const ended = endedKeys.map(sessionIdPrefix);
|
|
3445
3852
|
const rotated = body.rotate === true;
|
|
3446
3853
|
let agentTokens = 0;
|
|
3447
3854
|
if (rotated) {
|
|
@@ -3895,7 +4302,7 @@ async function handleRequest(
|
|
|
3895
4302
|
// GET /api/sudo-password/status → { set, passwordless,
|
|
3896
4303
|
// saved: "vault" | "session" | null, vault: { configured, label } }
|
|
3897
4304
|
// GET /api/sudo-password/vault-status → { backend, label, configured,
|
|
3898
|
-
// backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
4305
|
+
// canSave, backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
3899
4306
|
// POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
|
|
3900
4307
|
if (pathname === "/api/sudo-password") {
|
|
3901
4308
|
if (req.method !== "POST") {
|
|
@@ -3925,16 +4332,24 @@ async function handleRequest(
|
|
|
3925
4332
|
setSudoPassword(password);
|
|
3926
4333
|
let savedToVault = false;
|
|
3927
4334
|
let vaultError: string | undefined;
|
|
4335
|
+
let refCreated = false;
|
|
3928
4336
|
if (saveToVault) {
|
|
4337
|
+
// LK-4: with no secrets.refs.sudo, this creates the item and writes
|
|
4338
|
+
// the ref to user.yml. The error never carries the password.
|
|
3929
4339
|
try {
|
|
3930
|
-
await saveSudoToVault(password);
|
|
4340
|
+
refCreated = (await saveSudoToVault(password)).ref === "created";
|
|
3931
4341
|
savedToVault = true;
|
|
3932
4342
|
sudoInVault = true;
|
|
3933
4343
|
} catch (err) {
|
|
3934
4344
|
vaultError = (err as Error).message;
|
|
3935
4345
|
}
|
|
3936
4346
|
}
|
|
3937
|
-
return sendJson(res, {
|
|
4347
|
+
return sendJson(res, {
|
|
4348
|
+
ok: true,
|
|
4349
|
+
savedToVault,
|
|
4350
|
+
...(refCreated ? { refCreated } : {}),
|
|
4351
|
+
vaultError,
|
|
4352
|
+
});
|
|
3938
4353
|
} catch (e) {
|
|
3939
4354
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
3940
4355
|
}
|
|
@@ -4096,6 +4511,10 @@ async function handleRequest(
|
|
|
4096
4511
|
// The UI's language (R19), and the ones it has catalogs for.
|
|
4097
4512
|
language: uiLanguage(user.language),
|
|
4098
4513
|
ui_languages: UI_LANGUAGES,
|
|
4514
|
+
// SEC-2a: the name the signed-out page gives this machine, and
|
|
4515
|
+
// what it is when unset (the short hostname).
|
|
4516
|
+
machine_name: machineNameSetting(user),
|
|
4517
|
+
machine_name_default: machineLabel(null, os.hostname()),
|
|
4099
4518
|
});
|
|
4100
4519
|
}
|
|
4101
4520
|
if (req.method === "POST") {
|
|
@@ -4108,8 +4527,23 @@ async function handleRequest(
|
|
|
4108
4527
|
appearance?: unknown;
|
|
4109
4528
|
shell?: unknown;
|
|
4110
4529
|
language?: unknown;
|
|
4530
|
+
machine_name?: unknown;
|
|
4111
4531
|
};
|
|
4112
4532
|
const user = readUserConfig();
|
|
4533
|
+
// SEC-2a: web.machine_name (user.yml); null or "" clears it.
|
|
4534
|
+
if (body.machine_name !== undefined) {
|
|
4535
|
+
let name: string | null;
|
|
4536
|
+
try {
|
|
4537
|
+
name = parseMachineName(body.machine_name);
|
|
4538
|
+
} catch (e) {
|
|
4539
|
+
return send400(res, (e as Error).message);
|
|
4540
|
+
}
|
|
4541
|
+
const web = { ...(user.web ?? {}) };
|
|
4542
|
+
if (name) web.machine_name = name;
|
|
4543
|
+
else delete web.machine_name;
|
|
4544
|
+
if (Object.keys(web).length) user.web = web;
|
|
4545
|
+
else delete user.web;
|
|
4546
|
+
}
|
|
4113
4547
|
// null clears (0.3.0-dev.32, a consumer's nit): a block or a key
|
|
4114
4548
|
// set to null is removed from user.yml, and a block left empty
|
|
4115
4549
|
// goes too, so setting a pref and clearing it again leaves the
|
|
@@ -4204,6 +4638,7 @@ async function handleRequest(
|
|
|
4204
4638
|
appearance: appearancePrefs(user.appearance),
|
|
4205
4639
|
shell: shellPrefs(user.shell),
|
|
4206
4640
|
language: uiLanguage(user.language),
|
|
4641
|
+
machine_name: machineNameSetting(user),
|
|
4207
4642
|
});
|
|
4208
4643
|
} catch (e) {
|
|
4209
4644
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
@@ -4558,6 +4993,7 @@ server.on(
|
|
|
4558
4993
|
setPrincipal(req, guard.principal);
|
|
4559
4994
|
if (!isSessionIoPath(pathname))
|
|
4560
4995
|
return refuse(404, "No WebSocket route here", "not_found");
|
|
4996
|
+
if (guard.session) trackSessionSocket(guard.session.key, socket);
|
|
4561
4997
|
handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
|
|
4562
4998
|
(err: unknown) => {
|
|
4563
4999
|
serverLog(
|