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,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Text that came back from another server (an error answer, a tool name) may hold the credentials that were sent
|
|
3
|
+
* to it, or other credentials it chose to show. Everything such text reaches (a log line, a saved status, an API
|
|
4
|
+
* answer) gets it through sanitize() first, before any truncation, so no prefix of a secret survives a cut.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** `text` with every one of `secrets` replaced, longest first, however short it is. */
|
|
8
|
+
export function redact(text: string, secrets: Iterable<string | null | undefined>): string {
|
|
9
|
+
let out = text;
|
|
10
|
+
const list = [...new Set([...secrets].filter((x): x is string => typeof x === 'string' && x.length > 0))].sort((a, b) => b.length - a.length);
|
|
11
|
+
for (const v of list) out = out.split(v).join('[redacted]');
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs of 20 or more token characters with both a letter and a digit: the shape of access and refresh tokens,
|
|
19
|
+
* codes, keys and JWT segments. Words, host names (their labels break at dots) and UUIDs are left alone.
|
|
20
|
+
*/
|
|
21
|
+
export function redactTokenShaped(text: string): string {
|
|
22
|
+
return text.replace(/[A-Za-z0-9_~+/-]{20,}={0,2}/g, (run) => (/[A-Za-z]/.test(run) && /\d/.test(run) && !UUID.test(run) ? '[redacted]' : run));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* For logs, saved errors and API answers. Control characters go first, so none can split a secret apart for the
|
|
27
|
+
* steps after it; then known secrets, then token-shaped runs, and only then the cut.
|
|
28
|
+
*/
|
|
29
|
+
export function sanitize(text: string, secrets: Iterable<string | null | undefined> = [], max = 300): string {
|
|
30
|
+
return redactTokenShaped(redact(text.replace(/[\u0000-\u001f\u007f]/g, ''), secrets)).slice(0, max);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A JSON value with `scrub` applied to every string in it, object keys included (for a structured tool result, a
|
|
35
|
+
* tool's input schema): what comes back from another server is redacted whole, not only its text. Deeper than `depth`
|
|
36
|
+
* nesting is cut (no real result nests that far).
|
|
37
|
+
*/
|
|
38
|
+
export function redactJson(v: unknown, scrub: (text: string) => string, depth = 0): unknown {
|
|
39
|
+
if (typeof v === 'string') return scrub(v);
|
|
40
|
+
if (v === null || typeof v !== 'object') return v;
|
|
41
|
+
if (depth >= 64) return '[nested too deep]';
|
|
42
|
+
if (Array.isArray(v)) return v.map((x) => redactJson(x, scrub, depth + 1));
|
|
43
|
+
const out: Record<string, unknown> = {};
|
|
44
|
+
for (const [k, x] of Object.entries(v)) out[scrub(k)] = redactJson(x, scrub, depth + 1);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { serverCommand } from './install-type.ts';
|
|
2
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import type { AccessMode, AccessModeOption, SetupStatus, SetupWarning, UpdateSetupInput } from '../../shared/types.ts';
|
|
6
|
+
import type { Config } from './config.ts';
|
|
7
|
+
import { HttpError } from './store.ts';
|
|
8
|
+
import { isLoopback, isTailnetAddress, plainAddress } from './tailnet.ts';
|
|
9
|
+
import { nowIso, parseJson } from './util.ts';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* First-run setup and the access mode.
|
|
13
|
+
*
|
|
14
|
+
* The access mode is the owner's statement of which networks may reach the
|
|
15
|
+
* app's port, checked against every request's source address before anything
|
|
16
|
+
* else. It only narrows what the listeners already expose: reaching the server
|
|
17
|
+
* from further away (BOTONS_HOST, a published port, a proxy, DNS, the
|
|
18
|
+
* firewall) stays a change the owner makes on the host. Behind a proxy on the
|
|
19
|
+
* same machine (Tailscale Serve, Caddy) requests come from loopback or the
|
|
20
|
+
* proxy's container, so the mode can't tell those callers apart: there, the
|
|
21
|
+
* listener and the proxy are the boundary, and auth is the one after it. Every
|
|
22
|
+
* description of a mode says so (modeLimits), since a forwarder Botons didn't
|
|
23
|
+
* set up is invisible to it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const ACCESS_MODES: readonly AccessMode[] = ['local', 'tailscale', 'lan', 'public'];
|
|
27
|
+
|
|
28
|
+
export type AddressClass = 'loopback' | 'tailnet' | 'private' | 'public';
|
|
29
|
+
|
|
30
|
+
const SETTING_MODE = 'accessMode';
|
|
31
|
+
export const SETTING_SETUP = 'setup';
|
|
32
|
+
export const SETTING_CLAIM = 'ownerClaim';
|
|
33
|
+
|
|
34
|
+
/** RFC 1918, link-local, unique-local and link-local IPv6. */
|
|
35
|
+
function isPrivate(ip: string): boolean {
|
|
36
|
+
if (isIP(ip) === 4) {
|
|
37
|
+
const [a, b] = ip.split('.').map(Number);
|
|
38
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
39
|
+
}
|
|
40
|
+
return isIP(ip) === 6 && /^(f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/i.test(ip);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function classifyAddress(raw: string): AddressClass {
|
|
44
|
+
const ip = plainAddress(raw);
|
|
45
|
+
if (!ip || isLoopback(ip)) return 'loopback';
|
|
46
|
+
if (isTailnetAddress(ip)) return 'tailnet';
|
|
47
|
+
if (isPrivate(ip)) return 'private';
|
|
48
|
+
return 'public';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* In a container every caller arrives from the bridge network or a sidecar
|
|
53
|
+
* (a private address), so there local and tailscale also accept private
|
|
54
|
+
* addresses: the published port (127.0.0.1 by default) or the sidecar decides.
|
|
55
|
+
*/
|
|
56
|
+
export function modeAllows(mode: AccessMode | null, cls: AddressClass, container: boolean): boolean {
|
|
57
|
+
if (mode === null || mode === 'public' || cls === 'loopback') return true;
|
|
58
|
+
if (cls === 'public') return false;
|
|
59
|
+
if (mode === 'lan') return true;
|
|
60
|
+
if (cls === 'private') return container;
|
|
61
|
+
return mode === 'tailscale';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NETWORK: Record<AddressClass, string> = {
|
|
65
|
+
loopback: 'this machine',
|
|
66
|
+
tailnet: 'a Tailscale',
|
|
67
|
+
private: 'a local network',
|
|
68
|
+
public: 'a public internet',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function isAccessMode(v: unknown): v is AccessMode {
|
|
72
|
+
return typeof v === 'string' && (ACCESS_MODES as readonly string[]).includes(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readAccessMode(db: DatabaseSync): AccessMode | null {
|
|
76
|
+
const v = db.prepare('select value from settings where key = ?').get(SETTING_MODE)?.value;
|
|
77
|
+
return isAccessMode(v) ? v : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Also used by `npm run pair -- --access-mode`. */
|
|
81
|
+
export function writeAccessMode(db: DatabaseSync, mode: AccessMode | null): void {
|
|
82
|
+
if (mode === null) db.prepare('delete from settings where key = ?').run(SETTING_MODE);
|
|
83
|
+
else db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(SETTING_MODE, mode);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const https = (url: string | null) => !!url && url.startsWith('https://');
|
|
87
|
+
|
|
88
|
+
/** Why `mode` can't be chosen with this configuration, or null. Shared with `npm run pair`. */
|
|
89
|
+
export function accessModeProblem(mode: AccessMode, cfg: Pick<Config, 'authEnabled' | 'publicUrlOverride'>): string | null {
|
|
90
|
+
if ((mode === 'lan' || mode === 'public') && !cfg.authEnabled) {
|
|
91
|
+
return 'Auth is off. Turn it on first.';
|
|
92
|
+
}
|
|
93
|
+
if (mode === 'public' && !https(cfg.publicUrlOverride)) {
|
|
94
|
+
return 'Needs an HTTPS link set in BOTONS_PUBLIC_URL.';
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* What no mode can see. The mode checks the address a connection comes from, and anything that forwards
|
|
101
|
+
* connections (a reverse proxy, Tailscale Serve or Funnel, an SSH or other tunnel, a container's network)
|
|
102
|
+
* replaces that address with its own. Botons can't detect forwarders it didn't set up, so these are said
|
|
103
|
+
* wherever a mode is described, whether or not one is in sight.
|
|
104
|
+
*/
|
|
105
|
+
const FORWARDER_LIMIT =
|
|
106
|
+
'The access mode checks only the address each connection comes from. Anything on this machine that forwards connections to Botons (a reverse proxy, Tailscale Serve or Funnel, an SSH or other tunnel) makes them come from this machine, so they are let through from wherever they started. Botons can\'t detect such forwarders: whoever can reach one gets the sign-in page, and pairing is all that stands in the way.';
|
|
107
|
+
const CONTAINER_LIMIT =
|
|
108
|
+
'In a container every connection comes from the container network (the published port, a Tailscale sidecar, a proxy container), so the access mode can\'t tell them apart: the published port and the overlays you start decide who reaches Botons, and a proxy or tunnel in front lets through whoever can reach it.';
|
|
109
|
+
|
|
110
|
+
/** Shown with each mode, so no summary promises more than the check does. */
|
|
111
|
+
export function modeLimits(mode: AccessMode, container: boolean): string[] {
|
|
112
|
+
if (mode === 'public') return ['Nothing is refused by address: auth and pairing are the only boundary.'];
|
|
113
|
+
const out = [container ? CONTAINER_LIMIT : FORWARDER_LIMIT];
|
|
114
|
+
if (mode === 'tailscale') out.push('Your tailnet decides which Tailscale devices reach this machine (its access controls, shared nodes); Botons accepts every Tailscale address that gets here.');
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The public URL, when it looks like a forwarder's (HTTPS, or another port than the app's) on another host. */
|
|
119
|
+
function forwardedFrom(publicUrl: string | null, port: number): string | null {
|
|
120
|
+
if (!publicUrl) return null;
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(publicUrl);
|
|
123
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
124
|
+
if (host === 'localhost' || isLoopback(host)) return null;
|
|
125
|
+
const at = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
126
|
+
return url.protocol === 'https:' || at !== port ? publicUrl : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Conservative connection hint for choosing a mode, never an authorization input. */
|
|
133
|
+
export function connectionClass(remote: string, publicUrl: string | null, port: number, hints: IncomingHttpHeaders = {}): AddressClass {
|
|
134
|
+
// Untrusted headers may only make selection more conservative, never authorize a request.
|
|
135
|
+
if (Object.keys(hints).some(k => k === 'forwarded' || k.startsWith('x-forwarded-') || k.startsWith('tailscale-'))) return 'public';
|
|
136
|
+
for (const raw of [hints.host, hints.origin]) {
|
|
137
|
+
if (typeof raw !== 'string') continue;
|
|
138
|
+
try {
|
|
139
|
+
const url = new URL(raw.includes('://') ? raw : `http://${raw}`);
|
|
140
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
141
|
+
// A direct tailnet socket and matching configured tailnet hostname are evidence
|
|
142
|
+
// of a tailnet connection; the hostname alone cannot distinguish Serve from Funnel.
|
|
143
|
+
const directTailnet = classifyAddress(remote) === 'tailnet' && publicUrl
|
|
144
|
+
&& new URL(publicUrl).hostname === host && host.endsWith('.ts.net');
|
|
145
|
+
if (!directTailnet && host !== 'localhost' && classifyAddress(host) === 'public') return 'public';
|
|
146
|
+
} catch { return 'public'; }
|
|
147
|
+
}
|
|
148
|
+
const cls = classifyAddress(remote);
|
|
149
|
+
if ((cls === 'loopback' || cls === 'private') && forwardedFrom(publicUrl, port)) {
|
|
150
|
+
return 'public';
|
|
151
|
+
}
|
|
152
|
+
return cls;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const LABELS: Record<AccessMode, string> = {
|
|
156
|
+
local: 'This computer only',
|
|
157
|
+
tailscale: 'Only my Tailscale devices',
|
|
158
|
+
lan: 'Home network (plain HTTP)',
|
|
159
|
+
public: 'From anywhere',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export class Setup {
|
|
163
|
+
#db: DatabaseSync;
|
|
164
|
+
#cfg: Config;
|
|
165
|
+
#ownerName: () => string;
|
|
166
|
+
|
|
167
|
+
constructor(db: DatabaseSync, cfg: Config, ownerName: () => string) {
|
|
168
|
+
this.#db = db;
|
|
169
|
+
this.#cfg = cfg;
|
|
170
|
+
this.#ownerName = ownerName;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get accessMode(): AccessMode | null {
|
|
174
|
+
return readAccessMode(this.#db);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Why a request from `remote` is refused, or null. Read per request, so `npm run pair -- --access-mode` takes effect at once. */
|
|
178
|
+
refusal(remote: string): string | null {
|
|
179
|
+
const mode = this.accessMode;
|
|
180
|
+
const cls = classifyAddress(remote);
|
|
181
|
+
if (modeAllows(mode, cls, this.#cfg.container)) return null;
|
|
182
|
+
return `This Botons server only accepts ${LABELS[mode!].toLowerCase()} connections, and this request came from ${NETWORK[cls]} address. Change the access mode in Settings from a device it accepts, or on the server's machine with \`${accessModeCommand(this.#cfg)}\`.`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#setupRecord(): { completedAt: string | null } {
|
|
186
|
+
const raw = this.#db.prepare('select value from settings where key = ?').get(SETTING_SETUP)?.value;
|
|
187
|
+
return parseJson(raw, { completedAt: null });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
status(remote = '127.0.0.1', hints: IncomingHttpHeaders = {}): SetupStatus {
|
|
191
|
+
const cfg = this.#cfg;
|
|
192
|
+
const mode = this.accessMode;
|
|
193
|
+
const claim = parseJson<{ at?: string; label?: string } | null>(this.#db.prepare('select value from settings where key = ?').get(SETTING_CLAIM)?.value, null);
|
|
194
|
+
const devices = Number(this.#db.prepare("select count(*) as n from auth_tokens where kind = 'device'").get()!.n);
|
|
195
|
+
const botons = Number(this.#db.prepare('select count(*) as n from bots').get()!.n);
|
|
196
|
+
const record = this.#setupRecord();
|
|
197
|
+
// A server that had Botons before setup existed is set up already.
|
|
198
|
+
const completed = !!record.completedAt || (!this.#db.prepare('select 1 from settings where key = ?').get(SETTING_SETUP) && botons > 0);
|
|
199
|
+
const tailnetHosts = cfg.urls.map((u) => new URL(u)).filter((u) => isTailnetAddress(u.hostname.replace(/^\[|\]$/g, ''))).map((u) => u.host);
|
|
200
|
+
const tailnetUp = tailnetHosts.length > 0;
|
|
201
|
+
return {
|
|
202
|
+
owner: { name: this.#ownerName(), claimedAt: claim?.at ?? null, claimedBy: claim?.label ?? null, devices },
|
|
203
|
+
completed,
|
|
204
|
+
completedAt: record.completedAt,
|
|
205
|
+
accessMode: mode,
|
|
206
|
+
recommendedMode: this.#recommend(remote, hints),
|
|
207
|
+
modes: ACCESS_MODES.map((m) => this.#option(m, remote, hints)),
|
|
208
|
+
exposure: {
|
|
209
|
+
listeners: cfg.hosts.map((h) => `${h.includes(':') ? `[${h}]` : h}:${cfg.port}`).concat(cfg.tailscale.mode === 'bind' ? tailnetHosts : []),
|
|
210
|
+
hooksListeners: cfg.hooks.port === null ? [] : cfg.hooks.hosts.map((h) => `${h}:${cfg.hooks.port}`),
|
|
211
|
+
publicUrl: cfg.publicUrl,
|
|
212
|
+
https: https(cfg.publicUrl),
|
|
213
|
+
authRequired: cfg.authEnabled,
|
|
214
|
+
tailscale: tailnetUp,
|
|
215
|
+
container: cfg.container,
|
|
216
|
+
},
|
|
217
|
+
warnings: this.#warnings(mode, tailnetUp, completed),
|
|
218
|
+
botons,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#recommend(remote: string, hints: IncomingHttpHeaders): AccessMode | null {
|
|
223
|
+
const cls = connectionClass(remote, this.#cfg.publicUrlOverride, this.#cfg.port, hints);
|
|
224
|
+
const preferred: AccessMode = cls === 'public' ? 'public' : cls === 'tailnet' ? 'tailscale' : cls === 'private' ? 'lan' : 'local';
|
|
225
|
+
return [preferred, ...ACCESS_MODES].find(m => this.#option(m, remote, hints).available) ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#option(mode: AccessMode, remote: string, hints: IncomingHttpHeaders): AccessModeOption {
|
|
229
|
+
const cfg = this.#cfg;
|
|
230
|
+
const port = cfg.port;
|
|
231
|
+
const problem = accessModeProblem(mode, cfg) ?? (!modeAllows(mode, connectionClass(remote, cfg.publicUrlOverride, cfg.port, hints), cfg.container) ? 'Choose this from another device.' : null);
|
|
232
|
+
const docker = cfg.container;
|
|
233
|
+
const steps: Record<AccessMode, string[]> = {
|
|
234
|
+
local: [
|
|
235
|
+
`Open http://localhost:${port} on this machine.`,
|
|
236
|
+
`From another computer without opening anything: an SSH tunnel, \`ssh -L ${port}:127.0.0.1:${port} you@myhost\`, then http://localhost:${port} there.`,
|
|
237
|
+
],
|
|
238
|
+
tailscale: docker
|
|
239
|
+
? [
|
|
240
|
+
'Create an auth key in the Tailscale admin console (you do this; Botons never signs in for you) and put it in .env as TS_AUTHKEY.',
|
|
241
|
+
'Start with the Tailscale overlay: `docker compose -f compose.yaml -f docker/compose.tailscale.yaml up -d`, and set BOTONS_PUBLIC_URL to its https:// tailnet address (docs/DOCKER.md).',
|
|
242
|
+
]
|
|
243
|
+
: [
|
|
244
|
+
'Install Tailscale on this machine and sign in with `tailscale up` (you do this; Botons never signs in for you).',
|
|
245
|
+
'Botons notices the Tailscale address by itself and listens on it too. Your own devices on the tailnet then pair with one click.',
|
|
246
|
+
],
|
|
247
|
+
lan: docker
|
|
248
|
+
? [
|
|
249
|
+
'Publish the port on the LAN: set BOTONS_BIND=0.0.0.0 in .env and run `docker compose up -d`. This changes a safety default: traffic is plain HTTP.',
|
|
250
|
+
`Allow port ${port} from your LAN only in the host firewall, e.g. \`sudo ufw allow from 192.168.1.0/24 to any port ${port} proto tcp\`.`,
|
|
251
|
+
]
|
|
252
|
+
: [
|
|
253
|
+
`Set BOTONS_HOST=0.0.0.0 in the service's env file (~/.config/botons/env, or ~/.config/botons/env on an install made before the rename) and restart the service (${serverCommand('doctor', '', cfg.container)} names it). This listens on every interface, over plain HTTP.`,
|
|
254
|
+
`Allow port ${port} from your LAN only in the firewall, e.g. \`sudo ufw allow from 192.168.1.0/24 to any port ${port} proto tcp\`.`,
|
|
255
|
+
],
|
|
256
|
+
public: [
|
|
257
|
+
'Point a DNS name you own (say botons.example.com) at this machine.',
|
|
258
|
+
docker
|
|
259
|
+
? 'Start with the Caddy overlay, which gets the HTTPS certificate: set BOTONS_DOMAIN and BOTONS_PUBLIC_URL=https://botons.example.com in .env, then `docker compose -f compose.yaml -f docker/compose.caddy.yaml up -d`.'
|
|
260
|
+
: `Run an HTTPS reverse proxy (Caddy, say) for that name that forwards to 127.0.0.1:${port}, and set BOTONS_PUBLIC_URL=https://botons.example.com in the service's env file; restart.`,
|
|
261
|
+
`Open ports 80 and 443 in the firewall; keep ${port} closed.`,
|
|
262
|
+
'Every device still pairs with a code, and anyone can reach the sign-in page: keep your devices few and revoke ones you no longer use.',
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
const summary: Record<AccessMode, string> = {
|
|
266
|
+
public: 'HTTPS access with device pairing.',
|
|
267
|
+
tailscale: 'For devices connected through Tailscale.',
|
|
268
|
+
lan: 'Unencrypted access on your home network.',
|
|
269
|
+
local: 'For a browser on the server computer.',
|
|
270
|
+
};
|
|
271
|
+
return { mode, label: LABELS[mode], summary: summary[mode], limits: modeLimits(mode, docker), available: problem === null, reason: problem, steps: steps[mode] };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#warnings(mode: AccessMode | null, tailnetUp: boolean, completed: boolean): SetupWarning[] {
|
|
275
|
+
const cfg = this.#cfg;
|
|
276
|
+
const out: SetupWarning[] = [];
|
|
277
|
+
const wildcard = cfg.hosts.some((h) => h === '0.0.0.0' || h === '::');
|
|
278
|
+
const network = cfg.hosts.some((h) => !isLoopback(h) && h !== 'localhost');
|
|
279
|
+
if (!cfg.authEnabled) out.push({ level: 'error', text: 'Auth is off (BOTONS_AUTH=off): anyone who can reach the server is its owner, Botons included. Remove it from the server\'s environment and restart.' });
|
|
280
|
+
if (mode === 'public' && !https(cfg.publicUrlOverride)) out.push({ level: 'error', text: 'The access mode is public, but BOTONS_PUBLIC_URL is not an https:// address. Put an HTTPS proxy in front, or choose another mode.' });
|
|
281
|
+
if (mode === null && completed) out.push({ level: 'info', text: 'No access mode is chosen, so only the listeners decide who connects. Choose one so requests from other networks are refused.' });
|
|
282
|
+
if ((mode === 'local' || mode === 'tailscale') && network && !cfg.container) {
|
|
283
|
+
out.push({ level: 'warn', text: `Botons listens on ${cfg.hosts.join(', ')} (BOTONS_HOST), wider than this access mode. Requests from other networks are refused, but narrowing BOTONS_HOST closes the port to them altogether.` });
|
|
284
|
+
}
|
|
285
|
+
if (mode === 'tailscale' && !tailnetUp && !cfg.container) {
|
|
286
|
+
out.push({ level: 'warn', text: cfg.tailscale.enabled ? 'Tailscale is not up on this machine yet, so only this machine can connect.' : 'Tailscale is turned off (BOTONS_NO_TAILSCALE=1), so tailnet devices can only connect through a proxy such as Tailscale Serve.' });
|
|
287
|
+
}
|
|
288
|
+
if (mode === 'lan' && !wildcard && !network && !cfg.container) {
|
|
289
|
+
out.push({ level: 'info', text: 'Botons listens on this machine (and Tailscale) only, so the local network can\'t connect until you change BOTONS_HOST (see the steps).' });
|
|
290
|
+
}
|
|
291
|
+
if ((mode === 'lan' || (wildcard && !cfg.container)) && !https(cfg.publicUrl)) {
|
|
292
|
+
out.push({ level: 'warn', text: 'Devices on the network reach Botons over plain HTTP. Use Tailscale or an HTTPS proxy for anything beyond a network you control.' });
|
|
293
|
+
}
|
|
294
|
+
if (mode !== null && mode !== 'public') {
|
|
295
|
+
out.push({ level: 'warn', text: cfg.container ? CONTAINER_LIMIT : FORWARDER_LIMIT });
|
|
296
|
+
const via = forwardedFrom(cfg.publicUrlOverride, cfg.port);
|
|
297
|
+
if (via) {
|
|
298
|
+
out.push({
|
|
299
|
+
level: 'warn',
|
|
300
|
+
text: `BOTONS_PUBLIC_URL is ${via}, so something probably forwards connections to Botons from there. Through it they come from ${cfg.container ? 'the container network' : 'this machine'}, so ${LABELS[mode]} lets every one through: whoever can open ${via} gets the sign-in page.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (cfg.hooks.port !== null) {
|
|
305
|
+
out.push({ level: 'info', text: `A webhooks-only listener runs on port ${cfg.hooks.port} (BOTONS_HOOKS_PORT). It serves routine webhooks and nothing else, and the access mode does not apply to it.` });
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Devices only (the route's default). `remote`: the caller's address, which the new mode must still accept. */
|
|
311
|
+
update(input: UpdateSetupInput, remote: string, hints: IncomingHttpHeaders = {}): { changed: { accessMode?: { from: AccessMode | null; to: AccessMode | null }; completed?: boolean } } {
|
|
312
|
+
const changed: { accessMode?: { from: AccessMode | null; to: AccessMode | null }; completed?: boolean } = {};
|
|
313
|
+
if (input.accessMode !== undefined) {
|
|
314
|
+
const to = input.accessMode;
|
|
315
|
+
if (to !== null && !isAccessMode(to)) throw new HttpError(400, `accessMode must be one of ${ACCESS_MODES.join(', ')}, or null`);
|
|
316
|
+
if (to !== null) {
|
|
317
|
+
const problem = accessModeProblem(to, this.#cfg);
|
|
318
|
+
if (problem) throw new HttpError(400, problem);
|
|
319
|
+
const cls = connectionClass(remote, this.#cfg.publicUrlOverride, this.#cfg.port, hints);
|
|
320
|
+
if (!modeAllows(to, cls, this.#cfg.container)) {
|
|
321
|
+
throw new HttpError(409, `${LABELS[to]} would refuse this device, which connects from ${NETWORK[cls]} address, and lock you out. Choose a wider mode, or change it from another device that mode accepts.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const from = this.accessMode;
|
|
325
|
+
if (from !== to) {
|
|
326
|
+
writeAccessMode(this.#db, to);
|
|
327
|
+
changed.accessMode = { from, to };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (input.complete !== undefined) {
|
|
331
|
+
if (input.complete !== true) throw new HttpError(400, 'complete can only be true');
|
|
332
|
+
if (!this.#setupRecord().completedAt) {
|
|
333
|
+
this.#db
|
|
334
|
+
.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value')
|
|
335
|
+
.run(SETTING_SETUP, JSON.stringify({ completedAt: nowIso() }));
|
|
336
|
+
changed.completed = true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { changed };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** What the owner runs on the server's machine to manage pairing (`--list`, `--revoke`, `--approve`). */
|
|
344
|
+
export function pairCommand(cfg: Pick<Config, 'container'>): string {
|
|
345
|
+
return serverCommand('pair', '', cfg.container);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The same, to approve the device that shows `code`. */
|
|
349
|
+
export function approveCommand(cfg: Pick<Config, 'container'>, code = '<code>'): string {
|
|
350
|
+
return serverCommand('pair', `--approve ${code}`, cfg.container);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The same, to change the access mode. */
|
|
354
|
+
export function accessModeCommand(cfg: Pick<Config, 'container'>, mode = '<mode>'): string {
|
|
355
|
+
return serverCommand('pair', `--access-mode ${mode}`, cfg.container);
|
|
356
|
+
}
|