@dmsdc-ai/aigentry-telepty 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +28 -0
- package/daemon.js +103 -10
- package/package.json +4 -4
- package/src/net/tailnet.js +63 -0
- package/src/net/win-firewall.js +63 -0
- package/src/protocol/http-auth.js +37 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Added — seamless cross-machine on Tailscale (auto bind + auto trust) (#672)
|
|
8
|
+
|
|
9
|
+
- **On a Tailscale host, a fresh install is cross-machine-ready with zero manual env.** At
|
|
10
|
+
startup the daemon detects its tailnet interface (a `100.64.0.0/10` address, via a pure
|
|
11
|
+
live scan of `os.networkInterfaces()` — no subprocess, no `tailscale` CLI dependency) and
|
|
12
|
+
**binds :3848 to the tailnet IP only, plus loopback**. LAN/public interfaces stay closed,
|
|
13
|
+
so the inject/control API is reachable **only from your Tailnet** — this does **not**
|
|
14
|
+
reopen the telepty#50 / audit hole (it never binds `0.0.0.0`). Tailnet peers are trusted
|
|
15
|
+
automatically (Tailscale ACLs already gate tailnet membership), so no token/allowlist is
|
|
16
|
+
needed. The tailnet IP is **discovered live every start, never persisted** — a
|
|
17
|
+
Tailscale-reassigned IP is followed automatically.
|
|
18
|
+
- **Preserved safe defaults & overrides.** No tailnet detected ⇒ loopback-only (telepty#50
|
|
19
|
+
unchanged). Manual `TELEPTY_BIND` / `HOST` / `TELEPTY_PEER_ALLOWLIST` always win over
|
|
20
|
+
auto-detect. New `TELEPTY_NO_TAILNET_AUTO=1` forces loopback even on a tailnet.
|
|
21
|
+
- **Detection hardened against ISP-CGNAT.** `100.64.0.0/10` is also the shared ISP-CGNAT
|
|
22
|
+
range; detection prefers a Tailscale-named interface (`tailscale*` / `utun*` / a
|
|
23
|
+
`Tailscale` adapter) and, when it can only match by range, flags it in the banner so an
|
|
24
|
+
operator on ISP-CGNAT can opt out.
|
|
25
|
+
- **`TELEPTY_PEER_ALLOWLIST` now supports CIDRs** (via native `net.BlockList`) as the
|
|
26
|
+
`:170` comment always documented; exact-IP entries keep exact-match semantics.
|
|
27
|
+
- **Windows:** on the auto path the daemon adds the inbound firewall allow-rule
|
|
28
|
+
automatically when elevated, otherwise prints the exact one-time `netsh` command.
|
|
29
|
+
- **Trust boundary (docs):** a Tailscale host auto-exposes :3848 to the *entire* tailnet;
|
|
30
|
+
restrict with `TELEPTY_PEER_ALLOWLIST` or disable with `TELEPTY_NO_TAILNET_AUTO=1`.
|
|
31
|
+
Zero-config cross-machine is Tailscale-specific; other topologies use the manual
|
|
32
|
+
`TELEPTY_BIND` path. Addressing stays IP-free — use MagicDNS names with `<id>@<host>`.
|
|
33
|
+
|
|
5
34
|
## [0.6.6] - 2026-06-14
|
|
6
35
|
|
|
7
36
|
### Fixed — duplicate same-id `allow` flap loop + `kill` doesn't stick (#56)
|
package/README.md
CHANGED
|
@@ -126,6 +126,34 @@ environment default is enabled.
|
|
|
126
126
|
|
|
127
127
|
telepty auto-discovers sessions across your Tailnet. All commands (`list`, `attach`, `inject`, `rename`, `multicast`, `broadcast`) work seamlessly across machines.
|
|
128
128
|
|
|
129
|
+
### Zero-config on Tailscale (auto bind + auto trust)
|
|
130
|
+
|
|
131
|
+
On a host that is on a **Tailscale tailnet**, a fresh install is cross-machine-ready with
|
|
132
|
+
**no manual env**. At startup the daemon detects its tailnet interface (a `100.64.0.0/10`
|
|
133
|
+
address) and:
|
|
134
|
+
|
|
135
|
+
- **binds :3848 to the tailnet IP only** (plus loopback) — LAN/public interfaces stay
|
|
136
|
+
closed, so the control API is reachable **only from your Tailnet**, never the flat LAN;
|
|
137
|
+
- **trusts tailnet peers automatically** — Tailscale's own ACLs already gate who is on the
|
|
138
|
+
tailnet, so a tailnet peer needs no token or manual allowlist.
|
|
139
|
+
|
|
140
|
+
> **Trust boundary:** on a Tailscale host, telepty auto-exposes :3848 to your **entire
|
|
141
|
+
> tailnet** (every peer Tailscale's ACLs let onto it). If you share your tailnet with
|
|
142
|
+
> machines you don't fully trust, set `TELEPTY_PEER_ALLOWLIST` to restrict to specific
|
|
143
|
+
> peers/CIDRs, or `TELEPTY_NO_TAILNET_AUTO=1` to stay loopback-only.
|
|
144
|
+
|
|
145
|
+
**Zero-config cross-machine is Tailscale-specific.** On a non-Tailscale host (plain LAN,
|
|
146
|
+
other mesh VPNs) the daemon stays **loopback-only** (the safe default) — set `TELEPTY_BIND`
|
|
147
|
+
+ `TELEPTY_PEER_ALLOWLIST` manually to expose it. Manual `TELEPTY_BIND` / `HOST` /
|
|
148
|
+
`TELEPTY_PEER_ALLOWLIST` always win over auto-detect.
|
|
149
|
+
|
|
150
|
+
**Windows:** Defender Firewall blocks inbound on the tailnet interface by default. On the
|
|
151
|
+
auto path the daemon adds the inbound allow-rule automatically when run elevated, otherwise
|
|
152
|
+
it prints the exact one-time `netsh` command in the startup banner.
|
|
153
|
+
|
|
154
|
+
Addressing stays **IP-free**: use MagicDNS / hostnames with `<id>@<host>` (below) — you
|
|
155
|
+
never need to type a `100.x.y.z` address.
|
|
156
|
+
|
|
129
157
|
### `<id>@<host>` syntax
|
|
130
158
|
|
|
131
159
|
To target a specific host (when the same session ID exists on multiple hosts,
|
package/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ const terminalBackend = require('./terminal-backend');
|
|
|
12
12
|
const { installWebSocketTransport, isOpenWebSocket } = require('./src/transport/websocket');
|
|
13
13
|
const { createPeerRelay, relayPeersFromEnv } = require('./src/transport/peer-relay');
|
|
14
14
|
const { createAuthMiddleware, createIsAllowedPeer, createVerifyJwt } = require('./src/protocol/http-auth');
|
|
15
|
+
const { detectTailnet, TAILNET_CIDR } = require('./src/net/tailnet');
|
|
15
16
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
16
17
|
const { DeliveryEngine } = require('./src/mailbox/delivery');
|
|
17
18
|
const { UnixSocketNotifier } = require('./src/mailbox/notifier');
|
|
@@ -170,6 +171,19 @@ app.use(express.json());
|
|
|
170
171
|
// Peer allowlist: comma-separated IPs/CIDRs in TELEPTY_PEER_ALLOWLIST env
|
|
171
172
|
const PEER_ALLOWLIST = (process.env.TELEPTY_PEER_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
172
173
|
|
|
174
|
+
// #672 tailnet auto (seamless cross-machine): detect the tailnet interface once at boot
|
|
175
|
+
// via a PURE live scan of os.networkInterfaces(). D1: the IP is discovered, never
|
|
176
|
+
// configured — used only in-memory for this run, never persisted, and re-detected every
|
|
177
|
+
// start so a Tailscale-reassigned IP is followed automatically. Broker mode has its own
|
|
178
|
+
// network posture (HTTPS + per-node JWT) and is excluded from auto-bind/auto-trust.
|
|
179
|
+
const TAILNET = brokerServer ? null : detectTailnet();
|
|
180
|
+
const TAILNET_IP = TAILNET ? TAILNET.ip : null;
|
|
181
|
+
const AUTO_TAILNET = isTailnetAuto(process.env, TAILNET_IP);
|
|
182
|
+
// Auto-trust the tailnet CIDR on the auto path — safe because the socket is bound to the
|
|
183
|
+
// tailnet interface only (§Q2). NEVER widen a manual allowlist: an operator's explicit
|
|
184
|
+
// TELEPTY_PEER_ALLOWLIST is passed through verbatim.
|
|
185
|
+
const effectivePeerAllowlist = resolveEffectivePeerAllowlist(PEER_ALLOWLIST, AUTO_TAILNET);
|
|
186
|
+
|
|
173
187
|
// #533 Phase 2 — peer-lane inject guardrail. The orchestrator sid(s) define the
|
|
174
188
|
// ORCH LANE (always allowed). Space-separated; default matches aigentry-orchestrator
|
|
175
189
|
// bin/ask.sh so both ends agree on "who is the orchestrator" from one config. If this
|
|
@@ -265,7 +279,7 @@ const relayToPeers = createPeerRelay({
|
|
|
265
279
|
const JWT_SECRET = process.env.TELEPTY_JWT_SECRET || null;
|
|
266
280
|
|
|
267
281
|
const verifyJwt = createVerifyJwt(JWT_SECRET);
|
|
268
|
-
const isAllowedPeer = createIsAllowedPeer(
|
|
282
|
+
const isAllowedPeer = createIsAllowedPeer(effectivePeerAllowlist);
|
|
269
283
|
|
|
270
284
|
// Health check – no auth required
|
|
271
285
|
app.get('/api/health', (req, res) => {
|
|
@@ -286,20 +300,71 @@ let boundPort = Number(PORT);
|
|
|
286
300
|
// opt-in: TELEPTY_BIND=0.0.0.0 (preferred) or the legacy HOST override.
|
|
287
301
|
// BREAKING: cross-machine peers that dialed this daemon directly over LAN
|
|
288
302
|
// need the opt-in on the daemon host after a restart (see CHANGELOG).
|
|
289
|
-
|
|
290
|
-
|
|
303
|
+
// Truthy for an opt-out/flag env var: set and not a falsey literal ('', '0', 'false').
|
|
304
|
+
function isTruthyEnv(v) {
|
|
305
|
+
return v != null && v !== '' && v !== '0' && String(v).toLowerCase() !== 'false';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// #672 tailnet auto: the zero-config tailnet path is active only when a tailnet IP was
|
|
309
|
+
// detected AND the operator set no manual bind override AND did not opt out.
|
|
310
|
+
function isTailnetAuto(env, tailnetIp) {
|
|
311
|
+
return !!tailnetIp && !env.TELEPTY_BIND && !env.HOST && !isTruthyEnv(env.TELEPTY_NO_TAILNET_AUTO);
|
|
291
312
|
}
|
|
292
313
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
function
|
|
314
|
+
// #672: auto-trust the tailnet CIDR ONLY when on the auto path with no manual allowlist.
|
|
315
|
+
// A non-empty manual allowlist is respected verbatim (never widened to the whole /10).
|
|
316
|
+
function resolveEffectivePeerAllowlist(peerAllowlist, autoTailnet) {
|
|
317
|
+
return (autoTailnet && peerAllowlist.length === 0) ? [TAILNET_CIDR] : peerAllowlist;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// telepty#50 + #672: loopback by default; explicit TELEPTY_BIND/HOST win; on a detected
|
|
321
|
+
// tailnet with no override, bind the LIVE tailnet IP (never 0.0.0.0). The tailnetIp arg
|
|
322
|
+
// is optional so legacy 1-arg callers keep the exact #50 behavior.
|
|
323
|
+
function resolveBindHost(env, tailnetIp) {
|
|
324
|
+
if (env.TELEPTY_BIND) return env.TELEPTY_BIND;
|
|
325
|
+
if (env.HOST) return env.HOST;
|
|
326
|
+
if (isTailnetAuto(env, tailnetIp)) return tailnetIp;
|
|
327
|
+
return '127.0.0.1';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Bind-address banner so operators can see (and fix) their exposure posture at startup
|
|
331
|
+
// without reading docs. Multi-line for the tailnet/loopback cases (G2).
|
|
332
|
+
function formatBindHint(host, tailnet) {
|
|
333
|
+
if (tailnet && host === tailnet.ip) {
|
|
334
|
+
const rangeNote = tailnet.nameMatched
|
|
335
|
+
? ''
|
|
336
|
+
: `\n note: tailnet IP matched by CGNAT range only (iface "${tailnet.iface}" not Tailscale-named) — if this is ISP-CGNAT, set TELEPTY_NO_TAILNET_AUTO=1`;
|
|
337
|
+
return ` bind: ${host} (tailnet auto) — reachable from your Tailnet only; LAN/public closed (loopback also served)${rangeNote}`;
|
|
338
|
+
}
|
|
296
339
|
if (host === '127.0.0.1' || host === 'localhost' || host === '::1') {
|
|
297
|
-
return ` bind: ${host} (loopback only) — LAN peers cannot connect; opt in with TELEPTY_BIND=0.0.0.0
|
|
340
|
+
return ` bind: ${host} (loopback only) — LAN peers cannot connect; opt in with TELEPTY_BIND=0.0.0.0`
|
|
341
|
+
+ `\n cross-machine: zero-config needs Tailscale (auto-binds the tailnet); otherwise set TELEPTY_BIND + TELEPTY_PEER_ALLOWLIST`;
|
|
298
342
|
}
|
|
299
343
|
return ` bind: ${host} — reachable from the network (TELEPTY_BIND/HOST opt-in)`;
|
|
300
344
|
}
|
|
301
345
|
|
|
302
|
-
|
|
346
|
+
// G1 (win32 only): a correct tailnet bind is not enough on Windows — Defender Firewall
|
|
347
|
+
// blocks inbound on the tailnet iface by default. Detect the rule; auto-add if elevated,
|
|
348
|
+
// else print the exact one-time command. Never fatal. No-op on mac/Linux.
|
|
349
|
+
function maybeGuideWindowsFirewall(port) {
|
|
350
|
+
if (process.platform !== 'win32') return;
|
|
351
|
+
try {
|
|
352
|
+
const { ensureInboundRule } = require('./src/net/win-firewall');
|
|
353
|
+
const r = ensureInboundRule({ port });
|
|
354
|
+
if (r.action === 'exists') {
|
|
355
|
+
console.log(` firewall: inbound rule "${r.ruleName}" present — tailnet peers can reach :${port}`);
|
|
356
|
+
} else if (r.action === 'added') {
|
|
357
|
+
console.log(` firewall: added inbound allow rule "${r.ruleName}" for :${port} (TCP)`);
|
|
358
|
+
} else {
|
|
359
|
+
console.log(` firewall: Windows blocks inbound on :${port} by default. Run once as Administrator:`);
|
|
360
|
+
console.log(` ${r.command}`);
|
|
361
|
+
}
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.warn(`[FIREWALL] check skipped: ${e && e.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const HOST = resolveBindHost(process.env, TAILNET_IP);
|
|
303
368
|
process.title = 'telepty-daemon';
|
|
304
369
|
|
|
305
370
|
// Singleton claim — guarded so a test require neither exits (when a daemon is running) nor
|
|
@@ -4058,6 +4123,32 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
|
|
|
4058
4123
|
console.log(formatBindHint(HOST)); // telepty#50
|
|
4059
4124
|
runStartupBootstrapRestore();
|
|
4060
4125
|
});
|
|
4126
|
+
} else if (AUTO_TAILNET) {
|
|
4127
|
+
// #672 tailnet auto path: bind loopback as the PRIMARY (drives bootstrap/boundPort
|
|
4128
|
+
// and effectively never fails), then add an additive best-effort listener on the
|
|
4129
|
+
// live tailnet IP so tailnet peers reach :3848 — LAN/public sockets stay closed. An
|
|
4130
|
+
// IP flap between detect and listen degrades to loopback-only (logged), never a
|
|
4131
|
+
// crash. ponytail: no auto-rebind on flap — a restart re-detects; add a re-detect
|
|
4132
|
+
// loop only if flaps prove real in the field.
|
|
4133
|
+
server = app.listen(PORT, '127.0.0.1', () => {
|
|
4134
|
+
const address = server.address();
|
|
4135
|
+
boundPort = (address && address.port) || Number(PORT);
|
|
4136
|
+
console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${boundPort}`);
|
|
4137
|
+
console.log(formatBindHint(HOST, TAILNET));
|
|
4138
|
+
maybeGuideWindowsFirewall(boundPort); // G1: Windows inbound-rule guide/auto-add
|
|
4139
|
+
runStartupBootstrapRestore();
|
|
4140
|
+
nodeBrokerClient = startNodeBrokerClient();
|
|
4141
|
+
});
|
|
4142
|
+
// Additive tailnet listener — same HTTP posture as the loopback primary (the daemon
|
|
4143
|
+
// is HTTP by design; TLS is broker-mode only, and tailnet transport is already
|
|
4144
|
+
// WireGuard-encrypted). app.listen() returns a fresh server, so this is a second
|
|
4145
|
+
// socket on the same app. ponytail: fixed-PORT production shares one port on both
|
|
4146
|
+
// listeners; a PORT=0 ephemeral run would split ports, but the auto path is never
|
|
4147
|
+
// taken under PORT=0 in the suite (TELEPTY_NO_TAILNET_AUTO=1 default in setup-env.js).
|
|
4148
|
+
const tailnetServer = app.listen(Number(PORT), TAILNET_IP);
|
|
4149
|
+
tailnetServer.on('error', (e) => {
|
|
4150
|
+
console.warn(`[BIND] tailnet listener ${TAILNET_IP}:${Number(PORT)} unavailable (staying loopback-only): ${e && e.message}`);
|
|
4151
|
+
});
|
|
4061
4152
|
} else {
|
|
4062
4153
|
server = app.listen(PORT, HOST, () => {
|
|
4063
4154
|
const address = server.address();
|
|
@@ -4447,6 +4538,8 @@ module.exports = {
|
|
|
4447
4538
|
loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
|
|
4448
4539
|
startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
|
|
4449
4540
|
deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
|
|
4450
|
-
resolveBindHost, // telepty#50: pure bind-address policy (loopback default, env opt-in)
|
|
4451
|
-
formatBindHint, // telepty#50: startup bind/exposure banner line
|
|
4541
|
+
resolveBindHost, // telepty#50 + #672: pure bind-address policy (loopback default, env opt-in, tailnet auto)
|
|
4542
|
+
formatBindHint, // telepty#50 + #672: startup bind/exposure banner line
|
|
4543
|
+
isTailnetAuto, // #672: pure predicate — is the zero-config tailnet path active
|
|
4544
|
+
resolveEffectivePeerAllowlist, // #672: pure allowlist policy — auto-trust tailnet without widening a manual set
|
|
4452
4545
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"postinstall": "node scripts/postinstall.js",
|
|
39
39
|
"preuninstall": "node scripts/preuninstall.js",
|
|
40
|
-
"test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
-
"test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
|
|
42
|
-
"test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
40
|
+
"test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/tailnet-autobind.test.js test/win-firewall.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
+
"test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/tailnet-autobind.test.js test/win-firewall.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
|
|
42
|
+
"test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/tailnet-autobind.test.js test/win-firewall.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/dupid-flap-kill-stick-56.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/daemon-lifecycle-55.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
45
45
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tailnet detection for seamless cross-machine (tailnet auto-bind + auto-trust).
|
|
4
|
+
// PURE + injectable: takes an os.networkInterfaces()-shaped object so the decision
|
|
5
|
+
// logic is unit-testable without a real tailnet. No subprocess (no `tailscale` CLI):
|
|
6
|
+
// the interface scan already yields the bind IP, and a child_process call would only
|
|
7
|
+
// add a PATH/exec failure mode + a command-injection surface. Article-17 fallback IS
|
|
8
|
+
// the scan itself.
|
|
9
|
+
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const net = require('net');
|
|
12
|
+
|
|
13
|
+
// Tailscale assigns node IPs from the CGNAT range 100.64.0.0/10 (100.64.0.0 –
|
|
14
|
+
// 100.127.255.255). This is also the *effective trust CIDR* — safe only because the
|
|
15
|
+
// daemon binds the socket to the tailnet interface itself (see daemon.js), so only
|
|
16
|
+
// WireGuard-tunneled tailnet peers can reach it.
|
|
17
|
+
const TAILNET_CIDR = '100.64.0.0/10';
|
|
18
|
+
|
|
19
|
+
const _cgnat = new net.BlockList();
|
|
20
|
+
_cgnat.addSubnet('100.64.0.0', 10, 'ipv4');
|
|
21
|
+
function inTailnetRange(ip) {
|
|
22
|
+
return typeof ip === 'string' && net.isIPv4(ip) && _cgnat.check(ip, 'ipv4');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// R1: 100.64.0.0/10 is ALSO the shared ISP-CGNAT range — a machine directly on ISP
|
|
26
|
+
// CGNAT (some VPS/direct-modem setups) can carry a 100.64.x address on a REAL,
|
|
27
|
+
// non-Tailscale interface. Binding+trusting that would expose :3848 to the ISP-CGNAT
|
|
28
|
+
// segment (other customers). The iface NAME (already a key in networkInterfaces())
|
|
29
|
+
// disambiguates at zero cost: prefer a Tailscale-named iface; fall back to range-only
|
|
30
|
+
// only when none is named, and flag that fallback so an operator can see + opt out.
|
|
31
|
+
const TAILSCALE_NAME = /tailscale|^utun/i; // tailscale0 (Linux) / utun* (macOS) / Tailscale adapter (Windows)
|
|
32
|
+
|
|
33
|
+
function isIPv4Addr(addr) {
|
|
34
|
+
return addr && !addr.internal && (addr.family === 'IPv4' || addr.family === 4);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Returns { ip, iface, nameMatched } for the tailnet interface, or null if none.
|
|
38
|
+
// nameMatched=true → iface name looked like Tailscale (high confidence)
|
|
39
|
+
// nameMatched=false → range-only fallback (could be ISP-CGNAT; caller flags it)
|
|
40
|
+
function detectTailnet(interfaces = os.networkInterfaces()) {
|
|
41
|
+
if (!interfaces || typeof interfaces !== 'object') return null;
|
|
42
|
+
let rangeOnly = null; // first in-range candidate whose name did NOT match
|
|
43
|
+
for (const iface of Object.keys(interfaces)) {
|
|
44
|
+
const addrs = interfaces[iface];
|
|
45
|
+
if (!Array.isArray(addrs)) continue;
|
|
46
|
+
for (const addr of addrs) {
|
|
47
|
+
if (!isIPv4Addr(addr) || !inTailnetRange(addr.address)) continue;
|
|
48
|
+
if (TAILSCALE_NAME.test(iface)) {
|
|
49
|
+
return { ip: addr.address, iface, nameMatched: true }; // name + range → done
|
|
50
|
+
}
|
|
51
|
+
if (!rangeOnly) rangeOnly = { ip: addr.address, iface, nameMatched: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return rangeOnly; // range-only fallback, or null when no candidate at all
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Convenience for callers that only need the bind IP.
|
|
58
|
+
function detectTailnetIp(interfaces) {
|
|
59
|
+
const d = detectTailnet(interfaces);
|
|
60
|
+
return d ? d.ip : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { detectTailnet, detectTailnetIp, inTailnetRange, TAILNET_CIDR };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// G1 (Windows only): auto-binding the tailnet interface is necessary but NOT sufficient
|
|
4
|
+
// on Windows — Defender Firewall blocks inbound on the tailnet iface by default, so a
|
|
5
|
+
// tailnet peer still can't reach :3848 after a correct bind. Detect the inbound rule; add
|
|
6
|
+
// it automatically when the daemon runs elevated, otherwise print the exact one-time
|
|
7
|
+
// command. Never fatal. ponytail: no cross-firewall abstraction — mac/Linux need nothing.
|
|
8
|
+
//
|
|
9
|
+
// SECURITY: uses spawnSync with an ARGUMENT ARRAY (no shell) and the only interpolated
|
|
10
|
+
// value is the port, validated to an integer in [1,65535] — no command-injection surface.
|
|
11
|
+
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
function toPort(port) {
|
|
15
|
+
const p = Math.trunc(Number(port));
|
|
16
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`invalid port: ${port}`);
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function firewallRuleName(port) {
|
|
21
|
+
return `telepty-${toPort(port)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addRuleArgs(port) {
|
|
25
|
+
const p = toPort(port);
|
|
26
|
+
return ['advfirewall', 'firewall', 'add', 'rule', `name=telepty-${p}`, 'dir=in', 'action=allow', 'protocol=TCP', `localport=${p}`];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showRuleArgs(port) {
|
|
30
|
+
return ['advfirewall', 'firewall', 'show', 'rule', `name=telepty-${toPort(port)}`];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The copy-pasteable command shown in the banner when we can't add it ourselves.
|
|
34
|
+
function commandString(port) {
|
|
35
|
+
const p = toPort(port);
|
|
36
|
+
return `netsh advfirewall firewall add rule name="telepty-${p}" dir=in action=allow protocol=TCP localport=${p}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// runner is injectable for tests: (args) => { status, stdout, stderr, error }
|
|
40
|
+
function defaultRunner(args) {
|
|
41
|
+
const r = spawnSync('netsh', args, { encoding: 'utf8', windowsHide: true });
|
|
42
|
+
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ruleExists(port, runner = defaultRunner) {
|
|
46
|
+
const r = runner(showRuleArgs(port));
|
|
47
|
+
// netsh 'show rule' → status 0 + rule text when present; nonzero / "No rules match" when absent.
|
|
48
|
+
if (!r || r.error) return false;
|
|
49
|
+
return r.status === 0 && /telepty-\d+/i.test(r.stdout || '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Returns { action: 'exists'|'added'|'guide', ruleName, command }.
|
|
53
|
+
function ensureInboundRule({ port, runner = defaultRunner } = {}) {
|
|
54
|
+
const ruleName = firewallRuleName(port);
|
|
55
|
+
const command = commandString(port);
|
|
56
|
+
if (ruleExists(port, runner)) return { action: 'exists', ruleName, command };
|
|
57
|
+
const add = runner(addRuleArgs(port));
|
|
58
|
+
if (add && !add.error && add.status === 0) return { action: 'added', ruleName, command };
|
|
59
|
+
// unelevated or failed → guide the operator, never crash
|
|
60
|
+
return { action: 'guide', ruleName, command };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { firewallRuleName, addRuleArgs, showRuleArgs, commandString, ruleExists, ensureInboundRule };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const net = require('net');
|
|
4
5
|
|
|
5
6
|
function createVerifyJwt(JWT_SECRET) {
|
|
6
7
|
function verifyJwt(token) {
|
|
@@ -53,16 +54,47 @@ function isRevokedNode(revokedNodes, decodedJwtOrSub) {
|
|
|
53
54
|
return false;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Build a net.BlockList from allowlist entries so the ":170 comment"-promised
|
|
58
|
+
// "IPs/CIDRs" actually work: `a.b.c.d/n` → subnet, plain IP → exact address (exact
|
|
59
|
+
// entries keep the prior includes() semantics). Native (Node v15+), zero-dep. A
|
|
60
|
+
// malformed entry is skipped, never fatal. Returns null when the list is empty so the
|
|
61
|
+
// caller can preserve the "empty = allow all authenticated" branch.
|
|
62
|
+
function buildAllowBlockList(entries) {
|
|
63
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
64
|
+
const bl = new net.BlockList();
|
|
65
|
+
for (const raw of entries) {
|
|
66
|
+
const entry = String(raw).trim();
|
|
67
|
+
if (!entry) continue;
|
|
68
|
+
try {
|
|
69
|
+
const slash = entry.indexOf('/');
|
|
70
|
+
if (slash !== -1) {
|
|
71
|
+
const addr = entry.slice(0, slash);
|
|
72
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
73
|
+
const type = net.isIPv6(addr) ? 'ipv6' : 'ipv4';
|
|
74
|
+
bl.addSubnet(addr, prefix, type);
|
|
75
|
+
} else {
|
|
76
|
+
const type = net.isIPv6(entry) ? 'ipv6' : 'ipv4';
|
|
77
|
+
bl.addAddress(entry, type);
|
|
78
|
+
}
|
|
79
|
+
} catch { /* skip a malformed allowlist entry — never crash the daemon */ }
|
|
80
|
+
}
|
|
81
|
+
return bl;
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
function createIsAllowedPeer(PEER_ALLOWLIST) {
|
|
85
|
+
const blockList = buildAllowBlockList(PEER_ALLOWLIST);
|
|
57
86
|
function isAllowedPeer(ip) {
|
|
58
87
|
if (!ip) return false;
|
|
59
88
|
const cleanIp = ip.replace('::ffff:', '');
|
|
60
|
-
// Localhost always allowed (includes SSH tunnel traffic)
|
|
89
|
+
// Localhost always allowed (includes SSH tunnel traffic). FIRST — auto-populating
|
|
90
|
+
// the allowlist can never tighten loopback.
|
|
61
91
|
if (cleanIp === '127.0.0.1' || ip === '::1') return true;
|
|
62
|
-
//
|
|
63
|
-
if (
|
|
64
|
-
//
|
|
65
|
-
|
|
92
|
+
// No allowlist = allow all authenticated (preserved)
|
|
93
|
+
if (!blockList) return true;
|
|
94
|
+
// Peer allowlist — CIDR + exact match
|
|
95
|
+
try {
|
|
96
|
+
return blockList.check(cleanIp, net.isIPv6(cleanIp) ? 'ipv6' : 'ipv4');
|
|
97
|
+
} catch { return false; }
|
|
66
98
|
}
|
|
67
99
|
|
|
68
100
|
return isAllowedPeer;
|