getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { type ClientRequest, createServer, type IncomingMessage, request as httpRequest, type Server, type ServerResponse } from 'node:http';
|
|
4
|
+
import { connect, isIP, type AddressInfo, type Socket } from 'node:net';
|
|
5
|
+
import { connect as tlsConnect, createSecureContext, rootCertificates, TLSSocket } from 'node:tls';
|
|
6
|
+
import { hostOf, isPrivateHost, judgeRequest, portOf, siteFor } from './browser-policy.ts';
|
|
7
|
+
import { allowance, refusedAddress } from './ssrf.ts';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The one way out of a Boton's browser: Chromium is started with this as its proxy for every address, loopback
|
|
11
|
+
* included, and with UDP that bypasses proxies (WebRTC) and QUIC off. Each connection is checked against the
|
|
12
|
+
* Boton's sites as they stand at that moment, then the name is resolved here and every address checked, so a
|
|
13
|
+
* redirect, a script or a name that resolves to a private address can't reach what the owner didn't list. Plain HTTP
|
|
14
|
+
* is forwarded here, and only reads (GET, HEAD), but in the owner's handover (ProxyPolicy.writes). A tunnel, or a
|
|
15
|
+
* plain HTTP response still streaming, lasts only while its host stays allowed: recheck() cuts the ones a change of
|
|
16
|
+
* sites, or the browser being turned off, no longer allows, so nothing more of them reaches the page.
|
|
17
|
+
*
|
|
18
|
+
* A browser that loads pages (a Boton's, and the owner's handover: ProxyOptions.intercept) gets no tunnel to a site at
|
|
19
|
+
* all: its HTTPS ends here, with a certificate made for that one browser (browser-cert.ts), and every request inside
|
|
20
|
+
* is judged and forwarded as plain HTTP is, then sent on over TLS this proxy verifies. So its writes are decided where
|
|
21
|
+
* every byte leaves, whatever in Chromium sent them: a page, a worker, or a document as it unloads (which Playwright's
|
|
22
|
+
* routing never sees). Without intercept (a browser that loads nothing) HTTPS goes as a tunnel to an allowed host:port.
|
|
23
|
+
*
|
|
24
|
+
* Sealed (seal(true): the Boton clicked or typed on the page), nothing from Chromium goes at all: every open tunnel
|
|
25
|
+
* and exchange is cut and every new one refused, whatever made it. That holds where Chromium's routing can't: a
|
|
26
|
+
* request Playwright can name no frame for (a keepalive fetch or beacon as a document or frame unloads) is let go by
|
|
27
|
+
* Playwright without asking the routing. The one way through then is a pass (passOnce), made for the send of a
|
|
28
|
+
* request the owner allowed on a card: one tunnel, to one host:port, by credentials Chromium never has. A pass's
|
|
29
|
+
* tunnel is never ended here (intercept or not): its sender verifies the site's own certificate through it.
|
|
30
|
+
*
|
|
31
|
+
* Every request and tunnel is owned from the moment it arrives until it is cut or done, by the generation it arrived
|
|
32
|
+
* in. Sealing, unsealing and closing each start a new generation and cut everything of the old one, including what
|
|
33
|
+
* is still waiting on its name or its connection; and after every wait (the name resolved, the site connected) work
|
|
34
|
+
* goes on only if its generation is still the current one, the proxy unsealed (or it holds a pass of this
|
|
35
|
+
* generation) and its site still allowed. So nothing that arrived before a seal goes once it is sealed, and nothing
|
|
36
|
+
* from before a seal goes on the authority of a later unseal.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** What the proxy enforces now: off (the Boton lost its browser, or the server is stopping), or these sites. */
|
|
40
|
+
export interface ProxyPolicy {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
sites: string[];
|
|
43
|
+
/**
|
|
44
|
+
* Writes (POST, PUT, PATCH, DELETE; plain HTTP, or HTTPS ended here) to these sites go too: only in the owner's
|
|
45
|
+
* handover, right after the owner's own click, key or typing (browser-handover.ts). Never for a Boton's browser.
|
|
46
|
+
*/
|
|
47
|
+
writes?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BrowserProxy {
|
|
51
|
+
port: number;
|
|
52
|
+
/** Cuts every open tunnel, plain HTTP exchange and request still being resolved that the current policy no longer allows. */
|
|
53
|
+
recheck(): void;
|
|
54
|
+
/** A new generation either way: everything open or under way is cut. Sealed, nothing new goes but through a pass. */
|
|
55
|
+
seal(on: boolean): void;
|
|
56
|
+
/** A new generation, sealed or not as it was: everything open or under way is cut (the owner opened another address). */
|
|
57
|
+
cut(): void;
|
|
58
|
+
readonly sealed: boolean;
|
|
59
|
+
/** Credentials for one tunnel to host:port, valid for a minute and only in the generation they were made in: the send of an allowed request. */
|
|
60
|
+
passOnce(host: string, port: number): { username: string; password: string };
|
|
61
|
+
/** Credentials made for this proxy, given only to its Chromium: anything else on this computer (a Boton's shell) gets 407. */
|
|
62
|
+
username: string;
|
|
63
|
+
password: string;
|
|
64
|
+
/** What it holds now: requests and tunnels still being resolved or connected, open tunnels and exchanges, sockets. */
|
|
65
|
+
inFlight(): { pending: number; tunnels: number; exchanges: number; sockets: number };
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ProxyOptions {
|
|
70
|
+
/** Tests only: resolves a name in place of the system resolver (to hold a lookup while the proxy changes). */
|
|
71
|
+
lookup?: (host: string) => Promise<string[]>;
|
|
72
|
+
/**
|
|
73
|
+
* HTTPS ends here with this certificate (the one its Chromium trusts), and each request inside is judged on its own
|
|
74
|
+
* and sent on over TLS verified against the system's roots (and `ca`, tests only). Every browser that loads pages.
|
|
75
|
+
* A pass's tunnel is the one exception: it goes through as is.
|
|
76
|
+
*/
|
|
77
|
+
intercept?: { key: string; cert: string; ca?: string[] };
|
|
78
|
+
/** A write about to leave for its site (only ever while policy().writes): the journal's record of it. */
|
|
79
|
+
onWrite?: (url: string, method: string) => void;
|
|
80
|
+
/**
|
|
81
|
+
* A site that could not be reached: its name not found, or none of its addresses answered, or its certificate not
|
|
82
|
+
* accepted. Botons refused nothing, so this is never said as a block (onBlocked is Botons' policy alone).
|
|
83
|
+
*/
|
|
84
|
+
onUnreachable?: (url: string, message: string) => void;
|
|
85
|
+
/** Tests only: opens a TCP connection to an address that was checked (in place of net.connect). */
|
|
86
|
+
connect?: (address: string, port: number) => Socket;
|
|
87
|
+
/** Tests only: how long one address may take to answer (default ATTEMPT_MS). */
|
|
88
|
+
attemptMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const systemLookup = async (host: string) => (await lookup(host, { all: true })).map((a) => a.address);
|
|
92
|
+
|
|
93
|
+
/** How long one address may take to accept a connection before the next one is tried. */
|
|
94
|
+
const ATTEMPT_MS = 3000;
|
|
95
|
+
/** The most addresses tried for one connection: a name with many still answers (or fails) within a step's time. */
|
|
96
|
+
const MAX_ATTEMPTS = 6;
|
|
97
|
+
|
|
98
|
+
/** The words for a name that resolves to nothing, or a site none of whose addresses answered: Botons refused neither. */
|
|
99
|
+
export const notFound = (host: string) => `Couldn’t find ${host} — check the address.`;
|
|
100
|
+
export const noAnswer = (host: string) => `Couldn’t look up ${host}: the name server didn’t answer.`;
|
|
101
|
+
export const unreachable = (host: string, tried: number) => `Couldn’t connect to ${host} (tried ${tried} ${tried === 1 ? 'address' : 'addresses'}).`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The order addresses are dialled in: IPv4 first (a server may have no IPv6 route at all, and a dual-stack name would
|
|
105
|
+
* otherwise wait on it), then IPv6, each in the resolver's order, once each.
|
|
106
|
+
*/
|
|
107
|
+
export function dialOrder(addresses: string[]): string[] {
|
|
108
|
+
const unique = [...new Set(addresses)];
|
|
109
|
+
return [...unique.filter((a) => isIP(a) === 4), ...unique.filter((a) => isIP(a) !== 4)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type Target = { addresses: string[] } | { why: string } | { failed: string };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where host:port may be connected: every address it resolves to, each one checked before any is dialled (a private
|
|
116
|
+
* one anywhere refuses them all), or why not: `why` is Botons' policy, `failed` a name that could not be resolved.
|
|
117
|
+
* A private address goes only through an entry that names it with its port.
|
|
118
|
+
*/
|
|
119
|
+
async function resolveAllowed(sites: string[], host: string, port: number, resolve: (host: string) => Promise<string[]>): Promise<Target> {
|
|
120
|
+
const site = siteFor(sites, host, port);
|
|
121
|
+
if (!site) return { why: `${host}:${port} is not one of the allowed sites` };
|
|
122
|
+
const named = site.port !== null && isPrivateHost(site.host);
|
|
123
|
+
let addresses: string[];
|
|
124
|
+
if (isIP(host)) addresses = [host];
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
addresses = await resolve(host);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
130
|
+
return { failed: code && !['ENOTFOUND', 'ENODATA', 'EAI_NONAME', 'EAI_NODATA'].includes(code) ? noAnswer(host) : notFound(host) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!addresses.length) return { failed: notFound(host) };
|
|
134
|
+
if (!named) {
|
|
135
|
+
for (const a of addresses) {
|
|
136
|
+
const why = refusedAddress(a, allowance([]));
|
|
137
|
+
if (why) return { why: `${host} resolves to ${a}, ${why}` };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { addresses: dialOrder(addresses).slice(0, MAX_ATTEMPTS) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Connects to the first of `addresses` (already checked, IPs only: nothing is resolved again here) that answers within
|
|
145
|
+
* `ms`, one at a time. `ok` is asked before each attempt: once it says no, nothing more is dialled.
|
|
146
|
+
*/
|
|
147
|
+
async function dial(addresses: string[], port: number, open: (address: string, port: number) => Socket, ms: number, ok: () => boolean, track: (s: Socket) => void): Promise<Socket | 'stale' | 'failed'> {
|
|
148
|
+
for (const address of addresses) {
|
|
149
|
+
if (!ok()) return 'stale';
|
|
150
|
+
const sock = open(address, port);
|
|
151
|
+
track(sock);
|
|
152
|
+
const up = await new Promise<boolean>((done) => {
|
|
153
|
+
const timer = setTimeout(() => done(false), ms);
|
|
154
|
+
timer.unref?.();
|
|
155
|
+
const settle = (v: boolean) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
sock.off('connect', yes);
|
|
158
|
+
sock.off('error', no);
|
|
159
|
+
sock.off('close', no);
|
|
160
|
+
done(v);
|
|
161
|
+
};
|
|
162
|
+
const yes = () => settle(true);
|
|
163
|
+
const no = () => settle(false);
|
|
164
|
+
sock.once('connect', yes);
|
|
165
|
+
sock.once('error', no);
|
|
166
|
+
sock.once('close', no);
|
|
167
|
+
});
|
|
168
|
+
if (up) return sock;
|
|
169
|
+
sock.on('error', () => {});
|
|
170
|
+
sock.destroy();
|
|
171
|
+
}
|
|
172
|
+
return ok() ? 'failed' : 'stale';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const SEALED = 'the page was clicked or typed on, so nothing more from the browser goes without a card (open an address to go on)';
|
|
176
|
+
const STALE = 'the browser was sealed, reopened, turned off or the site removed while it waited';
|
|
177
|
+
const STALE_WRITE = 'it is a write, and while it waited the moment for writes passed, or the browser was sealed, reopened, turned off or the site removed';
|
|
178
|
+
|
|
179
|
+
/** A request or tunnel from its arrival until it is cut or done: what it may still do is decided against `gen`. */
|
|
180
|
+
interface Work {
|
|
181
|
+
gen: number;
|
|
182
|
+
host: string;
|
|
183
|
+
port: number;
|
|
184
|
+
/** It came with a pass (a card's send): it may go while sealed, but only in the generation the pass was made in. */
|
|
185
|
+
pass: boolean;
|
|
186
|
+
/** A write not sent yet: it may still go only while the policy allows writes (checked after every wait, as the rest). */
|
|
187
|
+
write: boolean;
|
|
188
|
+
/** Cut: no connection is made for it, and whatever it has open is destroyed. */
|
|
189
|
+
dead: boolean;
|
|
190
|
+
/** Destroys what it has open (the page's connection, and upstream once there is one). */
|
|
191
|
+
cut: () => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function startBrowserProxy(policy: () => ProxyPolicy, onBlocked: (url: string, why: string) => void, options: ProxyOptions = {}): Promise<BrowserProxy> {
|
|
195
|
+
const resolveName = options.lookup ?? systemLookup;
|
|
196
|
+
const open = options.connect ?? ((host: string, port: number) => connect({ host, port }));
|
|
197
|
+
const attemptMs = options.attemptMs ?? ATTEMPT_MS;
|
|
198
|
+
const onUnreachable = options.onUnreachable ?? (() => {});
|
|
199
|
+
const sockets = new Set<Socket>();
|
|
200
|
+
/** Every request and tunnel not yet cut or done, from the moment it arrived. */
|
|
201
|
+
const work = new Set<Work>();
|
|
202
|
+
/** Open tunnels, by the host:port they reach. */
|
|
203
|
+
const tunnels = new Map<Socket, Work>();
|
|
204
|
+
/** Plain HTTP requests forwarded and not yet answered in full, by the host:port they reach. */
|
|
205
|
+
const exchanges = new Map<ServerResponse, Work>();
|
|
206
|
+
let generation = 0;
|
|
207
|
+
let sealed = false;
|
|
208
|
+
let closed = false;
|
|
209
|
+
/** Inside the owner's browser's HTTPS, ended here: the tunnel each decrypted connection came through. */
|
|
210
|
+
const inner = new WeakMap<Socket, { host: string; port: number; tunnel: Work }>();
|
|
211
|
+
const secure = options.intercept ? createSecureContext({ key: options.intercept.key, cert: options.intercept.cert }) : null;
|
|
212
|
+
const ca = options.intercept?.ca?.length ? [...rootCertificates, ...options.intercept.ca] : undefined;
|
|
213
|
+
const allowedNow = (w: Work) => {
|
|
214
|
+
const p = policy();
|
|
215
|
+
return p.enabled && !!siteFor(p.sites, w.host, w.port) && (!w.write || p.writes === true);
|
|
216
|
+
};
|
|
217
|
+
/** Whether `w` may still go on, checked after every wait and right before anything leaves for the site. */
|
|
218
|
+
const current = (w: Work) => !closed && !w.dead && w.gen === generation && (w.pass || !sealed) && allowedNow(w);
|
|
219
|
+
const own = (w: Work) => {
|
|
220
|
+
work.add(w);
|
|
221
|
+
return w;
|
|
222
|
+
};
|
|
223
|
+
const drop = (w: Work) => {
|
|
224
|
+
w.dead = true;
|
|
225
|
+
work.delete(w);
|
|
226
|
+
w.cut();
|
|
227
|
+
};
|
|
228
|
+
const username = 'botons';
|
|
229
|
+
const password = randomBytes(24).toString('base64url');
|
|
230
|
+
const expected = Buffer.from(`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`);
|
|
231
|
+
const authorized = (req: IncomingMessage) => {
|
|
232
|
+
const got = Buffer.from(String(req.headers['proxy-authorization'] ?? ''));
|
|
233
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
234
|
+
};
|
|
235
|
+
/** Passes not yet used, by their whole Proxy-Authorization value. */
|
|
236
|
+
const passes = new Map<string, { target: string; until: number; gen: number }>();
|
|
237
|
+
/** Whether this CONNECT carries a pass for exactly its host:port, made in this generation; spent here either way. */
|
|
238
|
+
const takePass = (req: IncomingMessage, target: string): boolean => {
|
|
239
|
+
const got = String(req.headers['proxy-authorization'] ?? '');
|
|
240
|
+
const p = passes.get(got);
|
|
241
|
+
if (!p) return false;
|
|
242
|
+
passes.delete(got);
|
|
243
|
+
return p.target === target && p.until > Date.now() && p.gen === generation;
|
|
244
|
+
};
|
|
245
|
+
/** A new generation: every pass and everything of the old one is cut, under way or open. */
|
|
246
|
+
const nextGeneration = () => {
|
|
247
|
+
generation++;
|
|
248
|
+
passes.clear();
|
|
249
|
+
for (const w of [...work]) drop(w);
|
|
250
|
+
};
|
|
251
|
+
const challenge = 'HTTP/1.1 407 Proxy Authentication Required\r\nproxy-authenticate: Basic realm="botons"\r\ncontent-length: 0\r\n\r\n';
|
|
252
|
+
const refuse = (res: ServerResponse, status: number, why: string) => {
|
|
253
|
+
if (res.headersSent || res.destroyed) return void res.destroy();
|
|
254
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close', 'x-botons-proxy': 'blocked' });
|
|
255
|
+
res.end(`Blocked by Botons: ${why}\n`);
|
|
256
|
+
};
|
|
257
|
+
/** The proxy's own answer for a site it could not reach: plain words, never said as a block. */
|
|
258
|
+
const unreached = (res: ServerResponse, message: string) => {
|
|
259
|
+
if (res.headersSent || res.destroyed) return void res.destroy();
|
|
260
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close', 'x-botons-proxy': 'unreachable' });
|
|
261
|
+
res.end(`${message}\n`);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
265
|
+
const via = inner.get(req.socket);
|
|
266
|
+
let u: URL;
|
|
267
|
+
if (via) {
|
|
268
|
+
// Inside the owner's HTTPS: the address is the tunnel's, and the request must name that same site.
|
|
269
|
+
const authority = `${via.host.includes(':') ? `[${via.host}]` : via.host}:${via.port}`;
|
|
270
|
+
let named: URL | null = null;
|
|
271
|
+
try {
|
|
272
|
+
named = new URL(`https://${req.headers.host ?? ''}`);
|
|
273
|
+
} catch {}
|
|
274
|
+
if (!req.url?.startsWith('/') || !named || hostOf(named) !== via.host || portOf(named) !== via.port) return refuse(res, 421, 'a request for another site than its connection');
|
|
275
|
+
if (via.tunnel.dead || via.tunnel.gen !== generation) return void res.destroy();
|
|
276
|
+
u = new URL(`https://${authority}${req.url}`);
|
|
277
|
+
} else {
|
|
278
|
+
try {
|
|
279
|
+
u = new URL(req.url ?? '');
|
|
280
|
+
} catch {
|
|
281
|
+
return refuse(res, 400, 'not a proxy request');
|
|
282
|
+
}
|
|
283
|
+
if (!authorized(req)) {
|
|
284
|
+
res.writeHead(407, { 'proxy-authenticate': 'Basic realm="botons"', connection: 'close' });
|
|
285
|
+
return res.end();
|
|
286
|
+
}
|
|
287
|
+
if (u.protocol !== 'http:') return refuse(res, 400, 'only http:// goes through as a request');
|
|
288
|
+
}
|
|
289
|
+
if (closed) return refuse(res, 403, 'the browser is closing');
|
|
290
|
+
if (sealed) {
|
|
291
|
+
onBlocked(u.href, SEALED);
|
|
292
|
+
return refuse(res, 403, SEALED);
|
|
293
|
+
}
|
|
294
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
295
|
+
const p = policy();
|
|
296
|
+
const verdict = judgeRequest(u.href, method, p.sites);
|
|
297
|
+
const write = !verdict.ok && !!verdict.write;
|
|
298
|
+
if (!p.enabled || (!verdict.ok && !(write && p.writes === true))) {
|
|
299
|
+
const why = p.enabled ? (verdict as { why: string }).why : 'the browser is turned off';
|
|
300
|
+
onBlocked(u.href, why);
|
|
301
|
+
return refuse(res, 403, why);
|
|
302
|
+
}
|
|
303
|
+
const host = hostOf(u);
|
|
304
|
+
const port = portOf(u);
|
|
305
|
+
let upstream: Socket | null = null;
|
|
306
|
+
let out: ClientRequest | null = null;
|
|
307
|
+
const w = own({
|
|
308
|
+
gen: generation,
|
|
309
|
+
host,
|
|
310
|
+
port,
|
|
311
|
+
pass: false,
|
|
312
|
+
write,
|
|
313
|
+
dead: false,
|
|
314
|
+
cut: () => {
|
|
315
|
+
out?.destroy();
|
|
316
|
+
upstream?.destroy();
|
|
317
|
+
// The page's connection goes too: a response cut midway must not look complete.
|
|
318
|
+
res.destroy();
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const done = () => {
|
|
322
|
+
work.delete(w);
|
|
323
|
+
exchanges.delete(res);
|
|
324
|
+
};
|
|
325
|
+
res.on('close', () => {
|
|
326
|
+
// Answered, or the page gave up: nothing more is connected or forwarded for it.
|
|
327
|
+
w.dead = true;
|
|
328
|
+
out?.destroy();
|
|
329
|
+
upstream?.destroy();
|
|
330
|
+
done();
|
|
331
|
+
});
|
|
332
|
+
const stale = () => {
|
|
333
|
+
onBlocked(u.href, write ? STALE_WRITE : STALE);
|
|
334
|
+
if (!w.dead) refuse(res, 403, write ? STALE_WRITE : STALE);
|
|
335
|
+
done();
|
|
336
|
+
};
|
|
337
|
+
const target = await resolveAllowed(p.sites, host, port, resolveName);
|
|
338
|
+
if (!current(w)) return stale();
|
|
339
|
+
if ('why' in target) {
|
|
340
|
+
onBlocked(u.href, target.why);
|
|
341
|
+
refuse(res, 403, target.why);
|
|
342
|
+
return done();
|
|
343
|
+
}
|
|
344
|
+
const fail = (message: string) => {
|
|
345
|
+
onUnreachable(u.href, message);
|
|
346
|
+
unreached(res, message);
|
|
347
|
+
done();
|
|
348
|
+
};
|
|
349
|
+
if ('failed' in target) return fail(target.failed);
|
|
350
|
+
const track = (s: Socket) => {
|
|
351
|
+
upstream = s;
|
|
352
|
+
sockets.add(s);
|
|
353
|
+
s.on('close', () => sockets.delete(s));
|
|
354
|
+
};
|
|
355
|
+
// Every address was checked above; each is dialled by its IP, so nothing is resolved again on the way.
|
|
356
|
+
const tcp = await dial(target.addresses, port, open, attemptMs, () => current(w), track);
|
|
357
|
+
if (tcp === 'stale') return stale();
|
|
358
|
+
if (tcp === 'failed') return fail(unreachable(host, target.addresses.length));
|
|
359
|
+
if (!current(w)) {
|
|
360
|
+
tcp.destroy();
|
|
361
|
+
return stale();
|
|
362
|
+
}
|
|
363
|
+
const sock: Socket = via ? tlsConnect({ socket: tcp, host, servername: isIP(host) ? undefined : host, ca, ALPNProtocols: ['http/1.1'] }) : tcp;
|
|
364
|
+
if (via) {
|
|
365
|
+
// Its errors reach the TLS socket on it as a close; that one says what happened.
|
|
366
|
+
tcp.on('error', () => {});
|
|
367
|
+
track(sock);
|
|
368
|
+
}
|
|
369
|
+
sock.on('error', (err: NodeJS.ErrnoException) => {
|
|
370
|
+
if (exchanges.has(res) || w.dead) return;
|
|
371
|
+
const cert = via && (/CERT|SELF_SIGNED|ALTNAME|UNABLE_TO/.test(err.code ?? '') || /certificate/i.test(err.message));
|
|
372
|
+
fail(cert ? `Couldn\u2019t connect securely to ${host}: its certificate was not accepted.` : unreachable(host, target.addresses.length));
|
|
373
|
+
});
|
|
374
|
+
const go = () => {
|
|
375
|
+
// And again once connected, before a byte of the request is written.
|
|
376
|
+
if (!current(w)) {
|
|
377
|
+
sock.destroy();
|
|
378
|
+
return stale();
|
|
379
|
+
}
|
|
380
|
+
if (w.write) {
|
|
381
|
+
// From here it has gone: said so before its first byte, and no longer a write that waits.
|
|
382
|
+
options.onWrite?.(u.href, method);
|
|
383
|
+
w.write = false;
|
|
384
|
+
}
|
|
385
|
+
const headers = { ...req.headers };
|
|
386
|
+
for (const h of ['proxy-connection', 'proxy-authorization', 'keep-alive']) delete headers[h];
|
|
387
|
+
const fwd = httpRequest(
|
|
388
|
+
{ method: req.method, path: `${u.pathname}${u.search}`, headers: { ...headers, host: u.host }, setHost: false, createConnection: () => sock },
|
|
389
|
+
(up) => {
|
|
390
|
+
// Cut while the site waited to answer: already destroyed.
|
|
391
|
+
if (!exchanges.has(res)) return void up.destroy();
|
|
392
|
+
// Only the proxy's own answers carry its mark: a site can't make its page pass for one.
|
|
393
|
+
const { 'x-botons-proxy': _mark, ...headers } = up.headers;
|
|
394
|
+
res.writeHead(up.statusCode ?? 502, headers);
|
|
395
|
+
up.pipe(res);
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
out = fwd;
|
|
399
|
+
exchanges.set(res, w);
|
|
400
|
+
fwd.on('error', () => {
|
|
401
|
+
if (!exchanges.has(res)) return;
|
|
402
|
+
exchanges.delete(res);
|
|
403
|
+
if (res.headersSent) res.destroy();
|
|
404
|
+
else {
|
|
405
|
+
const message = `Couldn\u2019t get an answer from ${host}: the connection broke.`;
|
|
406
|
+
onUnreachable(u.href, message);
|
|
407
|
+
unreached(res, message);
|
|
408
|
+
}
|
|
409
|
+
work.delete(w);
|
|
410
|
+
});
|
|
411
|
+
req.pipe(fwd);
|
|
412
|
+
};
|
|
413
|
+
if (via) sock.once('secureConnect', go);
|
|
414
|
+
else go();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
server.on('connect', async (req: IncomingMessage, client: Socket, head: Buffer) => {
|
|
418
|
+
// No tunnel inside a tunnel.
|
|
419
|
+
if (inner.has(req.socket)) return void client.destroy();
|
|
420
|
+
sockets.add(client);
|
|
421
|
+
client.on('close', () => sockets.delete(client));
|
|
422
|
+
client.on('error', () => {});
|
|
423
|
+
const m = /^(\[[0-9a-fA-F:.]+\]|[^:\s]+):(\d{1,5})$/.exec(req.url ?? '');
|
|
424
|
+
const fail = (why: string) => {
|
|
425
|
+
onBlocked(`https://${req.url ?? ''}/`, why);
|
|
426
|
+
if (!client.destroyed) client.end(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nBlocked by Botons: ${why}\r\n`);
|
|
427
|
+
};
|
|
428
|
+
if (!m) return fail('not a host:port');
|
|
429
|
+
const host = m[1].replace(/^\[|\]$/g, '').toLowerCase();
|
|
430
|
+
const port = Number(m[2]);
|
|
431
|
+
const pass = takePass(req, `${host}:${port}`);
|
|
432
|
+
if (!pass && !authorized(req)) return void client.end(challenge);
|
|
433
|
+
if (closed) return fail('the browser is closing');
|
|
434
|
+
if (sealed && !pass) return fail(SEALED);
|
|
435
|
+
const p = policy();
|
|
436
|
+
if (!p.enabled) return fail('the browser is turned off');
|
|
437
|
+
if (secure && !pass) {
|
|
438
|
+
// Chromium's HTTPS ends here, and each request inside is judged and sent on by itself.
|
|
439
|
+
if (!siteFor(p.sites, host, port)) return fail(`${host}:${port} is not one of the allowed sites`);
|
|
440
|
+
let tls: TLSSocket | null = null;
|
|
441
|
+
const t = own({
|
|
442
|
+
gen: generation,
|
|
443
|
+
host,
|
|
444
|
+
port,
|
|
445
|
+
pass: false,
|
|
446
|
+
write: false,
|
|
447
|
+
dead: false,
|
|
448
|
+
cut: () => {
|
|
449
|
+
tls?.destroy();
|
|
450
|
+
client.destroy();
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
client.on('close', () => {
|
|
454
|
+
t.dead = true;
|
|
455
|
+
tls?.destroy();
|
|
456
|
+
work.delete(t);
|
|
457
|
+
tunnels.delete(client);
|
|
458
|
+
});
|
|
459
|
+
tunnels.set(client, t);
|
|
460
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
461
|
+
if (head.length) client.unshift(head);
|
|
462
|
+
tls = new TLSSocket(client, { isServer: true, secureContext: secure });
|
|
463
|
+
sockets.add(tls);
|
|
464
|
+
tls.on('close', () => sockets.delete(tls!));
|
|
465
|
+
tls.on('error', () => client.destroy());
|
|
466
|
+
inner.set(tls, { host, port, tunnel: t });
|
|
467
|
+
server.emit('connection', tls);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
let upstream: Socket | null = null;
|
|
471
|
+
const w = own({
|
|
472
|
+
gen: generation,
|
|
473
|
+
host,
|
|
474
|
+
port,
|
|
475
|
+
pass,
|
|
476
|
+
write: false,
|
|
477
|
+
dead: false,
|
|
478
|
+
cut: () => {
|
|
479
|
+
upstream?.destroy();
|
|
480
|
+
client.destroy();
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
const done = () => {
|
|
484
|
+
work.delete(w);
|
|
485
|
+
tunnels.delete(client);
|
|
486
|
+
};
|
|
487
|
+
client.on('close', () => {
|
|
488
|
+
w.dead = true;
|
|
489
|
+
upstream?.destroy();
|
|
490
|
+
done();
|
|
491
|
+
});
|
|
492
|
+
const stale = () => {
|
|
493
|
+
if (!w.dead) fail(STALE);
|
|
494
|
+
else onBlocked(`https://${req.url ?? ''}/`, STALE);
|
|
495
|
+
done();
|
|
496
|
+
};
|
|
497
|
+
const target = await resolveAllowed(p.sites, host, port, resolveName);
|
|
498
|
+
if (!current(w)) return stale();
|
|
499
|
+
if ('why' in target) {
|
|
500
|
+
fail(target.why);
|
|
501
|
+
return done();
|
|
502
|
+
}
|
|
503
|
+
const unreachableNow = (message: string) => {
|
|
504
|
+
onUnreachable(`https://${req.url ?? ''}/`, message);
|
|
505
|
+
if (!client.destroyed) client.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\nx-botons-proxy: unreachable\r\n\r\n${message}\r\n`);
|
|
506
|
+
done();
|
|
507
|
+
};
|
|
508
|
+
if ('failed' in target) return unreachableNow(target.failed);
|
|
509
|
+
// Every address was checked above; each is dialled by its IP, so nothing is resolved again on the way. Checked
|
|
510
|
+
// again before each attempt: no packet for it leaves unless it may go now.
|
|
511
|
+
const sock = await dial(target.addresses, port, open, attemptMs, () => current(w), (s) => {
|
|
512
|
+
upstream = s;
|
|
513
|
+
sockets.add(s);
|
|
514
|
+
s.on('close', () => sockets.delete(s));
|
|
515
|
+
});
|
|
516
|
+
if (sock === 'stale') return stale();
|
|
517
|
+
if (sock === 'failed') return unreachableNow(unreachable(host, target.addresses.length));
|
|
518
|
+
sock.on('close', () => {
|
|
519
|
+
if (tunnels.has(client)) client.destroy();
|
|
520
|
+
done();
|
|
521
|
+
});
|
|
522
|
+
sock.on('error', () => client.destroy());
|
|
523
|
+
// And again once connected, before the page's first byte goes through.
|
|
524
|
+
if (!current(w)) {
|
|
525
|
+
sock.destroy();
|
|
526
|
+
return stale();
|
|
527
|
+
}
|
|
528
|
+
tunnels.set(client, w);
|
|
529
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
530
|
+
if (head.length) sock.write(head);
|
|
531
|
+
sock.pipe(client);
|
|
532
|
+
client.pipe(sock);
|
|
533
|
+
});
|
|
534
|
+
// Upgrades come as CONNECT tunnels from Chromium; anything else asking for one is refused.
|
|
535
|
+
server.on('upgrade', (_req, socket: Socket) => socket.destroy());
|
|
536
|
+
|
|
537
|
+
const shown = (w: Work, scheme: string) => `${scheme}://${w.host.includes(':') ? `[${w.host}]` : w.host}:${w.port}/`;
|
|
538
|
+
|
|
539
|
+
return new Promise((resolve, reject) => {
|
|
540
|
+
server.once('error', reject);
|
|
541
|
+
server.listen(0, '127.0.0.1', () => {
|
|
542
|
+
resolve({
|
|
543
|
+
port: (server.address() as AddressInfo).port,
|
|
544
|
+
recheck: () => {
|
|
545
|
+
for (const w of [...work]) {
|
|
546
|
+
if (allowedNow(w)) continue;
|
|
547
|
+
const open = [...tunnels.values()].includes(w) ? 'https' : [...exchanges.values()].includes(w) ? 'http' : null;
|
|
548
|
+
drop(w);
|
|
549
|
+
if (open) onBlocked(shown(w, open), 'cut: the browser was turned off or the site removed');
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
username,
|
|
553
|
+
password,
|
|
554
|
+
get sealed() {
|
|
555
|
+
return sealed;
|
|
556
|
+
},
|
|
557
|
+
seal: (on) => {
|
|
558
|
+
sealed = on;
|
|
559
|
+
nextGeneration();
|
|
560
|
+
},
|
|
561
|
+
cut: () => nextGeneration(),
|
|
562
|
+
passOnce: (host, port) => {
|
|
563
|
+
const user = 'botons-send';
|
|
564
|
+
const pw = randomBytes(24).toString('base64url');
|
|
565
|
+
passes.set(`Basic ${Buffer.from(`${user}:${pw}`).toString('base64')}`, { target: `${host.toLowerCase()}:${port}`, until: Date.now() + 60_000, gen: generation });
|
|
566
|
+
return { username: user, password: pw };
|
|
567
|
+
},
|
|
568
|
+
inFlight: () => ({ pending: work.size - tunnels.size - exchanges.size, tunnels: tunnels.size, exchanges: exchanges.size, sockets: sockets.size }),
|
|
569
|
+
close: () =>
|
|
570
|
+
new Promise<void>((done) => {
|
|
571
|
+
closed = true;
|
|
572
|
+
nextGeneration();
|
|
573
|
+
for (const s of sockets) s.destroy();
|
|
574
|
+
server.closeAllConnections();
|
|
575
|
+
server.close(() => done());
|
|
576
|
+
}),
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
}
|