windows-development-sandbox 1.11.7 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/config.example.json +15 -4
- package/dist/app/features/access/access.def.d.ts +190 -0
- package/dist/app/features/access/access.def.js +273 -0
- package/dist/app/features/access/access.run.d.ts +79 -0
- package/dist/app/features/access/access.run.js +129 -0
- package/dist/app/features/access/index.d.ts +2 -0
- package/dist/app/features/access/index.js +3 -0
- package/dist/app/features/agents/agent-entry.js +5 -0
- package/dist/app/features/agents/agents.def.js +1 -0
- package/dist/app/features/apps/apps-rows.d.ts +4 -1
- package/dist/app/features/apps/apps-rows.js +7 -2
- package/dist/app/features/apps/apps.def.d.ts +19 -0
- package/dist/app/features/apps/apps.def.js +50 -12
- package/dist/app/features/apps/apps.run.d.ts +2 -0
- package/dist/app/features/apps/apps.run.js +1 -1
- package/dist/app/features/audit/audit.def.d.ts +350 -0
- package/dist/app/features/audit/audit.def.js +457 -0
- package/dist/app/features/audit/audit.run.d.ts +110 -0
- package/dist/app/features/audit/audit.run.js +133 -0
- package/dist/app/features/audit/index.d.ts +2 -0
- package/dist/app/features/audit/index.js +3 -0
- package/dist/app/features/defs.d.ts +349 -4
- package/dist/app/features/defs.js +16 -1
- package/dist/app/features/files/files.def.js +1 -0
- package/dist/app/features/health/health.def.js +1 -0
- package/dist/app/features/sandbox/sandbox.def.js +3 -0
- package/dist/app/features/sessions/sessions.def.d.ts +36 -0
- package/dist/app/features/sessions/sessions.def.js +77 -9
- package/dist/app/features/sessions/sessions.run.d.ts +25 -2
- package/dist/app/features/sessions/sessions.run.js +71 -3
- package/dist/app/features/settings/settings.def.d.ts +8 -0
- package/dist/app/features/settings/settings.def.js +17 -0
- package/dist/app/features/settings/settings.run.d.ts +3 -0
- package/dist/app/features/settings/settings.run.js +16 -2
- package/dist/app/features/web/web.def.d.ts +9 -0
- package/dist/app/features/web/web.def.js +26 -1
- package/dist/app/features/web/web.run.d.ts +3 -0
- package/dist/app/features/web/web.run.js +11 -3
- package/dist/app/kernel/access.d.ts +33 -0
- package/dist/app/kernel/access.js +44 -0
- package/dist/app/kernel/define.d.ts +26 -2
- package/dist/app/kernel/index.d.ts +1 -0
- package/dist/app/kernel/index.js +1 -0
- package/dist/app/kernel/rpc.d.ts +5 -2
- package/dist/app/registry.d.ts +4 -1
- package/dist/app/registry.js +26 -4
- package/dist/cli/args.d.ts +0 -3
- package/dist/cli/args.js +3 -4
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch/prepare.js +1 -1
- package/dist/cli/commands/maintenance/index.d.ts +0 -1
- package/dist/cli/commands/maintenance/index.js +0 -1
- package/dist/cli/commands/registry.js +3 -2
- package/dist/cli/commands/remote.d.ts +2 -0
- package/dist/cli/commands/remote.js +49 -0
- package/dist/cli/commands/sessions/reopen.js +3 -0
- package/dist/cli/commands/web.js +66 -0
- package/dist/cli/manifest.d.ts +63 -45
- package/dist/cli/manifest.js +33 -18
- package/dist/cli/menu/io-menu.d.ts +5 -3
- package/dist/cli/menu/io-menu.js +12 -5
- package/dist/cli/menu/pages/apps-page.js +2 -15
- package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
- package/dist/cli/menu/pages/maintenance-page.js +51 -8
- package/dist/cli/menu/remote-menu.d.ts +19 -0
- package/dist/cli/menu/remote-menu.js +108 -0
- package/dist/cli/menu/views.js +4 -0
- package/dist/cli/operations/handler.js +15 -1
- package/dist/cli/services/remote-client.d.ts +16 -0
- package/dist/cli/services/remote-client.js +141 -0
- package/dist/cli/services/sessions-service.d.ts +5 -0
- package/dist/cli/services/sessions-service.js +17 -1
- package/dist/core/config/defaults.js +12 -2
- package/dist/core/local-config/file.js +16 -2
- package/dist/core/local-config/overrides.js +7 -0
- package/dist/core/run/versions.js +9 -8
- package/dist/core/schema/apps.d.ts +9 -0
- package/dist/core/schema/apps.js +9 -0
- package/dist/core/schema/config.d.ts +23 -3
- package/dist/core/schema/config.js +42 -5
- package/dist/core/schema/sandbox.d.ts +1 -0
- package/dist/core/schema/sandbox.js +11 -3
- package/dist/core/schema/web.d.ts +51 -0
- package/dist/core/schema/web.js +46 -1
- package/dist/core/util/fs.d.ts +5 -0
- package/dist/core/util/fs.js +23 -0
- package/dist/core/util/index.d.ts +1 -1
- package/dist/core/util/index.js +1 -1
- package/dist/guest/apps/app-installer.js +50 -4
- package/dist/guest/apps/index.d.ts +1 -1
- package/dist/guest/apps/index.js +1 -1
- package/dist/guest/apps/sources.js +1 -1
- package/dist/guest/audit/parse.d.ts +32 -2
- package/dist/guest/audit/parse.js +43 -5
- package/dist/guest/audit/process-audit.d.ts +3 -1
- package/dist/guest/audit/process-audit.js +9 -6
- package/dist/guest/boot/boot.js +12 -3
- package/dist/guest/boot/guide.d.ts +1 -1
- package/dist/guest/boot/guide.js +27 -3
- package/dist/guest/daemon/daemon.js +2 -0
- package/dist/guest/daemon/idle-shutdown.js +2 -1
- package/dist/guest/daemon/sessions.d.ts +23 -2
- package/dist/guest/daemon/sessions.js +151 -20
- package/dist/guest/entries/daemon.js +2 -1
- package/dist/guest/git/decision.js +7 -0
- package/dist/guest/sessions/cmdline.d.ts +14 -0
- package/dist/guest/sessions/cmdline.js +10 -0
- package/dist/guest/setup/addon-mcp.d.ts +2 -0
- package/dist/guest/setup/addon-mcp.js +49 -0
- package/dist/guest/setup/agent-addons.d.ts +9 -0
- package/dist/guest/setup/agent-addons.js +22 -0
- package/dist/guest/setup/headroom.d.ts +7 -0
- package/dist/guest/setup/headroom.js +60 -0
- package/dist/guest/setup/managed-policy.d.ts +2 -1
- package/dist/guest/setup/managed-policy.js +15 -6
- package/dist/guest/toolchains/plan.js +8 -3
- package/dist/guest/toolchains/setup.js +3 -0
- package/dist/host/audit/attribute.d.ts +46 -0
- package/dist/host/audit/attribute.js +76 -0
- package/dist/host/audit/collect.d.ts +29 -3
- package/dist/host/audit/collect.js +256 -61
- package/dist/host/audit/git-commits.d.ts +29 -0
- package/dist/host/audit/git-commits.js +157 -0
- package/dist/host/audit/index.d.ts +5 -0
- package/dist/host/audit/index.js +6 -0
- package/dist/host/audit/log.d.ts +45 -7
- package/dist/host/audit/log.js +148 -45
- package/dist/host/audit/redact.d.ts +2 -0
- package/dist/host/audit/redact.js +67 -0
- package/dist/host/channel/channel.d.ts +5 -1
- package/dist/host/channel/channel.js +12 -0
- package/dist/host/guard/agent-files.js +7 -1
- package/dist/host/guard/commands.js +13 -1
- package/dist/host/guard/git-config.js +27 -7
- package/dist/host/guard/git-dirs.js +78 -3
- package/dist/host/guard/mark-of-the-web.js +3 -1
- package/dist/host/guard/nested-repos.js +7 -2
- package/dist/host/guard/store.js +1 -0
- package/dist/host/guard/types.d.ts +2 -0
- package/dist/host/launch/mappings.js +11 -1
- package/dist/host/launch/project-scan.js +2 -21
- package/dist/host/launch/stage.js +4 -0
- package/dist/host/life/session-ledger.js +2 -1
- package/dist/host/remote/access-store.d.ts +156 -0
- package/dist/host/remote/access-store.js +308 -0
- package/dist/host/remote/cert.d.ts +20 -0
- package/dist/host/remote/cert.js +103 -0
- package/dist/host/remote/door-client.d.ts +13 -0
- package/dist/host/remote/door-client.js +54 -0
- package/dist/host/remote/firewall.d.ts +44 -0
- package/dist/host/remote/firewall.js +118 -0
- package/dist/host/remote/gate.d.ts +125 -0
- package/dist/host/remote/gate.js +391 -0
- package/dist/host/remote/index.d.ts +8 -0
- package/dist/host/remote/index.js +10 -0
- package/dist/host/remote/lan.d.ts +24 -0
- package/dist/host/remote/lan.js +88 -0
- package/dist/host/remote/tls-store.d.ts +22 -0
- package/dist/host/remote/tls-store.js +63 -0
- package/dist/host/remote/windows-helper.d.ts +67 -0
- package/dist/host/remote/windows-helper.js +129 -0
- package/dist/host/web/bridge.d.ts +5 -1
- package/dist/host/web/bridge.js +6 -2
- package/dist/host/web/browser.js +1 -1
- package/dist/host/web/control.d.ts +4 -1
- package/dist/host/web/control.js +28 -4
- package/dist/host/web/rpc.d.ts +16 -2
- package/dist/host/web/rpc.js +37 -4
- package/dist/host/web/runner.d.ts +4 -0
- package/dist/host/web/runner.js +12 -0
- package/dist/host/web/server.d.ts +18 -2
- package/dist/host/web/server.js +97 -24
- package/dist/host/web/state.d.ts +5 -0
- package/dist/host/web/state.js +8 -0
- package/dist/runtime/agent.js +184 -18
- package/dist/runtime/daemon.js +772 -338
- package/dist/runtime/db.js +184 -18
- package/dist/runtime/gitguard.js +6 -0
- package/dist/runtime/main.js +966 -553
- package/dist/runtime/netproxy.js +184 -18
- package/dist/runtime/view.js +186 -18
- package/dist/selftest/app.test.js +66 -7
- package/dist/selftest/audit.test.js +354 -47
- package/dist/selftest/channel.test.js +16 -0
- package/dist/selftest/cmdline.test.js +16 -0
- package/dist/selftest/git-access.test.js +8 -0
- package/dist/selftest/hcs.test.js +35 -0
- package/dist/selftest/hostile-input.test.js +12 -1
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/live-daemon.test.js +25 -0
- package/dist/selftest/local-config.test.js +30 -0
- package/dist/selftest/menu-screens.js +1 -1
- package/dist/selftest/menu.test.js +3 -3
- package/dist/selftest/remote.test.d.ts +2 -0
- package/dist/selftest/remote.test.js +536 -0
- package/dist/selftest/repo-guard.test.js +56 -2
- package/dist/selftest/resume.test.js +2 -1
- package/dist/selftest/sharing.test.js +9 -2
- package/dist/selftest/support/engine-golden.js +9 -0
- package/dist/selftest/support/fixtures.d.ts +14 -0
- package/dist/selftest/support/web-preview.js +157 -0
- package/dist/selftest/toolchains.test.js +147 -2
- package/dist/selftest/web.test.js +2 -0
- package/dist/shared/apps/agent-addons.d.ts +64 -0
- package/dist/shared/apps/agent-addons.js +95 -0
- package/dist/shared/apps/catalogue.d.ts +33 -2
- package/dist/shared/apps/catalogue.js +102 -11
- package/dist/shared/apps/index.d.ts +3 -2
- package/dist/shared/apps/index.js +3 -2
- package/dist/shared/apps/selection.d.ts +2 -0
- package/dist/shared/apps/selection.js +7 -0
- package/dist/shared/channel/schema.d.ts +23 -0
- package/dist/shared/channel/schema.js +8 -0
- package/dist/shared/pty/protocol.d.ts +2 -0
- package/dist/shared/pty/protocol.js +2 -0
- package/dist/sync/watcher.js +9 -1
- package/dist/web/index.html +2 -2
- package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
- package/dist/web/main-Z2WYAE2Z.js +221 -0
- package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
- package/native/csharp/HcsHost/Hcs.cs +9 -0
- package/native/csharp/HcsHost/Program.cs +26 -1
- package/native/csharp/HcsHost/Share.cs +173 -0
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/Program.cs +17 -1
- package/native/csharp/HostTools/WebHelper.cs +499 -0
- package/native/scripts/get-network-profile.ps1 +19 -0
- package/native/scripts/set-folder-access.ps1 +11 -0
- package/native/scripts/set-web-firewall.ps1 +103 -0
- package/package.json +1 -1
- package/sandbox/addons/ccusage/package-lock.json +111 -0
- package/sandbox/addons/ccusage/package.json +7 -0
- package/sandbox/addons/context7/package-lock.json +1313 -0
- package/sandbox/addons/context7/package.json +7 -0
- package/sandbox/addons/headroom/requirements.in +1 -0
- package/sandbox/addons/headroom/requirements.txt +2922 -0
- package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
- package/sandbox/addons/playwright-mcp/package.json +7 -0
- package/sandbox/csharp/PtyHost.cs +20 -0
- package/schemas/config.schema.json +78 -4
- package/schemas/sandbox-config.schema.json +2 -20
- package/dist/cli/commands/maintenance/audit.d.ts +0 -3
- package/dist/cli/commands/maintenance/audit.js +0 -58
- package/dist/web/main-K2JMCAZI.js +0 -219
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import type net from 'node:net';
|
|
3
|
+
import type { Duplex } from 'node:stream';
|
|
4
|
+
import type { WebSocket } from 'ws';
|
|
5
|
+
import { type Caller } from '../../app/kernel/index.js';
|
|
6
|
+
import type { WebRemote } from '../../core/schema/web.js';
|
|
7
|
+
import type { AccessStore } from './access-store.js';
|
|
8
|
+
import { type NetworkProfile } from './firewall.js';
|
|
9
|
+
import { type Interfaces } from './lan.js';
|
|
10
|
+
import type { TlsStore } from './tls-store.js';
|
|
11
|
+
import { type Knock, type WindowsHelper } from './windows-helper.js';
|
|
12
|
+
export declare const WINDOWS_COOKIE = "sbxwin";
|
|
13
|
+
/** `__Host-`: the browser keeps it only with Secure, Path=/ and no Domain. */
|
|
14
|
+
export declare const DEVICE_COOKIE = "__Host-sbxdev";
|
|
15
|
+
/** A Windows user's one-time link is good for this long. */
|
|
16
|
+
export declare const ONCE_LIFETIME_MS = 60000;
|
|
17
|
+
/** Wrong device codes per address per minute. */
|
|
18
|
+
export declare const MAX_WRONG_PER_ADDRESS = 5;
|
|
19
|
+
/** One line of the audit log about a remote action. */
|
|
20
|
+
export interface RemoteAuditEntry {
|
|
21
|
+
readonly who: string;
|
|
22
|
+
readonly what: string;
|
|
23
|
+
readonly outcome: 'ok' | 'refused' | 'failed';
|
|
24
|
+
}
|
|
25
|
+
/** How the LAN listener hands requests and upgrades to the web server. */
|
|
26
|
+
export interface LanHandlers {
|
|
27
|
+
readonly request: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
28
|
+
readonly upgrade: (req: http.IncomingMessage, socket: Duplex, head: Buffer) => void;
|
|
29
|
+
}
|
|
30
|
+
/** One RPC call as the socket saw it. */
|
|
31
|
+
export interface CallRecord {
|
|
32
|
+
readonly caller: Caller;
|
|
33
|
+
readonly op: string;
|
|
34
|
+
readonly kind: 'operation' | 'query';
|
|
35
|
+
readonly outcome: 'ok' | 'refused' | 'failed';
|
|
36
|
+
}
|
|
37
|
+
/** What a route needs from the server (body parsing, answers, the same-site check). */
|
|
38
|
+
export interface RouteIO {
|
|
39
|
+
body(): Promise<unknown>;
|
|
40
|
+
json(status: number, body: unknown, headers?: http.OutgoingHttpHeaders): void;
|
|
41
|
+
requireSameSite(): void;
|
|
42
|
+
}
|
|
43
|
+
export interface LanState {
|
|
44
|
+
readonly running: boolean;
|
|
45
|
+
readonly url: string | null;
|
|
46
|
+
readonly fingerprint: string | null;
|
|
47
|
+
readonly notAfter: string | null;
|
|
48
|
+
/** Why it is not running although it is on. */
|
|
49
|
+
readonly problem: string | null;
|
|
50
|
+
}
|
|
51
|
+
export interface GateOptions {
|
|
52
|
+
readonly store: AccessStore;
|
|
53
|
+
/** The remote settings as they are now (read again by the caller when the file changes). */
|
|
54
|
+
readonly settings: () => WebRemote;
|
|
55
|
+
/** The owner's SID ('' when unknown: Windows sign-in stays off). */
|
|
56
|
+
readonly ownerSid: string;
|
|
57
|
+
/** null: no helper here (no csc): no Windows sign-in, the owner's cookie unbound as before. */
|
|
58
|
+
readonly helper: WindowsHelper | null;
|
|
59
|
+
readonly audit: (entry: RemoteAuditEntry) => void;
|
|
60
|
+
readonly tls?: TlsStore;
|
|
61
|
+
/** The network an address is on (get-network-profile.ps1); tests pass a fake. */
|
|
62
|
+
readonly profile?: (address: string) => Promise<NetworkProfile>;
|
|
63
|
+
readonly interfaces?: () => Interfaces;
|
|
64
|
+
readonly now?: () => number;
|
|
65
|
+
/** The loopback port (for the one-time link a Windows user gets). */
|
|
66
|
+
readonly port: () => number;
|
|
67
|
+
}
|
|
68
|
+
/** The Set-Cookie value of a remembered credential. */
|
|
69
|
+
export declare function credentialCookie(name: string, value: string, days: number, secure: boolean): string;
|
|
70
|
+
/** A device name as shown on the PC: no escape sequences or control characters, trimmed, at most 40 characters. */
|
|
71
|
+
export declare function deviceName(value: unknown): string;
|
|
72
|
+
export declare class RemoteGate {
|
|
73
|
+
private readonly o;
|
|
74
|
+
private readonly peers;
|
|
75
|
+
private readonly once;
|
|
76
|
+
private readonly wrong;
|
|
77
|
+
private readonly tracked;
|
|
78
|
+
private roles;
|
|
79
|
+
private lan;
|
|
80
|
+
private lanProblem;
|
|
81
|
+
private handlers;
|
|
82
|
+
private lanListener;
|
|
83
|
+
private timer;
|
|
84
|
+
private readonly now;
|
|
85
|
+
constructor(o: GateOptions);
|
|
86
|
+
/** The account at the other end of this loopback connection (asked once per TCP connection). */
|
|
87
|
+
private peer;
|
|
88
|
+
private sameSid;
|
|
89
|
+
/** The role of a Windows user, by SID (names in the settings are turned into SIDs by Windows). */
|
|
90
|
+
private roleOf;
|
|
91
|
+
/** The LAN listener got this socket (TLS); everything else is loopback. */
|
|
92
|
+
isLan(socket: net.Socket): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The caller of a request on either listener. `ownerCookie`: the request carries the owner's token cookie (checked
|
|
95
|
+
* by the server's WebAuth). Null: nobody we let in.
|
|
96
|
+
*/
|
|
97
|
+
caller(req: http.IncomingMessage, ownerCookie: boolean): Promise<Caller | null>;
|
|
98
|
+
private lanHost;
|
|
99
|
+
/** The Host header a LAN request must carry: exactly <address>:<port> (no names: DNS rebinding fails). */
|
|
100
|
+
lanHostAllowed(host: string | undefined): boolean;
|
|
101
|
+
lanOriginAllowed(origin: string | undefined, host: string | undefined): boolean;
|
|
102
|
+
/** Serves the remote sign-in routes; false when the path is not one of them. */
|
|
103
|
+
route(req: http.IncomingMessage, pathname: string, io: RouteIO): Promise<boolean>;
|
|
104
|
+
private tooMany;
|
|
105
|
+
private countWrong;
|
|
106
|
+
private pair;
|
|
107
|
+
private loginOnce;
|
|
108
|
+
/** A knock on the pipe: a one-time link for a Windows user on the list, else the reason. */
|
|
109
|
+
knock(k: Knock): Promise<string>;
|
|
110
|
+
/** Watches a socket: closed when its caller no longer gets in. Returns the caller as it is now, for each use. */
|
|
111
|
+
track(ws: WebSocket, req: http.IncomingMessage, ownerCookie: boolean, first: Caller): () => Caller | null;
|
|
112
|
+
/** Checks every socket again (and LAN mode against the settings); closes those whose access ended. */
|
|
113
|
+
recheck(): Promise<void>;
|
|
114
|
+
/** Starts the LAN listener when LAN mode is on and allowed here; else notes why. Devices go when it is off. */
|
|
115
|
+
startLan(handlers: LanHandlers): Promise<void>;
|
|
116
|
+
/** Told whenever the LAN listener starts, stops or fails (the server's state file says where it is). */
|
|
117
|
+
onLanChange(listener: (state: LanState) => void): void;
|
|
118
|
+
private stopLan;
|
|
119
|
+
/** An RPC call of someone other than the owner, for the audit log; after an operation, access is checked again. */
|
|
120
|
+
recordCall(e: CallRecord): void;
|
|
121
|
+
lanState(): LanState;
|
|
122
|
+
/** Starts the periodic check. */
|
|
123
|
+
start(): void;
|
|
124
|
+
stop(): Promise<void>;
|
|
125
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Who is at the other end of a web UI request when other Windows users and devices may come in
|
|
2
|
+
// (docs/design/remote-access.md). The web server asks the gate for the caller of every request and socket; the gate
|
|
3
|
+
// serves the routes of remote sign-in (/api/pair on the LAN listener, /api/login/once on loopback), answers knocks on
|
|
4
|
+
// the Windows sign-in pipe, runs the LAN listener, and closes sockets whose access ended. Each listener accepts a fixed
|
|
5
|
+
// set of credentials: loopback the owner's token cookie and Windows sign-ins (both bound to the Windows user of the
|
|
6
|
+
// connection when the helper runs), the LAN listener only device credentials.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import https from 'node:https';
|
|
9
|
+
import { callerLabel } from '../../app/kernel/index.js';
|
|
10
|
+
import { safeText } from '../../core/util/index.js';
|
|
11
|
+
import { cookieValue } from '../web/index.js';
|
|
12
|
+
import { networkProblem } from './firewall.js';
|
|
13
|
+
import { chooseLanAddress, isLoopbackIPv4, lanPeerProblem, lanUrl, plainIPv4, privateAddresses, } from './lan.js';
|
|
14
|
+
import { knockProblem } from './windows-helper.js';
|
|
15
|
+
export const WINDOWS_COOKIE = 'sbxwin';
|
|
16
|
+
/** `__Host-`: the browser keeps it only with Secure, Path=/ and no Domain. */
|
|
17
|
+
export const DEVICE_COOKIE = '__Host-sbxdev';
|
|
18
|
+
/** A Windows user's one-time link is good for this long. */
|
|
19
|
+
export const ONCE_LIFETIME_MS = 60_000;
|
|
20
|
+
/** Wrong device codes per address per minute. */
|
|
21
|
+
export const MAX_WRONG_PER_ADDRESS = 5;
|
|
22
|
+
const MINUTE_MS = 60_000;
|
|
23
|
+
const RECHECK_MS = 30_000;
|
|
24
|
+
const DAY_S = 86_400;
|
|
25
|
+
const MAX_DEVICE_NAME = 40;
|
|
26
|
+
const MAX_ONCE = 32;
|
|
27
|
+
class Refused extends Error {
|
|
28
|
+
status;
|
|
29
|
+
constructor(status, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The Set-Cookie value of a remembered credential. */
|
|
35
|
+
export function credentialCookie(name, value, days, secure) {
|
|
36
|
+
return (name + '=' + value + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + days * DAY_S + (secure ? '; Secure' : ''));
|
|
37
|
+
}
|
|
38
|
+
/** A device name as shown on the PC: no escape sequences or control characters, trimmed, at most 40 characters. */
|
|
39
|
+
export function deviceName(value) {
|
|
40
|
+
const text = typeof value === 'string'
|
|
41
|
+
? safeText(value.slice(0, 200))
|
|
42
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
43
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.trim()
|
|
46
|
+
: '';
|
|
47
|
+
return text.slice(0, MAX_DEVICE_NAME) || 'A device';
|
|
48
|
+
}
|
|
49
|
+
export class RemoteGate {
|
|
50
|
+
o;
|
|
51
|
+
peers = new WeakMap();
|
|
52
|
+
once = new Map();
|
|
53
|
+
wrong = new Map();
|
|
54
|
+
tracked = new Set();
|
|
55
|
+
roles;
|
|
56
|
+
lan = null;
|
|
57
|
+
lanProblem = null;
|
|
58
|
+
handlers;
|
|
59
|
+
lanListener = () => undefined;
|
|
60
|
+
timer;
|
|
61
|
+
now;
|
|
62
|
+
constructor(o) {
|
|
63
|
+
this.o = o;
|
|
64
|
+
this.now = o.now ?? Date.now;
|
|
65
|
+
o.helper?.onKnock((k) => this.knock(k));
|
|
66
|
+
}
|
|
67
|
+
/** The account at the other end of this loopback connection (asked once per TCP connection). */
|
|
68
|
+
peer(socket) {
|
|
69
|
+
const helper = this.o.helper;
|
|
70
|
+
if (!helper)
|
|
71
|
+
return Promise.resolve(null);
|
|
72
|
+
let known = this.peers.get(socket);
|
|
73
|
+
if (!known) {
|
|
74
|
+
known = helper.peer({
|
|
75
|
+
localAddress: plainIPv4(socket.localAddress),
|
|
76
|
+
localPort: socket.localPort ?? 0,
|
|
77
|
+
remoteAddress: plainIPv4(socket.remoteAddress),
|
|
78
|
+
remotePort: socket.remotePort ?? 0,
|
|
79
|
+
});
|
|
80
|
+
this.peers.set(socket, known);
|
|
81
|
+
}
|
|
82
|
+
return known;
|
|
83
|
+
}
|
|
84
|
+
sameSid(a, b) {
|
|
85
|
+
return a.toUpperCase() === b.toUpperCase();
|
|
86
|
+
}
|
|
87
|
+
/** The role of a Windows user, by SID (names in the settings are turned into SIDs by Windows). */
|
|
88
|
+
async roleOf(sid) {
|
|
89
|
+
const settings = this.o.settings();
|
|
90
|
+
if (!settings.windowsSignIn || !this.o.helper)
|
|
91
|
+
return null;
|
|
92
|
+
const key = JSON.stringify(settings.users);
|
|
93
|
+
if (this.roles?.key !== key) {
|
|
94
|
+
const helper = this.o.helper;
|
|
95
|
+
const bySid = (async () => {
|
|
96
|
+
const map = new Map();
|
|
97
|
+
for (const u of settings.users) {
|
|
98
|
+
const account = await helper.lookup(u.name);
|
|
99
|
+
if (account && !map.has(account.sid.toUpperCase()))
|
|
100
|
+
map.set(account.sid.toUpperCase(), u.role);
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
})();
|
|
104
|
+
this.roles = { key, bySid };
|
|
105
|
+
}
|
|
106
|
+
return (await this.roles.bySid).get(sid.toUpperCase()) ?? null;
|
|
107
|
+
}
|
|
108
|
+
/** The LAN listener got this socket (TLS); everything else is loopback. */
|
|
109
|
+
isLan(socket) {
|
|
110
|
+
return socket.encrypted === true;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The caller of a request on either listener. `ownerCookie`: the request carries the owner's token cookie (checked
|
|
114
|
+
* by the server's WebAuth). Null: nobody we let in.
|
|
115
|
+
*/
|
|
116
|
+
async caller(req, ownerCookie) {
|
|
117
|
+
const socket = req.socket;
|
|
118
|
+
if (this.isLan(socket)) {
|
|
119
|
+
if (!this.lan || !this.o.settings().lan.enabled)
|
|
120
|
+
return null;
|
|
121
|
+
const device = this.o.store.device(cookieValue(req.headers.cookie, DEVICE_COOKIE));
|
|
122
|
+
if (!device)
|
|
123
|
+
return null;
|
|
124
|
+
this.o.store.seen(device.id);
|
|
125
|
+
return { kind: 'device', id: device.id, name: device.name, role: device.role };
|
|
126
|
+
}
|
|
127
|
+
if (!isLoopbackIPv4(plainIPv4(socket.remoteAddress)))
|
|
128
|
+
return null;
|
|
129
|
+
if (ownerCookie) {
|
|
130
|
+
// Bound to the owner's own connections when Windows can tell (a cookie taken through another port is useless).
|
|
131
|
+
if (!this.o.helper || !this.o.ownerSid)
|
|
132
|
+
return { kind: 'owner' };
|
|
133
|
+
const account = await this.peer(socket);
|
|
134
|
+
return account && this.sameSid(account.sid, this.o.ownerSid) ? { kind: 'owner' } : null;
|
|
135
|
+
}
|
|
136
|
+
const grant = this.o.store.windowsGrant(cookieValue(req.headers.cookie, WINDOWS_COOKIE));
|
|
137
|
+
if (!grant)
|
|
138
|
+
return null;
|
|
139
|
+
const account = await this.peer(socket);
|
|
140
|
+
if (!account || !this.sameSid(account.sid, grant.sid))
|
|
141
|
+
return null;
|
|
142
|
+
const role = await this.roleOf(grant.sid);
|
|
143
|
+
return role ? { kind: 'windows', user: grant.user, role } : null;
|
|
144
|
+
}
|
|
145
|
+
lanHost() {
|
|
146
|
+
return this.lan ? this.lan.bound.address + ':' + this.lan.port : null;
|
|
147
|
+
}
|
|
148
|
+
/** The Host header a LAN request must carry: exactly <address>:<port> (no names: DNS rebinding fails). */
|
|
149
|
+
lanHostAllowed(host) {
|
|
150
|
+
const expected = this.lanHost();
|
|
151
|
+
return expected !== null && host === expected;
|
|
152
|
+
}
|
|
153
|
+
lanOriginAllowed(origin, host) {
|
|
154
|
+
return this.lanHostAllowed(host) && origin === 'https://' + host;
|
|
155
|
+
}
|
|
156
|
+
/** Serves the remote sign-in routes; false when the path is not one of them. */
|
|
157
|
+
async route(req, pathname, io) {
|
|
158
|
+
const lan = this.isLan(req.socket);
|
|
159
|
+
try {
|
|
160
|
+
if (pathname === '/api/pair' && req.method === 'POST') {
|
|
161
|
+
if (!lan)
|
|
162
|
+
throw new Refused(404, 'Not found.');
|
|
163
|
+
await this.pair(req, io);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (pathname === '/api/login/once' && req.method === 'POST') {
|
|
167
|
+
if (lan)
|
|
168
|
+
throw new Refused(404, 'Not found.');
|
|
169
|
+
await this.loginOnce(req, io);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
// The LAN listener has no token login and no stop.
|
|
173
|
+
if (lan && (pathname === '/api/login' || pathname === '/api/stop'))
|
|
174
|
+
throw new Refused(404, 'Not found.');
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (e instanceof Refused) {
|
|
179
|
+
io.json(e.status, { error: e.message });
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
tooMany(address) {
|
|
186
|
+
const t = this.now();
|
|
187
|
+
const recent = (this.wrong.get(address) ?? []).filter((at) => t - at < MINUTE_MS);
|
|
188
|
+
this.wrong.set(address, recent);
|
|
189
|
+
return recent.length >= MAX_WRONG_PER_ADDRESS;
|
|
190
|
+
}
|
|
191
|
+
countWrong(address) {
|
|
192
|
+
this.wrong.set(address, [...(this.wrong.get(address) ?? []), this.now()]);
|
|
193
|
+
if (this.wrong.size > 1000)
|
|
194
|
+
this.wrong.delete(this.wrong.keys().next().value);
|
|
195
|
+
}
|
|
196
|
+
async pair(req, io) {
|
|
197
|
+
io.requireSameSite();
|
|
198
|
+
const address = plainIPv4(req.socket.remoteAddress);
|
|
199
|
+
const body = (await io.body());
|
|
200
|
+
if (!this.o.store.codePending())
|
|
201
|
+
throw new Refused(409, 'No new device is being allowed right now. On the PC, choose "Allow a new device".');
|
|
202
|
+
if (this.tooMany(address))
|
|
203
|
+
throw new Refused(429, 'Too many wrong codes. Wait a minute.');
|
|
204
|
+
const name = deviceName(body?.name);
|
|
205
|
+
const result = this.o.store.redeem(body?.code, name, address, this.o.settings().rememberDays);
|
|
206
|
+
if (!result.ok) {
|
|
207
|
+
if (result.reason === 'wrong' || result.reason === 'void')
|
|
208
|
+
this.countWrong(address);
|
|
209
|
+
this.o.audit({ who: 'device ' + name + ' (' + address + ')', what: 'allow device', outcome: 'refused' });
|
|
210
|
+
throw new Refused(result.reason === 'expired' || result.reason === 'none' ? 409 : 403, result.reason === 'void'
|
|
211
|
+
? 'That code is no longer valid. Ask for a new one on the PC.'
|
|
212
|
+
: result.reason === 'wrong'
|
|
213
|
+
? 'That code is not right.'
|
|
214
|
+
: 'That code has run out. Ask for a new one on the PC.');
|
|
215
|
+
}
|
|
216
|
+
this.o.audit({ who: 'device ' + name + ' (' + address + ')', what: 'allow device', outcome: 'ok' });
|
|
217
|
+
io.json(200, { ok: true, role: result.device.role }, { 'Set-Cookie': credentialCookie(DEVICE_COOKIE, result.credential, this.o.settings().rememberDays, true) });
|
|
218
|
+
}
|
|
219
|
+
async loginOnce(req, io) {
|
|
220
|
+
io.requireSameSite();
|
|
221
|
+
const body = (await io.body());
|
|
222
|
+
const token = typeof body?.token === 'string' && /^[0-9a-f]{64}$/.test(body.token) ? body.token : '';
|
|
223
|
+
const key = crypto.createHash('sha256').update(token).digest('hex');
|
|
224
|
+
const entry = this.once.get(key);
|
|
225
|
+
this.once.delete(key);
|
|
226
|
+
if (!entry || entry.expires < this.now())
|
|
227
|
+
throw new Refused(401, 'That sign-in link has run out. Run it again.');
|
|
228
|
+
// The link must be used by the same Windows user it was made for.
|
|
229
|
+
const account = await this.peer(req.socket);
|
|
230
|
+
if (!account || !this.sameSid(account.sid, entry.account.sid)) {
|
|
231
|
+
this.o.audit({ who: 'Windows user ' + entry.account.user, what: 'sign in', outcome: 'refused' });
|
|
232
|
+
throw new Refused(403, 'That sign-in link is for another Windows user.');
|
|
233
|
+
}
|
|
234
|
+
const role = await this.roleOf(entry.account.sid);
|
|
235
|
+
if (!role)
|
|
236
|
+
throw new Refused(403, 'You may not use this web UI.');
|
|
237
|
+
const days = this.o.settings().rememberDays;
|
|
238
|
+
const credential = this.o.store.issueWindows(entry.account.sid, entry.account.user, days);
|
|
239
|
+
this.o.audit({ who: 'Windows user ' + entry.account.user, what: 'sign in', outcome: 'ok' });
|
|
240
|
+
io.json(200, { ok: true, role }, { 'Set-Cookie': credentialCookie(WINDOWS_COOKIE, credential, days, false) });
|
|
241
|
+
}
|
|
242
|
+
/** A knock on the pipe: a one-time link for a Windows user on the list, else the reason. */
|
|
243
|
+
async knock(k) {
|
|
244
|
+
const who = 'Windows user ' + k.user;
|
|
245
|
+
const refuse = (reason) => {
|
|
246
|
+
this.o.audit({ who, what: 'sign in (' + reason + ')', outcome: 'refused' });
|
|
247
|
+
return 'refused ' + reason;
|
|
248
|
+
};
|
|
249
|
+
if (!this.o.ownerSid)
|
|
250
|
+
return refuse('the owner is unknown');
|
|
251
|
+
const problem = knockProblem(k, this.o.ownerSid);
|
|
252
|
+
if (problem)
|
|
253
|
+
return refuse(problem);
|
|
254
|
+
if (!this.o.settings().windowsSignIn)
|
|
255
|
+
return refuse('Windows sign-in is off');
|
|
256
|
+
const role = await this.roleOf(k.sid);
|
|
257
|
+
if (!role)
|
|
258
|
+
return refuse('not on the list');
|
|
259
|
+
const t = this.now();
|
|
260
|
+
for (const [key, e] of this.once)
|
|
261
|
+
if (e.expires < t)
|
|
262
|
+
this.once.delete(key);
|
|
263
|
+
if (this.once.size >= MAX_ONCE)
|
|
264
|
+
return refuse('too many sign-ins at once');
|
|
265
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
266
|
+
this.once.set(crypto.createHash('sha256').update(token).digest('hex'), {
|
|
267
|
+
account: { sid: k.sid, user: k.user },
|
|
268
|
+
expires: t + ONCE_LIFETIME_MS,
|
|
269
|
+
});
|
|
270
|
+
return 'ok ' + token + ' ' + this.o.port() + ' ' + role;
|
|
271
|
+
}
|
|
272
|
+
/** Watches a socket: closed when its caller no longer gets in. Returns the caller as it is now, for each use. */
|
|
273
|
+
track(ws, req, ownerCookie, first) {
|
|
274
|
+
const entry = { ws, check: () => this.caller(req, ownerCookie), current: first };
|
|
275
|
+
this.tracked.add(entry);
|
|
276
|
+
ws.on('close', () => this.tracked.delete(entry));
|
|
277
|
+
return () => entry.current;
|
|
278
|
+
}
|
|
279
|
+
/** Checks every socket again (and LAN mode against the settings); closes those whose access ended. */
|
|
280
|
+
async recheck() {
|
|
281
|
+
const lanOn = this.o.settings().lan.enabled;
|
|
282
|
+
if (this.lan && !lanOn)
|
|
283
|
+
await this.stopLan(true);
|
|
284
|
+
else if (!this.lan && lanOn && this.handlers)
|
|
285
|
+
await this.startLan(this.handlers);
|
|
286
|
+
for (const entry of [...this.tracked]) {
|
|
287
|
+
let now = null;
|
|
288
|
+
try {
|
|
289
|
+
now = await entry.check();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
now = null;
|
|
293
|
+
}
|
|
294
|
+
const before = entry.current;
|
|
295
|
+
entry.current = now;
|
|
296
|
+
const downgraded = before && now && before.kind !== 'owner' && now.kind !== 'owner' && before.role !== now.role;
|
|
297
|
+
if (!now || downgraded) {
|
|
298
|
+
entry.current = null;
|
|
299
|
+
entry.ws.close(4001, now ? 'your access changed' : 'signed out');
|
|
300
|
+
this.tracked.delete(entry);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** Starts the LAN listener when LAN mode is on and allowed here; else notes why. Devices go when it is off. */
|
|
305
|
+
async startLan(handlers) {
|
|
306
|
+
this.handlers = handlers;
|
|
307
|
+
const settings = this.o.settings().lan;
|
|
308
|
+
this.lanProblem = null;
|
|
309
|
+
if (!settings.enabled) {
|
|
310
|
+
if (this.o.store.read().devices.length)
|
|
311
|
+
this.o.store.revokeAll('devices');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const interfaces = this.o.interfaces ?? (() => undefined);
|
|
315
|
+
try {
|
|
316
|
+
const bound = chooseLanAddress(settings.address, interfaces());
|
|
317
|
+
if (!this.o.tls || !this.o.profile)
|
|
318
|
+
throw new Error('LAN mode is not available here.');
|
|
319
|
+
const network = this.o.store.read().network;
|
|
320
|
+
const profile = await this.o.profile(bound.address);
|
|
321
|
+
const problem = networkProblem(profile, network);
|
|
322
|
+
if (problem)
|
|
323
|
+
throw new Error(problem);
|
|
324
|
+
if (network === null)
|
|
325
|
+
this.o.store.setNetwork(profile.name);
|
|
326
|
+
const tls = this.o.tls.material(privateAddresses(interfaces()).map((a) => a.address));
|
|
327
|
+
const server = https.createServer({ cert: tls.cert, key: tls.key, minVersion: 'TLSv1.2' }, handlers.request);
|
|
328
|
+
server.on('upgrade', handlers.upgrade);
|
|
329
|
+
// Before a byte of TLS: only on-link private peers of this adapter, never this PC or a sandbox.
|
|
330
|
+
server.on('connection', (socket) => {
|
|
331
|
+
if (lanPeerProblem(socket.remoteAddress, socket.localAddress, bound, interfaces()))
|
|
332
|
+
socket.destroy();
|
|
333
|
+
});
|
|
334
|
+
server.headersTimeout = 15000;
|
|
335
|
+
await new Promise((resolve, reject) => {
|
|
336
|
+
server.once('error', reject);
|
|
337
|
+
server.listen({ port: settings.port, host: bound.address, exclusive: true }, () => {
|
|
338
|
+
server.off('error', reject);
|
|
339
|
+
resolve();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
this.lan = { server, bound, port: settings.port, fingerprint: tls.fingerprint, notAfter: tls.notAfter };
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
this.lanProblem = e instanceof Error ? e.message : String(e);
|
|
346
|
+
}
|
|
347
|
+
this.lanListener(this.lanState());
|
|
348
|
+
}
|
|
349
|
+
/** Told whenever the LAN listener starts, stops or fails (the server's state file says where it is). */
|
|
350
|
+
onLanChange(listener) {
|
|
351
|
+
this.lanListener = listener;
|
|
352
|
+
}
|
|
353
|
+
async stopLan(revoke) {
|
|
354
|
+
const lan = this.lan;
|
|
355
|
+
this.lan = null;
|
|
356
|
+
if (revoke)
|
|
357
|
+
this.o.store.revokeAll('devices');
|
|
358
|
+
if (lan) {
|
|
359
|
+
lan.server.closeAllConnections();
|
|
360
|
+
await new Promise((resolve) => lan.server.close(() => resolve()));
|
|
361
|
+
}
|
|
362
|
+
this.lanListener(this.lanState());
|
|
363
|
+
}
|
|
364
|
+
/** An RPC call of someone other than the owner, for the audit log; after an operation, access is checked again. */
|
|
365
|
+
recordCall(e) {
|
|
366
|
+
if (e.caller.kind === 'owner')
|
|
367
|
+
return;
|
|
368
|
+
this.o.audit({ who: callerLabel(e.caller), what: e.op, outcome: e.outcome });
|
|
369
|
+
if (e.kind === 'operation')
|
|
370
|
+
void this.recheck();
|
|
371
|
+
}
|
|
372
|
+
lanState() {
|
|
373
|
+
return {
|
|
374
|
+
running: this.lan !== null,
|
|
375
|
+
url: this.lan ? lanUrl(this.lan.bound.address, this.lan.port) : null,
|
|
376
|
+
fingerprint: this.lan?.fingerprint ?? null,
|
|
377
|
+
notAfter: this.lan?.notAfter ?? null,
|
|
378
|
+
problem: this.lanProblem,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/** Starts the periodic check. */
|
|
382
|
+
start() {
|
|
383
|
+
this.timer = setInterval(() => void this.recheck(), RECHECK_MS);
|
|
384
|
+
this.timer.unref();
|
|
385
|
+
}
|
|
386
|
+
async stop() {
|
|
387
|
+
clearInterval(this.timer);
|
|
388
|
+
this.o.helper?.stop();
|
|
389
|
+
await this.stopLan(false);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { AccessStore, CODE_LIFETIME_MS, CODE_MAX_WRONG, EMPTY_ACCESS, newCode, normalizeCode, parseCredential, secretHash, showCode, type AccessData, type Device, type PendingCode, type RedeemResult, type WindowsGrant, } from './access-store.js';
|
|
2
|
+
export { knockOnDoor, parseDoorAnswer, type DoorAnswer } from './door-client.js';
|
|
3
|
+
export { CERT_DAYS, fingerprintOf, makeCertificate, type MadeCertificate } from './cert.js';
|
|
4
|
+
export { addRuleRequest, applyFirewall, checkFirewall, FIREWALL_RULE_NAME, networkProblem, networkProfile, removeRuleRequest, spawnRunner, type FirewallOutcome, type FirewallRequest, type NetworkProfile, type Runner, type ScriptRun, } from './firewall.js';
|
|
5
|
+
export { credentialCookie, DEVICE_COOKIE, deviceName, MAX_WRONG_PER_ADDRESS, ONCE_LIFETIME_MS, RemoteGate, WINDOWS_COOKIE, type GateOptions, type LanState, type RemoteAuditEntry, type RouteIO, } from './gate.js';
|
|
6
|
+
export { chooseLanAddress, isLoopbackIPv4, isPrivateIPv4, lanPeerProblem, lanUrl, plainIPv4, privateAddresses, type Interfaces, type LanAddress, } from './lan.js';
|
|
7
|
+
export { TlsStore, type TlsMaterial } from './tls-store.js';
|
|
8
|
+
export { doorPipeName, HostToolsHelper, knockProblem, parseHelperLine, type Connection, type Knock, type WindowsAccount, type WindowsHelper, } from './windows-helper.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Remote access to the web UI: other Windows users of this PC and devices on the local network
|
|
2
|
+
// (docs/design/remote-access.md).
|
|
3
|
+
export { AccessStore, CODE_LIFETIME_MS, CODE_MAX_WRONG, EMPTY_ACCESS, newCode, normalizeCode, parseCredential, secretHash, showCode, } from './access-store.js';
|
|
4
|
+
export { knockOnDoor, parseDoorAnswer } from './door-client.js';
|
|
5
|
+
export { CERT_DAYS, fingerprintOf, makeCertificate } from './cert.js';
|
|
6
|
+
export { addRuleRequest, applyFirewall, checkFirewall, FIREWALL_RULE_NAME, networkProblem, networkProfile, removeRuleRequest, spawnRunner, } from './firewall.js';
|
|
7
|
+
export { credentialCookie, DEVICE_COOKIE, deviceName, MAX_WRONG_PER_ADDRESS, ONCE_LIFETIME_MS, RemoteGate, WINDOWS_COOKIE, } from './gate.js';
|
|
8
|
+
export { chooseLanAddress, isLoopbackIPv4, isPrivateIPv4, lanPeerProblem, lanUrl, plainIPv4, privateAddresses, } from './lan.js';
|
|
9
|
+
export { TlsStore } from './tls-store.js';
|
|
10
|
+
export { doorPipeName, HostToolsHelper, knockProblem, parseHelperLine, } from './windows-helper.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
export type Interfaces = ReturnType<typeof os.networkInterfaces>;
|
|
3
|
+
/** An IPv4 address in 10/8, 172.16/12 or 192.168/16 (what home and office networks use). Nothing else counts. */
|
|
4
|
+
export declare function isPrivateIPv4(ip: string): boolean;
|
|
5
|
+
/** 127.0.0.0/8. */
|
|
6
|
+
export declare function isLoopbackIPv4(ip: string): boolean;
|
|
7
|
+
/** Node may report an IPv4 peer as ::ffff:a.b.c.d. */
|
|
8
|
+
export declare function plainIPv4(ip: string | undefined): string;
|
|
9
|
+
export interface LanAddress {
|
|
10
|
+
readonly address: string;
|
|
11
|
+
readonly netmask: string;
|
|
12
|
+
readonly adapter: string;
|
|
13
|
+
}
|
|
14
|
+
/** This PC's private IPv4 addresses on real adapters. */
|
|
15
|
+
export declare function privateAddresses(interfaces?: Interfaces): LanAddress[];
|
|
16
|
+
/**
|
|
17
|
+
* The address LAN mode listens on: the configured one when it is one of this PC's private addresses on a real
|
|
18
|
+
* adapter, else (empty setting) the first such address. Fails with the reason for anything else.
|
|
19
|
+
*/
|
|
20
|
+
export declare function chooseLanAddress(configured: string, interfaces?: Interfaces): LanAddress;
|
|
21
|
+
/** Why a LAN peer is refused, or null when it may talk to the LAN listener. */
|
|
22
|
+
export declare function lanPeerProblem(peer: string | undefined, local: string | undefined, bound: LanAddress, interfaces?: Interfaces): string | null;
|
|
23
|
+
/** https://<address>:<port>, what a phone types. */
|
|
24
|
+
export declare function lanUrl(address: string, port: number): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// LAN mode stays on the local network (docs/design/remote-access.md): it listens on one private IPv4 address of a real
|
|
2
|
+
// adapter of this PC (never 0.0.0.0, a public address, link-local or a Hyper-V vEthernet adapter, the sandbox's
|
|
3
|
+
// network), and every connection is checked again: the peer must be a private address on that adapter's own subnet,
|
|
4
|
+
// not this PC itself and not on any vEthernet subnet (listed again at every check: the sandbox's subnet changes every
|
|
5
|
+
// boot). Pure apart from os.networkInterfaces, which callers may pass.
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { fail } from '../../core/util/index.js';
|
|
9
|
+
const toInt = (ip) => ip.split('.').reduce((n, part) => n * 256 + Number(part), 0) >>> 0;
|
|
10
|
+
/** An IPv4 address in 10/8, 172.16/12 or 192.168/16 (what home and office networks use). Nothing else counts. */
|
|
11
|
+
export function isPrivateIPv4(ip) {
|
|
12
|
+
if (!net.isIPv4(ip))
|
|
13
|
+
return false;
|
|
14
|
+
const [a, b] = ip.split('.').map(Number);
|
|
15
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
16
|
+
}
|
|
17
|
+
/** 127.0.0.0/8. */
|
|
18
|
+
export function isLoopbackIPv4(ip) {
|
|
19
|
+
return net.isIPv4(ip) && ip.startsWith('127.');
|
|
20
|
+
}
|
|
21
|
+
/** Node may report an IPv4 peer as ::ffff:a.b.c.d. */
|
|
22
|
+
export function plainIPv4(ip) {
|
|
23
|
+
const value = ip ?? '';
|
|
24
|
+
return value.startsWith('::ffff:') && net.isIPv4(value.slice(7)) ? value.slice(7) : value;
|
|
25
|
+
}
|
|
26
|
+
/** The Hyper-V adapters (the sandbox's network, WSL's): never listened on, never trusted as a peer. */
|
|
27
|
+
function virtualAdapter(name) {
|
|
28
|
+
return /^vEthernet/i.test(name) || /Hyper-V|WSL/i.test(name);
|
|
29
|
+
}
|
|
30
|
+
/** This PC's private IPv4 addresses on real adapters. */
|
|
31
|
+
export function privateAddresses(interfaces = os.networkInterfaces()) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const [adapter, list] of Object.entries(interfaces)) {
|
|
34
|
+
if (virtualAdapter(adapter))
|
|
35
|
+
continue;
|
|
36
|
+
for (const i of list ?? [])
|
|
37
|
+
if (i.family === 'IPv4' && !i.internal && isPrivateIPv4(i.address))
|
|
38
|
+
out.push({ address: i.address, netmask: i.netmask, adapter });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The address LAN mode listens on: the configured one when it is one of this PC's private addresses on a real
|
|
44
|
+
* adapter, else (empty setting) the first such address. Fails with the reason for anything else.
|
|
45
|
+
*/
|
|
46
|
+
export function chooseLanAddress(configured, interfaces = os.networkInterfaces()) {
|
|
47
|
+
const own = privateAddresses(interfaces);
|
|
48
|
+
if (configured) {
|
|
49
|
+
if (!isPrivateIPv4(configured))
|
|
50
|
+
fail(configured + ' is not a private address (10.x, 172.16-31.x, 192.168.x), so the web UI does not listen on it.', 'LAN mode is for your own network only: set web.remote.lan.address to this PC address on it, or leave it empty.');
|
|
51
|
+
const found = own.find((a) => a.address === configured);
|
|
52
|
+
if (!found)
|
|
53
|
+
fail(configured + ' is not an address of a network adapter of this PC.', 'Leave web.remote.lan.address empty to use this PC address on your network.');
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
const first = own[0];
|
|
57
|
+
if (!first)
|
|
58
|
+
fail('This PC has no private network address (10.x, 172.16-31.x, 192.168.x) on a network adapter.', 'Connect it to your home or office network (Wi-Fi or cable), then try again.');
|
|
59
|
+
return first;
|
|
60
|
+
}
|
|
61
|
+
function sameSubnet(a, b, netmask) {
|
|
62
|
+
const mask = toInt(netmask);
|
|
63
|
+
return mask !== 0 && (toInt(a) & mask) === (toInt(b) & mask);
|
|
64
|
+
}
|
|
65
|
+
/** Why a LAN peer is refused, or null when it may talk to the LAN listener. */
|
|
66
|
+
export function lanPeerProblem(peer, local, bound, interfaces = os.networkInterfaces()) {
|
|
67
|
+
const ip = plainIPv4(peer);
|
|
68
|
+
if (plainIPv4(local) !== bound.address)
|
|
69
|
+
return 'reached another address of this PC';
|
|
70
|
+
if (!isPrivateIPv4(ip))
|
|
71
|
+
return 'not a private address';
|
|
72
|
+
if (!sameSubnet(ip, bound.address, bound.netmask))
|
|
73
|
+
return 'not on the same network';
|
|
74
|
+
for (const [name, list] of Object.entries(interfaces))
|
|
75
|
+
for (const i of list ?? []) {
|
|
76
|
+
if (i.family !== 'IPv4')
|
|
77
|
+
continue;
|
|
78
|
+
if (i.address === ip)
|
|
79
|
+
return 'this PC itself (use the address on this PC)';
|
|
80
|
+
if (virtualAdapter(name) && !i.internal && sameSubnet(ip, i.address, i.netmask))
|
|
81
|
+
return 'on a virtual network of this PC (a sandbox)';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/** https://<address>:<port>, what a phone types. */
|
|
86
|
+
export function lanUrl(address, port) {
|
|
87
|
+
return 'https://' + address + ':' + port;
|
|
88
|
+
}
|