nattermatic 0.0.1 → 0.2.1

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/src/node.js ADDED
@@ -0,0 +1,1387 @@
1
+ // The per-machine node: durable store, local session hub, peer-to-peer delivery, LAN discovery.
2
+ //
3
+ // Every connection, in and out, is TLS. The certificate this node presents is a throwaway (a fresh
4
+ // key per connection; while invites are open, the invite key), so connecting to the port shows
5
+ // nothing that identifies the machine. Who is who is proved inside the connection (see tls.js):
6
+ // outgoing connections accept only the machine the roster says should be at the other end, so an
7
+ // address is usable for a machine exactly when the machine there proves it holds that key, and a
8
+ // stranger who happens to hold a stored address is never told who we are or sent a request.
9
+ import https from 'node:https'
10
+ import http from 'node:http'
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import crypto from 'node:crypto'
14
+ import { execFile } from 'node:child_process'
15
+ import { promisify } from 'node:util'
16
+ import { openStore } from './store.js'
17
+ import { startDiscovery } from './discovery.js'
18
+ import { signEnvelope, verifyEnvelope, sha256, randomToken, gateOk, proveAuth, checkAuth } from './crypto.js'
19
+ import { fingerprint, shortFp, newIdentity, selfSignedCert } from './cert.js'
20
+ import { Roster, MAX_ROSTER_BYTES } from './roster.js'
21
+ import { authedRequest, exporterOf, PinError, ProtoError, PROTO, MAX_BODY as MAX_REQ, MIN_RATE } from './tls.js'
22
+ import { SEA, PKG_ROOT, readAsset, appBundle } from './self.js'
23
+ import { renameOldDb } from './legacy.js'
24
+ import { parseAddr, parsePeer, normHost, machineName, sanitizeName, shQuote, validAddr, tailscaleName, localIPv4s, errMsg, netHint, ensureHomeDir, rosterPath, MAX_TTL_MS, VERSION } from './config.js'
25
+
26
+ const MAX_JOIN_REQ = 16 << 10 // a join request (before any identity check) is small
27
+ const MAX_JOIN_CHAIN = 4 // add records an unknown client may submit (it, whoever added it, ...)
28
+ const MAX_REPLY = 1 << 20 // a reply to anything but a hello: only a hello carries a roster
29
+ const MAX_BATCH = 256 << 10 // bytes of envelopes per request (well under MAX_REQ)
30
+ const MAX_MSG = 64 * 1024
31
+ const BACKOFF_MAX = 60_000
32
+ const HELLO_EVERY = 120_000
33
+ const REPLICATE_TO = 3 // max other nodes that hold a copy for an offline machine
34
+ const PEER_FORGET_MS = 30 * 86400_000
35
+ const IN_FLIGHT_MS = 30_000 // a send being delivered directly is hidden from the flush loop
36
+ const PROBE_RETRY_MS = 10 * 60_000 // don't re-probe an address that just failed verification
37
+ const HELLO_CONCURRENCY = 2 // offline .local names tie up a DNS (libuv) thread each for seconds
38
+ const FLUSH_HOSTS = 8 // machines being delivered to at once
39
+ const PUMP_WINDOW = 256 // messages a session may hold unacknowledged: the rest wait here
40
+ const PUMP_BUFFER = 1 << 20 // bytes waiting for a session to read before we stop writing
41
+ const PEERS_WAIT_MS = 5000 // `peers` answers by then: a machine that slow counts as offline
42
+ const CLOCK_SLACK_MS = 86400_000 // how far ahead of us a sender's clock can put a message's expiry
43
+ const INVITE_TTL_MS = 24 * 3600_000
44
+ const SEND_WAIT_MS = 10_000 // a send answers by then: it's stored, and clients stop waiting at 15 s
45
+ const COMPACT_WAIT_MS = 100_000 // to catch up before a snapshot: the command waits a little longer
46
+ const MAX_LOG = 10 << 20 // node.log is cut back to its last MB past this
47
+ const LOG_ERRORS_PER_MIN = 10 // failed requests logged: anyone can cause one
48
+
49
+ /** @typedef {import('./types.js').Config} Config */
50
+ /** @typedef {import('./types.js').Envelope} Envelope */
51
+ /** @typedef {import('./types.js').MessageRow} MessageRow */
52
+ /** @typedef {import('./types.js').PeerRow} PeerRow */
53
+ /** @typedef {http.ServerResponse} Res */
54
+ /** What the forwarding loop needs of a row: the text is read only when it's being sent. */
55
+ /** @typedef {Pick<MessageRow, 'id' | 'to_host' | 'role' | 'attempts' | 'replicated'>} DueRow */
56
+
57
+ const execFileP = promisify(execFile)
58
+ const now = () => Date.now()
59
+ const backoff = (/** @type {number} */ n) => Math.min(BACKOFF_MAX, 2000 * 2 ** Math.min(n, 5))
60
+ const ignore = () => {}
61
+
62
+ /**
63
+ * @param {Config} cfg
64
+ * @param {(...a: any[]) => void} [log]
65
+ */
66
+ export async function runNode(cfg, log = (...a) => { try { console.error(new Date().toISOString(), ...a) } catch {} }) {
67
+ ensureHomeDir(cfg.dir)
68
+ // a full disk mustn't take the node down with its log. (Once: a standby service gets here on every retry)
69
+ if (!process.stderr.listeners('error').includes(ignore)) process.stderr.on('error', ignore)
70
+ const me = cfg.identity
71
+ // Bind the port before touching the shared database: a node that loses the race for the port
72
+ // (a standby service, a second session-started node) must not reset retry state or leak handles.
73
+ /** @type {(req: http.IncomingMessage, res: Res) => void} */
74
+ let onRequest = (req, res) => { res.writeHead(503, { 'content-type': 'application/json' }); res.end('{"error":"starting"}') }
75
+ // TLS 1.2 is accepted for the installer download (macOS curl's SecureTransport has no TLS 1.3);
76
+ // the identity exchange requires TLS 1.3.
77
+ /** @param {{ key: string, cert: string }} id */
78
+ const tlsOpts = id => ({ key: id.key, cert: id.cert, minVersion: /** @type {const} */ ('TLSv1.2') })
79
+ const server = https.createServer(tlsOpts(newIdentity()), (req, res) => onRequest(req, res))
80
+ server.on('tlsClientError', () => {})
81
+ /** @type {() => void} */
82
+ let rotate = () => {}
83
+ server.on('connection', () => rotate())
84
+ await new Promise(/** @param {(v?: void) => void} resolve */ (resolve, reject) => {
85
+ server.once('error', reject)
86
+ server.listen(cfg.port, () => { server.off('error', reject); resolve() })
87
+ })
88
+ /** @type {import('node:sqlite').DatabaseSync} */
89
+ let db
90
+ /** @type {Roster} */
91
+ let roster
92
+ try {
93
+ roster = Roster.load(rosterPath(cfg.dir))
94
+ renameOldDb(cfg.dir) // this node has the port, so no other node of this machine has it open
95
+ db = openStore(path.join(cfg.dir, 'nattermatic.db'))
96
+ } catch (e) { server.close(); throw e }
97
+ const myPub = crypto.createPublicKey(me.key)
98
+ // the roster decides this machine's name: under any other, its messages wouldn't be accepted
99
+ const rosterName = roster.members.get(me.fp)?.host
100
+ if (rosterName && rosterName !== cfg.host) {
101
+ log(`this machine is ${rosterName} on its network, not ${cfg.host}: using ${rosterName}`)
102
+ cfg.host = rosterName
103
+ }
104
+ if (!roster.members.has(me.fp)) log(roster.removed.has(me.fp) ? 'this machine has been removed from the network' : 'this machine is not (yet) a member of its network')
105
+ /** @type {Map<string, { res: Res, sent: Set<string>, token: string, live: boolean, owner: number, cwd: string, notified: Set<string>, waiter: Res | null, more: boolean, draining: boolean }>} name -> session stream (live: woken by channel pushes; sent: offered, not yet acknowledged) */
106
+ const sessions = new Map()
107
+
108
+ const q = {
109
+ insert: db.prepare(`INSERT OR IGNORE INTO messages
110
+ (id,from_addr,to_addr,to_host,to_name,body,sent_at,expires_at,sig,role,state,next_attempt,updated_at)
111
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`),
112
+ get: db.prepare(`SELECT * FROM messages WHERE id=?`),
113
+ // the names with something due, those tried least first, so a backlog for a machine that's off
114
+ // can't keep the others waiting; what's due for one name, the oldest first; and the names nothing
115
+ // has identified, the same way, so a crowd of made-up names can't keep a real one waiting
116
+ due: db.prepare(`SELECT to_host FROM messages WHERE state='pending' AND next_attempt<=? AND expires_at>? AND to_host IN (SELECT value FROM json_each(?)) GROUP BY to_host ORDER BY MIN(attempts)`),
117
+ dueFor: db.prepare(`SELECT id,to_host,role,attempts,replicated FROM messages WHERE state='pending' AND next_attempt<=? AND expires_at>? AND to_host=? ORDER BY sent_at LIMIT 2000`),
118
+ dueNames: db.prepare(`SELECT to_host FROM messages WHERE state='pending' AND next_attempt<=? AND expires_at>? AND to_host NOT IN (SELECT value FROM json_each(?)) GROUP BY to_host ORDER BY MIN(attempts) LIMIT ?`),
119
+ // handed over: the text has no more use here, and shouldn't sit on a holder until the message expires
120
+ forwarded: db.prepare(`UPDATE messages SET state='forwarded', body='', updated_at=? WHERE id=? AND state='pending'`),
121
+ failed: db.prepare(`UPDATE messages SET state='failed', last_error=?, updated_at=? WHERE id=?`),
122
+ retry: db.prepare(`UPDATE messages SET attempts=attempts+1, next_attempt=?, last_error=?, updated_at=? WHERE id=? AND state='pending'`),
123
+ replicated: db.prepare(`UPDATE messages SET replicated=1 WHERE id=?`),
124
+ wake: db.prepare(`UPDATE messages SET next_attempt=0 WHERE state='pending' AND to_host=?`),
125
+ toInbox: db.prepare(`UPDATE messages SET role='inbox', state='queued', to_host=?, updated_at=? WHERE to_host=? AND state='pending' RETURNING to_name`),
126
+ inboxQueued: db.prepare(`SELECT id FROM messages WHERE role='inbox' AND to_name=? AND state='queued' AND expires_at>? ORDER BY sent_at LIMIT ?`),
127
+ // the machine's own copy of something it was holding as a copy for a name it didn't know was its own
128
+ claim: db.prepare(`UPDATE messages SET role='inbox', state='queued', to_host=?, to_name=?, updated_at=? WHERE id=? AND role='held' AND state='pending'`),
129
+ delivered: db.prepare(`UPDATE messages SET state='delivered', updated_at=? WHERE id=? AND role='inbox'`),
130
+ // pushed into a channel session: Claude Code gives no receipt, so this means "shown", not "read"
131
+ pushed: db.prepare(`UPDATE messages SET state='pushed', updated_at=? WHERE id=? AND role='inbox' AND state='queued'`),
132
+ // newest to arrive: sent_at is whatever the sender says
133
+ history: db.prepare(`SELECT * FROM messages WHERE role='inbox' AND to_name=? ORDER BY rowid DESC LIMIT ?`),
134
+ expire: db.prepare(`UPDATE messages SET state='expired', updated_at=? WHERE state IN ('pending','queued') AND expires_at<=?`),
135
+ gc: db.prepare(`DELETE FROM messages WHERE expires_at < ?`),
136
+ counts: db.prepare(`SELECT role, state, count(*) n FROM messages GROUP BY role, state`),
137
+ waiting: db.prepare(`SELECT count(*) n FROM messages WHERE to_host=? AND to_name=? AND state IN ('pending','queued')`),
138
+ // pages in use: what's been freed is used again before the file grows
139
+ stored: db.prepare(`SELECT (p.page_count - f.freelist_count) * s.page_size n FROM pragma_page_count p, pragma_freelist_count f, pragma_page_size s`),
140
+ upsertPeer: db.prepare(`INSERT INTO peers(host,addr,last_seen,configured) VALUES(?,?,?,?)
141
+ ON CONFLICT(host) DO UPDATE SET last_seen=excluded.last_seen,
142
+ addr=COALESCE(excluded.addr, peers.addr), configured=MAX(peers.configured, excluded.configured)`),
143
+ peer: db.prepare(`SELECT * FROM peers WHERE host=?`),
144
+ peers: db.prepare(`SELECT * FROM peers ORDER BY host`),
145
+ setAlias: db.prepare(`UPDATE peers SET alias_of=? WHERE host=?`),
146
+ deletePeer: db.prepare(`DELETE FROM peers WHERE host=?`),
147
+ clearAddr: db.prepare(`UPDATE peers SET addr=NULL WHERE host=?`),
148
+ forgetPeers: db.prepare(`DELETE FROM peers WHERE configured=0 AND last_seen < ?`),
149
+ addInvite: db.prepare(`INSERT INTO invites(hash, created_at, expires_at) VALUES(?,?,?)`),
150
+ invite: db.prepare(`SELECT * FROM invites WHERE hash=?`),
151
+ useInvite: db.prepare(`UPDATE invites SET used_at=? WHERE hash=? AND used_at IS NULL`),
152
+ revokeInvites: db.prepare(`DELETE FROM invites WHERE used_at IS NULL`),
153
+ gcInvites: db.prepare(`DELETE FROM invites WHERE expires_at < ?`),
154
+ openInvite: db.prepare(`SELECT 1 FROM invites WHERE used_at IS NULL AND expires_at > ? LIMIT 1`),
155
+ openInvites: db.prepare(`SELECT created_at, expires_at FROM invites WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at`),
156
+ }
157
+
158
+ // ---------- the certificate we present ----------
159
+ // While an invite is open, the invite key (the one its curl line pins; shared by all open invites,
160
+ // kept across restarts, replaced once none are open). Otherwise a fresh key for each connection,
161
+ // or at most each second (key generation costs about a millisecond).
162
+ const inviteKeyPath = path.join(cfg.dir, 'invite.key')
163
+ /** @type {{ key: string, cert: string, fp: string } | null} */
164
+ let invite = null
165
+ /** The invite key if any invite is open (deleted once none is). */
166
+ function inviteIdentity() {
167
+ if (!q.openInvite.get(now())) {
168
+ if (invite || fs.existsSync(inviteKeyPath)) { fs.rmSync(inviteKeyPath, { force: true }); invite = null }
169
+ return null
170
+ }
171
+ if (!invite) {
172
+ try {
173
+ const key = fs.readFileSync(inviteKeyPath, 'utf8'), k = crypto.createPrivateKey(key)
174
+ invite = { key, cert: selfSignedCert(k), fp: fingerprint(k) }
175
+ } catch {}
176
+ }
177
+ return invite
178
+ }
179
+ let presenting = '', rotatedAt = 0
180
+ /** @type {NodeJS.Timeout | null} */
181
+ let rotateTimer = null
182
+ rotate = () => {
183
+ let inv = null
184
+ try { inv = inviteIdentity() } catch (e) { log('invite key:', errMsg(e)) }
185
+ if (inv) {
186
+ if (presenting !== inv.fp) { server.setSecureContext(tlsOpts(inv)); presenting = inv.fp }
187
+ return
188
+ }
189
+ // the current key has just been shown: replace it now, or once a second has passed since the last
190
+ // replacement, so no two connections a second or more apart ever see the same key
191
+ const wait = presenting === 'throwaway' ? 1000 - (now() - rotatedAt) : 0
192
+ if (wait > 0) {
193
+ rotateTimer ||= setTimeout(() => { rotateTimer = null; rotate() }, wait)
194
+ rotateTimer.unref()
195
+ return
196
+ }
197
+ server.setSecureContext(tlsOpts(newIdentity()))
198
+ presenting = 'throwaway'
199
+ rotatedAt = now()
200
+ }
201
+ /** A new invite token; makes the invite key if none is open. */
202
+ function newInvite() {
203
+ if (!inviteIdentity()) {
204
+ const id = newIdentity()
205
+ fs.writeFileSync(inviteKeyPath, id.key, { mode: 0o600 })
206
+ invite = id
207
+ }
208
+ const inv = /** @type {{ key: string, cert: string, fp: string }} */ (invite)
209
+ const token = randomToken()
210
+ q.addInvite.run(sha256(token), now(), now() + INVITE_TTL_MS)
211
+ rotate()
212
+ return { token, fp: inv.fp }
213
+ }
214
+ rotate()
215
+
216
+ // typed views of the rows node:sqlite returns
217
+ const peerRow = (/** @type {string} */ host) => /** @type {PeerRow | undefined} */ (q.peer.get(host))
218
+ const allPeers = () => /** @type {PeerRow[]} */ (/** @type {unknown} */ (q.peers.all()))
219
+ const msgRow = (/** @type {string} */ id) => /** @type {MessageRow | undefined} */ (q.get.get(id))
220
+ /** @type {(rows: unknown) => DueRow[]} */
221
+ const dueRows = rows => /** @type {DueRow[]} */ (rows)
222
+ /** One commit for many row updates. @param {() => void} fn */
223
+ function tx(fn) {
224
+ db.exec('BEGIN IMMEDIATE')
225
+ try { fn(); db.exec('COMMIT') } catch (e) { try { db.exec('ROLLBACK') } catch {} throw e }
226
+ }
227
+
228
+ // ---------- identity ----------
229
+ const selfAliases = new Set(cfg.aliases)
230
+ /** This machine's Tailscale name, if it has one now (Tailscale may arrive while the node runs). */
231
+ function tailscale() {
232
+ const name = cfg.autoAliases ? tailscaleName() : null
233
+ if (name && name !== cfg.host) selfAliases.add(name)
234
+ return name
235
+ }
236
+ tailscale()
237
+ selfAliases.delete(cfg.host)
238
+ const isSelf = (/** @type {string} */ h) => h === cfg.host || selfAliases.has(h)
239
+ /** A current member other than this machine, by name. @param {string} host */
240
+ const member = host => { const m = roster.byHost.get(host); return m && m.fp !== me.fp ? m : undefined }
241
+ const otherMembers = () => [...roster.members.values()].filter(m => m.fp !== me.fp)
242
+ /** Merge membership records from a peer; persist if anything changed. @param {unknown} records */
243
+ function mergeRoster(records) {
244
+ if (!roster.merge(records)) return false
245
+ try { roster.save() } catch (e) { log('could not save the roster:', errMsg(e)) }
246
+ return true
247
+ }
248
+
249
+ /**
250
+ * `name` turned out to be one of this machine's names (an IP, a Tailscale name used by a sender):
251
+ * take delivery of anything queued for it. Configured peers are other machines by definition, so
252
+ * a configured name whose address now reaches us (DHCP reuse) is not adopted.
253
+ * @param {string} name
254
+ */
255
+ function adoptSelfAlias(name) {
256
+ const row = peerRow(name)
257
+ if (row?.configured) {
258
+ log(`address ${row.addr || name} for ${name} now reaches this machine; forgetting it`)
259
+ q.clearAddr.run(name)
260
+ return false
261
+ }
262
+ if (!selfAliases.has(name)) log(`learned that ${name} is this machine`)
263
+ selfAliases.add(name)
264
+ if (row) q.deletePeer.run(name)
265
+ const names = new Set(q.toInbox.all(cfg.host, now(), name).map(r => String(r.to_name)))
266
+ for (const n of names) pump(n)
267
+ return true
268
+ }
269
+
270
+ for (const pe of cfg.peers) if (!isSelf(pe.host)) q.upsertPeer.run(pe.host, pe.addr, now(), 1)
271
+ db.exec(`UPDATE messages SET next_attempt=0 WHERE state='pending'`) // fresh start: retry everything now
272
+ // rows with out-of-range timestamps (stored before they were validated) can't even be read back by
273
+ // node:sqlite, and would stall the forwarding loop for good: drop them without selecting them
274
+ {
275
+ const bad = db.prepare(`DELETE FROM messages WHERE expires_at > ? OR sent_at > ? OR sent_at < 0`).run(now() + 2 * MAX_TTL_MS, now() + 2 * MAX_TTL_MS)
276
+ if (bad.changes) log(`dropped ${bad.changes} stored message(s) with invalid timestamps`)
277
+ }
278
+
279
+ // ---------- peers ----------
280
+ const addrOf = (/** @type {string} */ host) => cfg.routes[host] || peerRow(host)?.addr || `${host}:${cfg.port}`
281
+ const helloBody = () => ({ peers: shareablePeers(), records: roster.records })
282
+
283
+ /**
284
+ * Request to another member. The machine at the address must prove it holds that member's key
285
+ * before we say who we are or send the request: anyone else gets nothing.
286
+ * `addr` dials an explicit, not yet verified address instead of the stored route for `host`.
287
+ * @param {string} host
288
+ * @param {'GET'|'POST'} method
289
+ * @param {string} p
290
+ * @param {object} [obj]
291
+ * @param {{ timeout?: number, addr?: string, retried?: boolean, deadline?: number }} [opts]
292
+ * @returns {Promise<any>}
293
+ */
294
+ async function call(host, method, p, obj, { timeout = 4000, addr, retried = false, deadline } = {}) {
295
+ const m = member(host)
296
+ if (!m) throw new Error(notMember(host))
297
+ const body = obj === undefined ? '' : JSON.stringify(obj)
298
+ timeout += Math.ceil(Buffer.byteLength(body) / MIN_RATE) // big batches on slow links still finish
299
+ const target = addr || addrOf(host)
300
+ if (!validAddr(target)) throw new Error(`invalid address for ${host}: ${target}`)
301
+ let r
302
+ try {
303
+ r = await authedRequest({ addr: target, method, path: p, body, headers: { 'x-agentlink-port': String(cfg.port) }, identity: me, accept: new Map([[m.fp, m.publicKey]]), dks: roster.dks, timeout, deadline }, p === '/v1/hello' ? MAX_REQ : MAX_REPLY)
304
+ } catch (e) {
305
+ // reason: why the machine can't be reached, without its name (peers shows it next to the name)
306
+ const reason = e instanceof PinError ? `${target} is answered by a different machine (not ${host}'s key)`
307
+ : e instanceof ProtoError ? e.message
308
+ : `${target}: ${errMsg(e)}${netHint(e)}`
309
+ if (!addr) noteReach(host, reason) // a probe of an unverified address says nothing about the machine
310
+ throw Object.assign(new Error(e instanceof ProtoError ? `${host}: ${reason}` : `${host} at ${reason}`), { reason })
311
+ }
312
+ if (!addr) noteReach(host, null)
313
+ // a member that hasn't heard we joined yet: tell it (hello carries the records that add us), then retry
314
+ if (r.status === 403 && p !== '/v1/hello' && !retried) {
315
+ await call(host, 'POST', '/v1/hello', helloBody(), { timeout: 2500, addr })
316
+ return call(host, method, p, obj, { timeout, addr, retried: true, deadline })
317
+ }
318
+ /** @type {any} */
319
+ let data = {}
320
+ try { data = r.text ? JSON.parse(r.text) : {} } catch { throw new Error(`${host}: bad reply`) }
321
+ // only the status: what another machine writes here would reach sessions and terminals as our own words
322
+ if (r.status < 200 || r.status >= 300) throw new Error(`${host} answered HTTP ${r.status}`)
323
+ q.upsertPeer.run(host, null, now(), 0)
324
+ if (data.records) mergeRoster(data.records)
325
+ return data
326
+ }
327
+ /** Why `host` can't be sent to. @param {string} host */
328
+ const notMember = host => removedHost(host) ? `${host} was removed from the network` : `${host} is not a known machine on this network (yet)`
329
+
330
+ /**
331
+ * Which machine answers at `addr`? Any member is accepted (including us); returns that
332
+ * member, or throws. Used for names that aren't member names: IPs, Tailscale names, configured peers.
333
+ * @param {string} addr
334
+ */
335
+ async function identify(addr, timeout = 2500) {
336
+ if (!validAddr(addr)) throw new Error(`invalid address ${addr}`)
337
+ const accept = new Map([...roster.members.values()].map(m => [m.fp, m.publicKey]))
338
+ let r
339
+ try {
340
+ r = await authedRequest({ addr, method: 'POST', path: '/v1/hello', body: JSON.stringify(helloBody()), headers: { 'x-agentlink-port': String(cfg.port) }, identity: me, accept, dks: roster.dks, timeout }, MAX_REQ)
341
+ } catch (e) { throw new Error(e instanceof PinError ? `${addr}: not a machine on this network` : `${addr}: ${errMsg(e)}`) }
342
+ const m = roster.members.get(r.peerFp)
343
+ if (!m) throw new Error(`${addr}: not a machine on this network`)
344
+ if (r.status === 200 && m.fp !== me.fp) { try { const d = JSON.parse(r.text); mergeRoster(d.records); learnPeers(d.peers) } catch {} }
345
+ return m
346
+ }
347
+
348
+ /**
349
+ * Work out which member a non-member name (IP, Tailscale name, configured peer) refers to, and
350
+ * remember it. Returns the member's host (cfg.host if it's us), or null.
351
+ * @param {string} name
352
+ */
353
+ async function resolveName(name) {
354
+ const real = peerRow(name)?.alias_of
355
+ if (real && (member(real) || real === cfg.host)) return real
356
+ const addr = addrOf(name)
357
+ let m
358
+ try { m = await identify(addr) } catch { return null }
359
+ if (m.fp === me.fp) return adoptSelfAlias(name) ? cfg.host : null
360
+ q.upsertPeer.run(name, addr, now(), 0)
361
+ q.setAlias.run(m.host, name)
362
+ if (!cfg.routes[m.host]) q.upsertPeer.run(m.host, addr, now(), 0)
363
+ log(`${name} is ${m.host}`)
364
+ return m.host
365
+ }
366
+
367
+ const probing = new Set()
368
+ /** @type {Map<string, number>} "host addr" -> when verification last failed */
369
+ const probeFailed = new Map()
370
+ /**
371
+ * Start routing member `host` to `addr`, but only once the machine there proves it holds its key.
372
+ * @param {string} host @param {string} addr @param {{ refresh?: boolean }} [opts] refresh: a fresh sighting (beacon, request)
373
+ */
374
+ async function probe(host, addr, { refresh = true } = {}) {
375
+ if (!validAddr(addr) || isSelf(host) || !member(host)) return
376
+ const key = `${host} ${addr}`
377
+ try { // nothing awaits this, so a failed write here (a full disk) must stop here too
378
+ const cur = peerRow(host)
379
+ if (cur?.addr === addr) {
380
+ if (!refresh) return
381
+ q.upsertPeer.run(host, null, now(), 0)
382
+ if (q.wake.run(host).changes) kick()
383
+ return
384
+ }
385
+ if (probing.has(key) || now() - (probeFailed.get(key) || 0) < PROBE_RETRY_MS) return
386
+ probing.add(key)
387
+ try {
388
+ const data = await call(host, 'POST', '/v1/hello', helloBody(), { addr, timeout: 2500 })
389
+ if (!cfg.routes[host]) q.upsertPeer.run(host, addr, now(), 0)
390
+ learnPeers(data.peers)
391
+ log(`${host} is at ${addr}`)
392
+ if (q.wake.run(host).changes) kick()
393
+ } catch { probeFailed.set(key, now()) } finally { probing.delete(key) }
394
+ if (probeFailed.size > 1000) probeFailed.clear()
395
+ } catch (e) { log('probe error', errMsg(e)) }
396
+ }
397
+
398
+ /** Track a member that sent us a request from `remoteIp` (its listening port in the header). @param {string} host @param {string | undefined} remoteIp @param {unknown} portHdr */
399
+ function notePeer(host, remoteIp, portHdr) {
400
+ q.upsertPeer.run(host, null, now(), 0)
401
+ const ip = String(remoteIp || '').replace(/^::ffff:/, '')
402
+ const port = Number(portHdr)
403
+ if (ip && !/^(127\.|::1$)/.test(ip) && Number.isInteger(port) && port > 0 && port < 65536) {
404
+ const addr = ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`
405
+ if (peerRow(host)?.addr !== addr && !cfg.routes[host]) probe(host, addr)
406
+ }
407
+ }
408
+
409
+ const knownPeers = () => otherMembers().map(m => m.host)
410
+
411
+ /** @type {Map<string, Set<string>>} member -> addresses other members have told us about */
412
+ const heard = new Map()
413
+ /**
414
+ * Other places `host` might be when its stored address stops answering: what it was configured or
415
+ * first found under (a Tailscale name, an IP), what other members told us, and its own name. A
416
+ * laptop that moves between networks keeps an address that no beacon will ever replace.
417
+ * @param {string} host
418
+ */
419
+ function otherAddrs(host) {
420
+ const cur = addrOf(host)
421
+ const names = allPeers().filter(r => r.alias_of === host).map(r => addrOf(r.host))
422
+ // what we were told comes last, newest first: any member can say anything, and can't crowd out the rest
423
+ return [...new Set([...names, `${host}:${cfg.port}`, ...[...(heard.get(host) || [])].reverse()])].filter(a => a !== cur && validAddr(a))
424
+ }
425
+
426
+ /** Addresses worth telling other members about (so every node learns the whole network). */
427
+ function shareablePeers() {
428
+ const cutoff = now() - 86400_000
429
+ return allPeers().filter(r => member(r.host) && r.last_seen >= cutoff && r.addr).map(r => ({ host: r.host, addr: r.addr }))
430
+ }
431
+ /**
432
+ * Addresses another member told us about. They may be stale (DHCP reuse) or wrong, so they are
433
+ * never used directly: each is probed, and only used once the handshake there presents the key.
434
+ * @param {unknown} list
435
+ */
436
+ function learnPeers(list) {
437
+ for (const pe of Array.isArray(list) ? list.slice(0, 200) : []) {
438
+ if (typeof pe?.host !== 'string' || !validAddr(pe?.addr)) continue
439
+ let h
440
+ try { h = normHost(pe.host) } catch { continue }
441
+ if (!member(h) || cfg.routes[h]) continue
442
+ if (!peerRow(h)?.addr) probe(h, pe.addr, { refresh: false })
443
+ else if (pe.addr !== peerRow(h)?.addr) {
444
+ if (!heard.has(h)) heard.set(h, new Set())
445
+ const known = /** @type {Set<string>} */ (heard.get(h))
446
+ known.delete(pe.addr)
447
+ known.add(pe.addr) // newest last; the oldest makes way
448
+ if (known.size > 8) known.delete(/** @type {string} */ (known.values().next().value))
449
+ }
450
+ }
451
+ }
452
+
453
+ /** @param {string} host */
454
+ async function hello(host) {
455
+ try {
456
+ const r = await call(host, 'POST', '/v1/hello', helloBody(), { timeout: 2500 })
457
+ learnPeers(r.peers)
458
+ if (q.wake.run(host).changes) kick() // it's reachable: retry anything waiting for it now
459
+ } catch {
460
+ // nothing answers where we think it is: look for it. Each address must prove it holds the key.
461
+ if (!cfg.routes[host]) await Promise.all(otherAddrs(host).slice(0, 4).map(addr => probe(host, addr)))
462
+ }
463
+ }
464
+ /** @type {Promise<void> | null} */
465
+ let helloing = null
466
+ /** Say hello to everyone: finished when the round in progress, or a new one, is. */
467
+ const helloAll = () => helloing ??= helloRound().finally(() => { helloing = null })
468
+ async function helloRound() {
469
+ try {
470
+ // configured names that aren't member names (an IP, a Tailscale name): find out who they are
471
+ for (const pe of cfg.peers) if (!member(pe.host) && !isSelf(pe.host) && !peerRow(pe.host)?.alias_of) await resolveName(pe.host)
472
+ const hosts = knownPeers()
473
+ let i = 0
474
+ await Promise.all(Array.from({ length: Math.min(HELLO_CONCURRENCY, hosts.length) }, async () => {
475
+ while (i < hosts.length) await hello(hosts[i++])
476
+ }))
477
+ } catch (e) { log('hello error', errMsg(e)) }
478
+ }
479
+
480
+ // ---------- messages ----------
481
+ /** @type {(r: MessageRow) => Envelope} */
482
+ const envOf = r => ({ id: r.id, from: r.from_addr, to: r.to_addr, body: r.body, sent_at: r.sent_at, expires_at: r.expires_at, sig: r.sig })
483
+
484
+ /**
485
+ * Is this a well-formed envelope signed by the machine it claims to come from? 'unknown-sender'
486
+ * if it names a machine we haven't heard of (yet): it may have joined while we were offline.
487
+ * @param {any} e @returns {'ok' | 'unknown-sender' | 'bad'}
488
+ */
489
+ function checkEnv(e) {
490
+ if (!(e && typeof e === 'object' &&
491
+ ['id', 'from', 'to', 'body', 'sig'].every(k => typeof e[k] === 'string') &&
492
+ /^[\w-]{1,64}$/.test(e.id) && e.from.length <= 320 && e.to.length <= 320 &&
493
+ // text that wouldn't survive the store unchanged would fail its signature once re-read
494
+ e.body.isWellFormed() && !e.body.includes('\0') &&
495
+ // safe, bounded integers: anything else can't be stored and read back reliably
496
+ Number.isSafeInteger(e.sent_at) && Number.isSafeInteger(e.expires_at) && e.sent_at >= 0 &&
497
+ e.expires_at - now() <= MAX_TTL_MS + CLOCK_SLACK_MS && e.sent_at - now() <= MAX_TTL_MS &&
498
+ Buffer.byteLength(e.body) <= MAX_MSG)) return 'bad'
499
+ // addresses exactly as a node writes them: only the host is proved below, so whatever else `from`
500
+ // held (a line break, a NUL) could otherwise pass for another sender when it's shown to a session
501
+ const from = parseAddr(e.from, ''), to = parseAddr(e.to, '')
502
+ if (e.from !== `${from.name}@${from.host}` || e.to !== `${to.name}@${to.host}`) return 'bad'
503
+ const host = from.host
504
+ const sender = roster.byHost.get(host)
505
+ if (!sender) {
506
+ const gone = removedKey(host), key = gone && roster.ranked.get(gone)?.publicKey
507
+ // signed by another key, it may be that name's machine back with a new one we haven't heard of.
508
+ // So may a key that was dropped, which we can't check: its holder tries again, and tells us what it knows
509
+ return key && verifyEnvelope(key, e) ? 'bad' : 'unknown-sender'
510
+ }
511
+ return verifyEnvelope(sender.publicKey, e) ? 'ok' : 'bad'
512
+ }
513
+ /** The key of a machine called `host` that was removed, or whose add never counted (it never will). @param {string} host */
514
+ const removedKey = host => {
515
+ const fp = [...roster.records].reverse().find(r => r.type === 'add' && r.host === host)?.fp ?? [...roster.ranked.values()].reverse().find(m => m.host === host)?.fp
516
+ return fp && (roster.removed.has(fp) || roster.dropped.has(fp)) ? fp : null
517
+ }
518
+ /** @param {string} host */
519
+ const removedHost = host => removedKey(host) !== null
520
+ /**
521
+ * Store a row unless one with that id exists. An existing id is only fine for the same message:
522
+ * a different message reusing an id someone has seen is refused, not reported as delivered. (The
523
+ * same message, not the same signature: a copy can carry another encoding of it, and the envelope
524
+ * has been verified by now. Nothing keeps the text of what it has passed on.)
525
+ * @param {Envelope} e @param {string} toHost @param {string} toName @param {'inbox' | 'held'} role @param {'queued' | 'pending'} state
526
+ */
527
+ function store(e, toHost, toName, role, state) {
528
+ if (q.insert.run(e.id, e.from, e.to, toHost, toName, e.body, e.sent_at, e.expires_at, e.sig, role, state, 0, now()).changes) return true
529
+ const r = msgRow(e.id)
530
+ return !!r && r.from_addr === e.from && r.to_addr === e.to && r.sent_at === e.sent_at && r.expires_at === e.expires_at && (r.body === e.body || r.state === 'forwarded')
531
+ }
532
+
533
+ /**
534
+ * Is there no room for another message to this session? Whatever sends too much, a script gone wrong
535
+ * or agents stuck in a loop, is held back at the sender, and everything else keeps moving. The limit
536
+ * on the store as a whole is for what no one recipient's limit sees: any number of made-up names, or
537
+ * messages read as fast as they come. Nothing is lost by this: the sender keeps trying until the
538
+ * message expires, and a session here is told at once.
539
+ * @param {string} host @param {string} name
540
+ */
541
+ const full = (host, name) => Number(q.waiting.get(host, name)?.n) >= cfg.maxWaiting || Number(q.stored.get()?.n) >= cfg.maxStoreBytes
542
+
543
+ /**
544
+ * Store an incoming envelope durably. Returns 'live' | 'queued' | 'held' | 'expired' |
545
+ * 'full' (not stored: no room for now, and the sender should retry) |
546
+ * 'unknown-sender' (not stored: the sender should tell us its membership records, then retry) |
547
+ * null (rejected: the envelope itself is bad, and the sender must not retry it). Storage errors (disk full,
548
+ * database busy) throw, so callers can report a temporary failure and the sender retries.
549
+ * @param {unknown} e
550
+ * @param {Set<string>} [toPump] collect local recipients here instead of pumping each one now
551
+ * @param {string} [mine] the machine delivering this says its name for the recipient's machine (a
552
+ * nickname of its own, which we may not know) means us. Believed only from the message's own
553
+ * sender: a holder could otherwise turn a copy addressed elsewhere into a message for a session here.
554
+ * @returns {import('./types.js').AcceptResult}
555
+ */
556
+ function accept(e, toPump, mine) {
557
+ let to
558
+ /** @type {Envelope} */
559
+ let env
560
+ try {
561
+ const v = checkEnv(e)
562
+ if (v === 'unknown-sender') return v
563
+ if (v !== 'ok') return null
564
+ env = /** @type {Envelope} */ (e)
565
+ to = parseAddr(env.to, cfg.host)
566
+ } catch (err) {
567
+ log('rejected envelope:', errMsg(err))
568
+ return null
569
+ }
570
+ if (env.expires_at <= now()) return 'expired'
571
+ if (isSelf(to.host) || (mine && mine === parseAddr(env.from, '').host && !member(to.host) && !removedHost(to.host))) {
572
+ if (full(cfg.host, to.name) && !msgRow(env.id)) return 'full'
573
+ if (!store(env, cfg.host, to.name, 'inbox', 'queued')) return null
574
+ q.claim.run(cfg.host, to.name, now(), env.id)
575
+ const s = sessions.get(to.name)
576
+ const live = s?.live || s?.waiter // before pumping: a wait ends once it has woken the session
577
+ if (toPump) toPump.add(to.name)
578
+ else pump(to.name)
579
+ return live ? 'live' : 'queued'
580
+ }
581
+ if (full(to.host, to.name) && !msgRow(env.id)) return 'full'
582
+ if (!store(env, to.host, to.name, 'held', 'pending')) return null
583
+ kick()
584
+ return 'held'
585
+ }
586
+
587
+ /**
588
+ * Offer `name`'s queued messages to its live session, if it has one. A few at a time: the session's
589
+ * acknowledgements bring the next ones, so a big queue is never in memory here or in the session.
590
+ * @param {string} name
591
+ */
592
+ function pump(name) {
593
+ const s = sessions.get(name)
594
+ if (!s) return
595
+ let more = false
596
+ for (const { id } of q.inboxQueued.all(name, now(), 2 * PUMP_WINDOW)) { // ids first: most may have been offered already
597
+ if (s.sent.has(String(id))) continue
598
+ if (s.sent.size >= PUMP_WINDOW || s.res.writableLength > PUMP_BUFFER) { more = true; break }
599
+ const r = msgRow(String(id))
600
+ if (!r) continue
601
+ s.sent.add(r.id)
602
+ sse(s.res, 'message', { id: r.id, from: r.from_addr, to: r.to_addr, body: r.body, sent_at: r.sent_at })
603
+ }
604
+ if (s.res.writableLength > PUMP_BUFFER && !s.draining) {
605
+ s.draining = true
606
+ s.res.once('drain', () => { s.draining = false; pump(name) })
607
+ }
608
+ if (more !== s.more) { s.more = more; sse(s.res, 'queue', { more }) }
609
+ nudge(name)
610
+ }
611
+
612
+ /** Wake `name`'s session if a wait is open and it has messages it hasn't been told about. @param {string} name */
613
+ function nudge(name) {
614
+ const s = sessions.get(name)
615
+ if (!s?.waiter) return
616
+ const fresh = [...s.sent].filter(id => !s.notified.has(id))
617
+ if (!fresh.length) return
618
+ for (const id of fresh) s.notified.add(id)
619
+ sse(s.waiter, 'wake', { name, count: fresh.length })
620
+ s.waiter.end()
621
+ s.waiter = null
622
+ }
623
+
624
+ /**
625
+ * Rows still to hand over, in request-sized batches, read as they're needed: a machine that doesn't
626
+ * answer costs one batch, however much is waiting for it.
627
+ * @param {DueRow[]} rows @returns {Generator<MessageRow[]>}
628
+ */
629
+ function* batches(rows) {
630
+ /** @type {MessageRow[]} */
631
+ let cur = []
632
+ let size = 0
633
+ for (const { id } of rows) {
634
+ const r = msgRow(id)
635
+ if (r?.state !== 'pending') continue
636
+ const n = Buffer.byteLength(JSON.stringify(envOf(r))) + 1
637
+ if (cur.length && size + n > MAX_BATCH) { yield cur; cur = []; size = 0 }
638
+ cur.push(r)
639
+ size += n
640
+ }
641
+ if (cur.length) yield cur
642
+ }
643
+
644
+ /**
645
+ * Hand rows to member `host`'s node. Returns null on success, else the error.
646
+ * @param {string} host
647
+ * @param {DueRow[]} rows
648
+ * @returns {Promise<null | { err: unknown, rest: DueRow[] }>}
649
+ */
650
+ async function handOver(host, rows) {
651
+ for (const batch of batches(rows)) {
652
+ try {
653
+ // addressed under a name only we use for this machine: tell it they're for it
654
+ const res = await call(host, 'POST', '/v1/deliver', { messages: batch.map(envOf), mine: batch.filter(r => r.role === 'outbox' && r.to_host !== host).map(r => r.id) })
655
+ const results = res.results || {}
656
+ const unknown = new Set(Array.isArray(res.unknown) ? res.unknown : [])
657
+ const noRoom = new Set(Array.isArray(res.full) ? res.full : [])
658
+ let tell = false
659
+ tx(() => {
660
+ for (const r of batch) {
661
+ if (results[r.id]) q.forwarded.run(now(), r.id)
662
+ // it hasn't heard of the sending machine yet: tell it what we know, then try again later
663
+ else if (unknown.has(r.id)) { q.retry.run(now() + backoff(r.attempts), `${host} doesn't know the sender yet`, now(), r.id); tell ||= r.attempts < 3 }
664
+ else if (noRoom.has(r.id)) q.retry.run(now() + backoff(r.attempts), `no room on ${host} for now`, now(), r.id)
665
+ else q.failed.run(`rejected by ${host} (invalid or malformed message)`, now(), r.id)
666
+ }
667
+ })
668
+ if (tell) setImmediate(() => hello(host))
669
+ } catch (err) {
670
+ const rest = rows.slice(rows.findIndex(r => r.id === batch[0].id))
671
+ tx(() => { for (const r of rest) q.retry.run(now() + backoff(r.attempts), errMsg(err), now(), r.id) })
672
+ return { err, rest }
673
+ }
674
+ }
675
+ return null
676
+ }
677
+
678
+ /**
679
+ * Which member should messages for `host` go to? A member name is used as is; any other name
680
+ * is resolved by asking who answers at its address. 'self' if it turns out to be this machine.
681
+ * @param {string} host @returns {Promise<string | 'self' | null>}
682
+ */
683
+ async function routeFor(host) {
684
+ if (isSelf(host)) return 'self'
685
+ if (member(host)) return host
686
+ const alias = peerRow(host)?.alias_of
687
+ if (alias && member(alias)) return alias
688
+ const r = await resolveName(host)
689
+ return r === cfg.host ? 'self' : r
690
+ }
691
+
692
+ // ---------- forwarding loop ----------
693
+ /** @type {Set<string>} sends being handed over directly: not for the flush loop too */
694
+ const inFlight = new Set()
695
+ /** @type {Set<string>} names being delivered to now: a machine that's slow to answer holds up only itself */
696
+ const busy = new Set()
697
+ let delivering = 0, lookingUp = 0
698
+ let kicked = false
699
+ /** Flush soon: once, however many messages came in together. */
700
+ const kick = () => { if (!kicked) { kicked = true; setImmediate(() => { kicked = false; flush() }) } }
701
+
702
+ function flush() {
703
+ try {
704
+ const t = now()
705
+ q.expire.run(t, t)
706
+ // every name we can put a machine to
707
+ const known = [cfg.host, ...selfAliases, ...knownPeers(), ...allPeers().filter(p => member(p.alias_of || '')).map(p => p.host)]
708
+ for (const row of q.due.all(t, t, JSON.stringify(known.filter(h => !busy.has(h))))) {
709
+ if (delivering >= FLUSH_HOSTS) break
710
+ const host = String(row.to_host)
711
+ const rows = dueRows(q.dueFor.all(t, t, host)).filter(r => !inFlight.has(r.id))
712
+ if (!rows.length) continue
713
+ delivering++
714
+ busy.add(host)
715
+ deliver(host, rows).catch(e => log('flush error', errMsg(e))).finally(() => { busy.delete(host); delivering-- })
716
+ }
717
+ // A name nothing has identified yet costs a hello and a name lookup, and any member can queue
718
+ // thousands of them: a few at a time, off to the side of what's going to machines we know
719
+ if (lookingUp >= HELLO_CONCURRENCY) return
720
+ for (const row of q.dueNames.all(t, t, JSON.stringify([...known, ...busy]), HELLO_CONCURRENCY - lookingUp)) {
721
+ const host = String(row.to_host)
722
+ lookingUp++
723
+ busy.add(host)
724
+ deliver(host, dueRows(q.dueFor.all(t, t, host)).filter(r => !inFlight.has(r.id))).catch(e => log('flush error', errMsg(e))).finally(() => { busy.delete(host); lookingUp-- })
725
+ }
726
+ } catch (e) { log('flush error', errMsg(e)) }
727
+ }
728
+
729
+ /** @param {string} host @param {DueRow[]} rows */
730
+ async function deliver(host, rows) {
731
+ const dest = await routeFor(host)
732
+ if (dest === 'self') { if (isSelf(host)) adoptSelfAlias(host); return }
733
+ if (!dest) {
734
+ const why = notMember(host)
735
+ tx(() => { for (const r of rows) q.retry.run(now() + backoff(r.attempts), why, now(), r.id) })
736
+ return
737
+ }
738
+ const failure = await handOver(dest, rows)
739
+ // in the background: trying several (possibly offline) holders must not stall deliveries
740
+ if (failure) replicate(dest, failure.rest.filter(r => r.role === 'outbox' && !r.replicated))
741
+ }
742
+
743
+ /** Other recently seen members that could hold copies for `host` while it is offline. @param {string} host */
744
+ function holderCandidates(host) {
745
+ const cutoff = now() - 10 * 60_000
746
+ return allPeers()
747
+ .filter(p => member(p.host) && p.host !== host && p.last_seen >= cutoff)
748
+ .sort((a, b) => b.last_seen - a.last_seen)
749
+ }
750
+
751
+ /** @type {Set<string>} hosts with a replication in progress */
752
+ const replicating = new Set()
753
+ /** Hand copies to other reachable members so they can deliver when `host` comes back. @param {string} host @param {DueRow[]} rows */
754
+ async function replicate(host, rows) {
755
+ if (!rows.length || replicating.has(host)) return
756
+ replicating.add(host)
757
+ try { await replicateInner(host, rows) } catch (e) { log('replicate error', errMsg(e)) } finally { replicating.delete(host) }
758
+ }
759
+ /** @param {string} host @param {DueRow[]} rows */
760
+ async function replicateInner(host, rows) {
761
+ /** @type {Map<string, number>} message -> how many holders stored it */
762
+ const copies = new Map()
763
+ let holders = 0
764
+ for (const p of holderCandidates(host)) {
765
+ if (holders >= REPLICATE_TO) break
766
+ try {
767
+ let stored = 0
768
+ for (const batch of batches(rows)) {
769
+ const { results } = await call(p.host, 'POST', '/v1/deliver', { messages: batch.map(envOf) }, { timeout: 3000 })
770
+ for (const r of batch) if (results?.[r.id]) { copies.set(r.id, (copies.get(r.id) || 0) + 1); stored++ }
771
+ }
772
+ if (stored) holders++
773
+ } catch {}
774
+ }
775
+ for (const id of copies.keys()) q.replicated.run(id)
776
+ }
777
+
778
+ function gc() {
779
+ try {
780
+ q.gc.run(now() - 86400_000)
781
+ q.forgetPeers.run(now() - PEER_FORGET_MS)
782
+ q.gcInvites.run(now() - 86400_000)
783
+ } catch (e) { log('gc error', errMsg(e)) }
784
+ trimLog()
785
+ }
786
+ /** Keep node.log from growing without end. It's opened for appending, so writers carry on at the new end. */
787
+ function trimLog() {
788
+ const file = path.join(cfg.dir, 'node.log')
789
+ try {
790
+ const { size } = fs.statSync(file)
791
+ if (size <= MAX_LOG) return
792
+ const fd = fs.openSync(file, 'r')
793
+ const tail = Buffer.alloc(1 << 20)
794
+ try { fs.readSync(fd, tail, 0, tail.length, size - tail.length) } finally { fs.closeSync(fd) }
795
+ fs.truncateSync(file, 0)
796
+ fs.appendFileSync(file, tail.subarray(tail.indexOf(10) + 1))
797
+ } catch {}
798
+ }
799
+
800
+ // ---------- local API helpers ----------
801
+ /** @param {{ from?: unknown, to?: unknown, body?: unknown, ttl_ms?: unknown }} d */
802
+ async function localSend(d) {
803
+ if (typeof d.body !== 'string' || !d.body) throw httpErr(400, 'message body required')
804
+ const body = /** @type {any} */ (d.body).toWellFormed().replaceAll('\0', '\uFFFD') // what the store can give back unchanged
805
+ if (Buffer.byteLength(body) > MAX_MSG) throw httpErr(413, `message too large (max ${MAX_MSG} bytes)`)
806
+ if (!d.to) throw httpErr(400, 'recipient required (name@host)')
807
+ let to
808
+ try { to = parseAddr(d.to, cfg.host) } catch (e) { throw httpErr(400, `bad recipient "${d.to}": ${errMsg(e)}`) }
809
+ // address it to the machine's member name if we can tell (e.g. desk.tailnet.ts.net -> desk.local)
810
+ if (!isSelf(to.host) && !member(to.host)) {
811
+ const r = await routeFor(to.host).catch(() => null)
812
+ if (r === 'self') to.host = cfg.host
813
+ else if (r) to.host = r
814
+ }
815
+ if (isSelf(to.host)) to.host = cfg.host
816
+ // not a machine on this network (a typo, or a machine that hasn't joined): say so now rather
817
+ // than queue it for days. Names configured as peers (a Tailscale name, an IP) are queued: the
818
+ // machine may just be offline, and is found when it answers.
819
+ if (to.host !== cfg.host && !member(to.host) && removedHost(to.host)) throw httpErr(410, `${to.host} was removed from the network`)
820
+ if (to.host !== cfg.host && !member(to.host) && !peerRow(to.host)?.configured) {
821
+ const known = [cfg.host, ...knownPeers()].join(', ')
822
+ throw httpErr(404, `${to.host} is not a machine on this network, and nothing at that name answered as one (machines: ${known}). If it is new, run \`natter invite\` and join it first.`)
823
+ }
824
+ if (full(to.host, to.name)) throw fullError(`${to.name}@${to.host}`)
825
+ /** @type {Omit<Envelope, 'sig'>} */
826
+ const unsigned = {
827
+ id: crypto.randomUUID(),
828
+ from: `${sanitizeName(d.from || 'cli')}@${cfg.host}`,
829
+ to: `${to.name}@${to.host}`,
830
+ body,
831
+ sent_at: now(),
832
+ expires_at: now() + (Number(d.ttl_ms) > 0 ? Math.min(Number(d.ttl_ms), cfg.ttlMs) : cfg.ttlMs),
833
+ }
834
+ /** @type {Envelope} */
835
+ const e = { ...unsigned, sig: signEnvelope(me.key, unsigned) }
836
+ if (to.host === cfg.host) {
837
+ let st
838
+ try { st = accept(e) } catch (err) { throw httpErr(503, `could not queue message: ${errMsg(err)}`) }
839
+ if (st === 'unknown-sender') throw httpErr(409, `this machine (${cfg.host}) isn't on its member list, so nothing was sent: run \`natter setup\``)
840
+ if (st === 'expired') throw httpErr(400, 'the message expired before it could be queued')
841
+ if (st === 'full') throw fullError(e.to)
842
+ if (!st) throw httpErr(500, 'could not queue message')
843
+ return { id: e.id, to: e.to, status: `${d.to !== e.to && String(d.to).includes('@') ? `${parseAddr(d.to, cfg.host).host} is this machine — ` : ''}${localStatus(to.name, st === 'live')}` }
844
+ }
845
+ // hidden from the flush loop while we try the direct hand-off, so it isn't sent twice
846
+ inFlight.add(e.id)
847
+ q.insert.run(e.id, e.from, e.to, to.host, to.name, e.body, e.sent_at, e.expires_at, e.sig, 'outbox', 'pending', now() + IN_FLIGHT_MS, now())
848
+ let remote = null, copies = false, refused = false
849
+ try {
850
+ if (!member(to.host)) throw new Error(notMember(to.host))
851
+ /** @type {NodeJS.Timeout | undefined} */
852
+ let wait
853
+ const res = await Promise.race([
854
+ call(to.host, 'POST', '/v1/deliver', { messages: [e] }),
855
+ new Promise((_, reject) => { wait = setTimeout(() => reject(new Error('no answer yet')), SEND_WAIT_MS) }),
856
+ ]).finally(() => clearTimeout(wait))
857
+ remote = res.results?.[e.id]
858
+ if (remote) q.forwarded.run(now(), e.id)
859
+ else if (Array.isArray(res.unknown) && res.unknown.includes(e.id)) throw new Error(`${to.host} doesn't know this machine yet`)
860
+ else if (Array.isArray(res.full) && res.full.includes(e.id)) { refused = true; q.retry.run(now() + backoff(0), `no room on ${to.host} for now`, now(), e.id) }
861
+ else q.failed.run(`rejected by ${to.host} (invalid or malformed message)`, now(), e.id)
862
+ } catch (err) {
863
+ q.retry.run(now() + backoff(0), errMsg(err), now(), e.id)
864
+ const row = msgRow(e.id)
865
+ // the message is stored durably now: answer the client and hand out copies in the background
866
+ // (trying several offline candidates in turn could otherwise outlast the client's timeout)
867
+ if (row?.state === 'pending' && member(to.host) && holderCandidates(to.host).length) { copies = true; replicate(to.host, [row]) }
868
+ } finally { inFlight.delete(e.id) }
869
+ const r = msgRow(e.id)
870
+ if (!r) throw httpErr(500, 'message vanished from the store')
871
+ let status
872
+ if (r.role === 'inbox') status = `${to.host} is this machine — ${localStatus(to.name)}`
873
+ else if (r.state === 'forwarded') status = remote === 'live' ? `pushed to ${e.to}'s live session` : `stored on ${to.host}; ${to.name} will get it when it next connects or checks its inbox`
874
+ else if (r.state === 'failed') status = `FAILED: ${r.last_error}`
875
+ else if (refused) status = `${to.host} has no room for more messages to ${to.name} for now; queued here — will deliver once it has`
876
+ else status = `${to.host} unreachable (${r.last_error}); queued — will deliver when it comes back` + (copies ? ' (also offering copies to other online machines)' : '')
877
+ return { id: e.id, to: e.to, status }
878
+ }
879
+ const rosterStatus = () => {
880
+ const bytes = Buffer.byteLength(JSON.stringify(roster.records))
881
+ return { bytes, limit: MAX_ROSTER_BYTES, ...(bytes > MAX_ROSTER_BYTES * 0.75 ? { note: 'the member list is nearly full: remove any machine flooding it, then run `natter compact` on the founding machine' } : {}) }
882
+ }
883
+ /** @param {string} to */
884
+ const fullError = to => httpErr(429, `not sent: too many messages are waiting for ${to}, or this machine's message store is full. Try again once they've been read`)
885
+ /** @param {string} name @param {boolean} [live] it was just told (its wait, having woken it, is over) */
886
+ function localStatus(name, live) {
887
+ const s = sessions.get(name)
888
+ return live || s?.live || s?.waiter ? 'pushed to its live session' : s ? `waiting in ${name}'s inbox (the session is connected but reads with the inbox tool)` : `queued until ${name} connects`
889
+ }
890
+
891
+ /** @type {Map<string, string>} host -> why it was last unreachable ('' once reached) */
892
+ const reach = new Map()
893
+ /** @type {Map<string, number>} host -> when it last answered */
894
+ const reachedAt = new Map()
895
+ /**
896
+ * Log when a machine becomes unreachable, or reachable again (not every attempt).
897
+ * @param {string} host @param {string | null} reason null: reached
898
+ */
899
+ function noteReach(host, reason) {
900
+ if (reason === null) reachedAt.set(host, now())
901
+ const prev = reach.get(host)
902
+ if (reason === null) {
903
+ if (prev) log(`${host} is reachable again`)
904
+ reach.set(host, '')
905
+ } else if (prev !== reason) {
906
+ log(`cannot reach ${host}: ${reason}`)
907
+ reach.set(host, reason)
908
+ }
909
+ }
910
+
911
+ async function localPeers() {
912
+ const remote = await Promise.all(knownPeers().map(async h => {
913
+ try {
914
+ const r = await call(h, 'GET', '/v1/sessions', undefined, { timeout: 1500, deadline: PEERS_WAIT_MS })
915
+ return { host: h, online: true, sessions: sessionNames(r.sessions) }
916
+ } catch (e) { return { host: h, online: false, sessions: [], error: /** @type {any} */ (e).reason || errMsg(e) } }
917
+ }))
918
+ return { self: cfg.host, local: [...sessions.keys()], remote }
919
+ }
920
+
921
+ const hostOfFp = (/** @type {string} */ fp) => roster.members.get(fp)?.host ||
922
+ roster.ranked.get(fp)?.host || [...roster.records].reverse().find(r => r.type === 'add' && r.fp === fp)?.host || shortFp(fp)
923
+ function members() {
924
+ // in joining order: each machine can remove the ones listed after it
925
+ return [...roster.members.values()].sort((a, b) => a.rank - b.rank).map(m => ({
926
+ host: m.host, fp: shortFp(m.fp), added_by: m.by === m.fp ? '(founded the network)' : hostOfFp(m.by), added_at: m.at, self: m.fp === me.fp,
927
+ }))
928
+ }
929
+
930
+ /** @param {string} token */
931
+ function inviteValid(token) {
932
+ const row = /** @type {{ expires_at: number, used_at: number | null } | undefined} */ (q.invite.get(sha256(token)))
933
+ return !!row && !row.used_at && row.expires_at > now()
934
+ }
935
+
936
+ /**
937
+ * A machine joining with an invite token: add it to the roster (signed by us).
938
+ * @param {{ token?: unknown, host?: unknown, cert?: unknown }} d
939
+ */
940
+ function join(d) {
941
+ if (typeof d.token !== 'string' || !inviteValid(d.token)) throw httpErr(403, 'this invite is not valid (already used, expired or revoked): run `natter invite` on a member for a new one')
942
+ let pem, host
943
+ try { pem = new crypto.X509Certificate(Buffer.from(String(d.cert), 'base64')).toString(); host = normHost(d.host) } catch { throw httpErr(400, 'bad join request') }
944
+ if (!machineName(host)) throw httpErr(400, `"${host}" can't identify a machine: join under a name, by ending the invite line with \`| sh -s -- --host <name>\``)
945
+ const fp = fingerprint(pem)
946
+ const cur = roster.members.get(fp)
947
+ if (cur && cur.host !== host) throw httpErr(409, `this machine is already a member as ${cur.host}. To rename it, run \`natter remove ${cur.host}\` on a machine that joined before it, then run a new invite line with \`| sh -s -- --host ${host}\` at the end`)
948
+ if (!cur) {
949
+ // 410: setup answers it by joining again with a new key (a machine isn't told it was removed)
950
+ if (roster.removed.has(fp)) throw httpErr(410, 'this machine\'s key was removed from the network')
951
+ if (roster.byHost.has(host)) throw httpErr(409, `${host} is already a member of this network with a different key. On a member, run \`natter remove ${host}\` first, or join under another name by ending the invite line with \`| sh -s -- --host <name>\``)
952
+ try { roster.add(me, host, pem) } catch (e) { throw httpErr(507, errMsg(e)) }
953
+ if (!roster.members.has(fp)) throw httpErr(500, 'could not add this machine')
954
+ roster.save()
955
+ log(`${host} joined the network (key ${shortFp(fp)})`)
956
+ }
957
+ q.useInvite.run(now(), sha256(d.token))
958
+ setImmediate(rotate) // after this reply: the last open invite's key goes once it's used
959
+ setImmediate(helloAll)
960
+ return { net: roster.net, records: roster.records, peers: shareablePeers(), host: cfg.host }
961
+ }
962
+
963
+ /**
964
+ * The add records for `fp`, then for whoever signed that, and so on (at most a few), ending at a
965
+ * current member: what an unknown client may submit before it has proved who it is.
966
+ * @param {unknown} records @param {string} fp
967
+ */
968
+ function joinChain(records, fp) {
969
+ /** @type {Map<unknown, any>} */
970
+ const adds = new Map()
971
+ for (const r of Array.isArray(records) ? records : []) if (r?.type === 'add' && !adds.has(r.fp)) adds.set(r.fp, r)
972
+ const out = []
973
+ for (let want = fp; out.length < MAX_JOIN_CHAIN && !roster.members.has(want);) {
974
+ if (roster.removed.has(want) || roster.dropped.has(want)) return [] // a chain through a removed machine counts for nothing
975
+ const r = adds.get(want)
976
+ if (!r || typeof r.by !== 'string' || r.by === want) break
977
+ out.push(r)
978
+ want = r.by
979
+ }
980
+ return out.length && roster.members.has(String(out[out.length - 1].by)) ? out : []
981
+ }
982
+
983
+ // ---------- HTTPS ----------
984
+ /** @type {WeakSet<import('node:net').Socket>} connections whose client passed the gate */
985
+ const gated = new WeakSet()
986
+ /** @type {WeakMap<import('node:net').Socket, string>} connection -> the client's proven key */
987
+ const authed = new WeakMap()
988
+ /** @param {Res} res @param {number} status @param {unknown} obj */
989
+ function reply(res, status, obj) {
990
+ res.writeHead(status, { 'content-type': 'application/json' })
991
+ res.end(JSON.stringify(obj))
992
+ }
993
+
994
+ let errorsLogged = 0, errorMinute = 0
995
+ onRequest = (req, res) => handle(req, res).catch(err => {
996
+ if (!res.headersSent) reply(res, err.status || 500, { error: err.status ? err.message : 'internal error' })
997
+ else res.destroy()
998
+ if (err.status) return
999
+ if (now() - errorMinute > 60_000) { errorMinute = now(); errorsLogged = 0 }
1000
+ if (++errorsLogged <= LOG_ERRORS_PER_MIN) log('request error', req.method, (req.url || '').split('?')[0], errMsg(err)) // never log the query: it can hold an invite token
1001
+ })
1002
+
1003
+ /** @param {http.IncomingMessage} req @param {Res} res */
1004
+ async function handle(req, res) {
1005
+ let u
1006
+ try { u = new URL(req.url || '/', 'https://x') } catch { return reply(res, 404, { error: 'not found' }) } // like any unknown path
1007
+ const p = u.pathname
1008
+ const sock = /** @type {import('node:tls').TLSSocket} */ (req.socket)
1009
+ // ---- identity, step 1 (the gate): a client that holds a discovery key learns who we are ----
1010
+ // Anyone else gets the same 404 as for any unknown path.
1011
+ if (p === '/v1/auth') {
1012
+ const ekm = sock.getProtocol() === 'TLSv1.3' ? exporterOf(sock) : null
1013
+ if (!ekm || !gateOk(roster.gateDks(), ekm, req.headers['x-agentlink-gate'])) return reply(res, 404, { error: 'not found' })
1014
+ gated.add(sock)
1015
+ return reply(res, 200, { fp: me.fp, sig: proveAuth(me.key, 'server', ekm), proto: PROTO })
1016
+ }
1017
+ // ---- joining (an invite token is the credential) ----
1018
+ if (req.method === 'GET' && (p === '/join/install' || p === '/join/pkg.tgz' || p === '/join/app.zip')) {
1019
+ const t = u.searchParams.get('t')
1020
+ if (!t || !inviteValid(t)) return reply(res, 404, { error: 'this invite is not valid (already used, expired or revoked): run `natter invite` on a member for a new one' })
1021
+ if (p === '/join/install') {
1022
+ res.writeHead(200, { 'content-type': 'text/x-shellscript' })
1023
+ return res.end(installScript(req.headers.host, cfg, t, invite?.fp || ''))
1024
+ }
1025
+ const bundle = appBundle()
1026
+ if (p === '/join/app.zip' && !bundle) return reply(res, 404, { error: 'this machine does not run the nattermatic app' })
1027
+ let file, size
1028
+ try { file = await (p === '/join/app.zip' && bundle ? packApp(cfg.dir, bundle) : packTarball(cfg.dir)); size = (await fs.promises.stat(file)).size } catch (e) {
1029
+ log('could not pack nattermatic for an installing machine:', errMsg(e))
1030
+ return reply(res, 500, { error: 'package unavailable' })
1031
+ }
1032
+ const stream = fs.createReadStream(file)
1033
+ res.on('close', () => stream.destroy()) // an abandoned download mustn't keep the file open
1034
+ stream.on('error', () => { if (!res.headersSent) reply(res, 500, { error: 'package unavailable' }); else res.destroy() })
1035
+ stream.on('open', () => {
1036
+ res.writeHead(200, { 'content-type': p === '/join/app.zip' ? 'application/zip' : 'application/gzip', 'content-length': size })
1037
+ stream.pipe(res)
1038
+ })
1039
+ return
1040
+ }
1041
+ const joining = req.method === 'POST' && p === '/join'
1042
+ // request bodies are read only for joins (small) and for clients past the gate
1043
+ if (!joining && !gated.has(sock)) return reply(res, 404, { error: 'not found' })
1044
+ const body = req.method === 'POST' ? await readBody(req, joining ? MAX_JOIN_REQ : MAX_REQ) : ''
1045
+ /** @type {Record<string, any>} request body (untrusted: every field is checked where it's used) */
1046
+ let d = {}
1047
+ if (body) { try { d = JSON.parse(body) } catch { return reply(res, 400, { error: 'bad JSON' }) } }
1048
+ if (!d || typeof d !== 'object') d = {}
1049
+ if (joining) return reply(res, 200, join(d))
1050
+
1051
+ // ---- identity, step 2: the client signs the same connection's exporter with its machine key ----
1052
+ let clientFp = authed.get(sock) || ''
1053
+ if (!clientFp) {
1054
+ const [fp = '', sig] = String(req.headers['x-agentlink-auth'] || '').split('.')
1055
+ // a machine that has just joined proves it with the records that add it (and those adding
1056
+ // whoever added it, if they are new too). Nothing else is taken before the signature checks out.
1057
+ if (p === '/v1/hello' && fp !== me.fp && !roster.members.has(fp)) {
1058
+ // it may be in a snapshot we haven't heard of, with no add left to show: one record, and only
1059
+ // the founder can have signed it
1060
+ mergeRoster(Array.isArray(d.records) ? d.records.filter((/** @type {any} */ r) => r?.type === 'snapshot').slice(-1) : [])
1061
+ if (!roster.members.has(fp)) mergeRoster(joinChain(d.records, fp))
1062
+ }
1063
+ const pub = fp === me.fp ? myPub : roster.members.get(fp)?.publicKey
1064
+ if (pub && checkAuth(pub, 'client', exporterOf(sock), sig)) { clientFp = fp; authed.set(sock, fp) }
1065
+ }
1066
+ const local = isLoopback(sock.remoteAddress) && clientFp === me.fp // a process holding this machine's key, on this machine
1067
+
1068
+ if (p === '/health') {
1069
+ if (!local) return reply(res, 404, { error: 'not found' })
1070
+ return reply(res, 200, { ok: true, host: cfg.host, net: cfg.net, fp: me.fp, version: VERSION, proto: PROTO, pid: process.pid })
1071
+ }
1072
+
1073
+ if (p.startsWith('/local/')) {
1074
+ if (!local) return reply(res, 403, { error: 'local only' })
1075
+ return handleLocal(req, res, u, p, d)
1076
+ }
1077
+ if (!p.startsWith('/v1/')) return reply(res, 404, { error: 'not found' })
1078
+ // ---- node <-> node: the client's key must be a current member's ----
1079
+ const peer = roster.members.get(clientFp)
1080
+ if (!peer) return reply(res, 403, { error: 'not a member of this network' })
1081
+ if (peer.fp !== me.fp) notePeer(peer.host, sock.remoteAddress, req.headers['x-agentlink-port'])
1082
+
1083
+ switch (`${req.method} ${p}`) {
1084
+ case 'POST /v1/deliver': {
1085
+ /** @type {Record<string, string>} */
1086
+ const results = {}
1087
+ /** @type {string[]} */
1088
+ const unknown = []
1089
+ /** @type {string[]} */
1090
+ const noRoom = []
1091
+ /** @type {Set<string>} */
1092
+ const toPump = new Set() // offer each local recipient its new messages once, not per envelope
1093
+ const mine = new Set(Array.isArray(d.mine) ? d.mine.slice(0, 2000) : [])
1094
+ try {
1095
+ tx(() => {
1096
+ for (const e of Array.isArray(d.messages) ? d.messages.slice(0, 2000) : []) {
1097
+ const st = accept(e, toPump, mine.has(e?.id) ? peer.host : undefined)
1098
+ if (st === 'unknown-sender') unknown.push(e.id)
1099
+ else if (st === 'full') noRoom.push(e.id)
1100
+ else if (st) results[e.id] = st
1101
+ }
1102
+ })
1103
+ } catch (err) {
1104
+ // temporary storage failure: 503 makes the sender back off and retry the whole batch,
1105
+ // instead of marking messages permanently failed
1106
+ log('could not store delivered messages:', errMsg(err))
1107
+ throw httpErr(503, 'temporarily unable to store messages; retry later')
1108
+ }
1109
+ for (const n of toPump) pump(n)
1110
+ return reply(res, 200, { results, unknown, full: noRoom })
1111
+ }
1112
+ case 'POST /v1/hello': {
1113
+ if (peer.fp !== me.fp) {
1114
+ if (q.wake.run(peer.host).changes) kick()
1115
+ mergeRoster(d.records)
1116
+ learnPeers(d.peers)
1117
+ }
1118
+ return reply(res, 200, { host: cfg.host, peers: shareablePeers(), records: roster.records })
1119
+ }
1120
+ case 'GET /v1/sessions':
1121
+ return reply(res, 200, { host: cfg.host, sessions: [...sessions.keys()] })
1122
+ }
1123
+ return reply(res, 404, { error: 'not found' })
1124
+ }
1125
+
1126
+ /** @param {http.IncomingMessage} req @param {Res} res @param {URL} u @param {string} p @param {Record<string, any>} d */
1127
+ async function handleLocal(req, res, u, p, d) {
1128
+ switch (`${req.method} ${p}`) {
1129
+ case 'GET /local/stream': {
1130
+ const want = sanitizeName(u.searchParams.get('name'))
1131
+ const token = String(u.searchParams.get('token') || '')
1132
+ let name = want
1133
+ const existing = sessions.get(want)
1134
+ // a reconnecting session (same token) replaces its own stale stream; anyone else gets a new name
1135
+ const reconnecting = existing && token && existing.token === token ? existing : null
1136
+ if (reconnecting) { reconnecting.res.end(); reconnecting.waiter?.end(); sessions.delete(want) }
1137
+ for (let i = 2; sessions.has(name); i++) name = `${want.slice(0, 47 - String(i).length)}-${i}` // stays within the 48-char name limit
1138
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
1139
+ const s = {
1140
+ res, sent: new Set(), token, live: u.searchParams.get('mode') !== 'poll', owner: Number(u.searchParams.get('owner')) || 0, cwd: u.searchParams.get('cwd') || '',
1141
+ // told about already, so a session that doesn't read a message isn't woken for it again and again
1142
+ notified: reconnecting?.notified ?? new Set(), waiter: /** @type {Res | null} */ (null), more: false, draining: false,
1143
+ }
1144
+ sessions.set(name, s)
1145
+ sse(res, 'hello', { name, address: `${name}@${cfg.host}`, host: cfg.host })
1146
+ const ping = setInterval(() => res.write(': ping\n\n'), 20_000)
1147
+ res.on('close', () => { clearInterval(ping); s.waiter?.end(); if (sessions.get(name) === s) sessions.delete(name) })
1148
+ log(`session connected: ${name}`)
1149
+ pump(name)
1150
+ return
1151
+ }
1152
+ case 'GET /local/wake': { // the wait behind `natter wake`: answers once, when there's news
1153
+ const pids = String(u.searchParams.get('pids') || '').split(',').map(Number).filter(Boolean)
1154
+ // by process, nearest first: a Claude started from inside another Claude's session belongs to
1155
+ // the inner one. Codex starts every session's adapter from one shared app-server, so its waiter
1156
+ // also says which folder: the newest session there, if there are several
1157
+ const cwd = u.searchParams.get('cwd')
1158
+ const found = cwd ? [...sessions].reverse().find(([, x]) => pids.includes(x.owner) && x.cwd === cwd)
1159
+ : pids.map(pid => [...sessions].find(([, x]) => x.owner === pid)).find(Boolean)
1160
+ if (!found) return reply(res, 404, { error: 'no session' })
1161
+ const [name, s] = found
1162
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
1163
+ if (s.live) { sse(res, 'done', {}); return res.end() } // a channel session is woken by the push itself
1164
+ if (s.waiter) { sse(s.waiter, 'done', {}); s.waiter.end() }
1165
+ s.waiter = res
1166
+ const ping = setInterval(() => res.write(': ping\n\n'), 20_000)
1167
+ res.on('close', () => { clearInterval(ping); if (s.waiter === res) s.waiter = null })
1168
+ nudge(name)
1169
+ return
1170
+ }
1171
+ case 'POST /local/ack': { // state 'pushed': shown in a channel session (no read receipt)
1172
+ const s = sessions.get(d.name)
1173
+ const mark = d.state === 'pushed' ? q.pushed : q.delivered
1174
+ // a message the session has now is done with: nothing need remember it for the session's life
1175
+ tx(() => { for (const id of Array.isArray(d.ids) ? d.ids : []) { mark.run(now(), String(id)); s?.sent.delete(String(id)); s?.notified.delete(String(id)) } })
1176
+ if (s) pump(d.name)
1177
+ return reply(res, 200, { ok: true })
1178
+ }
1179
+ case 'POST /local/told': { // a session has read these; or not (a cancelled read), and it should hear of them again
1180
+ const s = sessions.get(d.name)
1181
+ for (const id of Array.isArray(d.ids) ? d.ids : []) { if (d.told) s?.notified.add(String(id)); else s?.notified.delete(String(id)) }
1182
+ if (s) nudge(d.name)
1183
+ return reply(res, 200, { ok: true })
1184
+ }
1185
+ case 'POST /local/nack': { // the session couldn't take these: offer them again
1186
+ const s = sessions.get(d.name)
1187
+ for (const id of Array.isArray(d.ids) ? d.ids : []) s?.sent.delete(String(id))
1188
+ if (s) setTimeout(() => pump(d.name), 2000)
1189
+ return reply(res, 200, { ok: true })
1190
+ }
1191
+ case 'POST /local/send':
1192
+ return reply(res, 200, await localSend(d))
1193
+ case 'GET /local/peers':
1194
+ return reply(res, 200, await localPeers())
1195
+ case 'GET /local/history': {
1196
+ const limit = Math.max(1, Math.min(Math.floor(Number(u.searchParams.get('limit'))) || 20, 200))
1197
+ const rows = q.history.all(sanitizeName(u.searchParams.get('name')), limit)
1198
+ return reply(res, 200, { messages: rows.map(r => ({ id: r.id, from: r.from_addr, body: r.body, sent_at: r.sent_at, state: r.state })) })
1199
+ }
1200
+ case 'GET /local/status':
1201
+ return reply(res, 200, {
1202
+ host: cfg.host, aliases: [...selfAliases], net: shortFp(cfg.net), fp: shortFp(me.fp), port: cfg.port, version: VERSION, pid: process.pid,
1203
+ member: roster.members.has(me.fp), removed: roster.removed.has(me.fp),
1204
+ roster: rosterStatus(),
1205
+ members: members(), sessions: [...sessions.keys()], counts: q.counts.all(), peers: allPeers(),
1206
+ pending: db.prepare(`SELECT id,to_addr,role,attempts,replicated,last_error FROM messages WHERE state='pending' ORDER BY sent_at LIMIT 20`).all(),
1207
+ })
1208
+ case 'POST /local/invite': {
1209
+ if (!roster.members.has(me.fp)) throw httpErr(409, 'this machine is not a member of its network, so it cannot invite others')
1210
+ const { token, fp } = newInvite()
1211
+ return reply(res, 200, { token, fp, host: cfg.host, port: cfg.port, tailscale: tailscale(), ip: localIPv4s().find(a => !a.startsWith('127.')), expires_at: now() + INVITE_TTL_MS })
1212
+ }
1213
+ case 'GET /local/invites': // open invites (their tokens are stored hashed, so only times)
1214
+ return reply(res, 200, { invites: q.openInvites.all(now()) })
1215
+ case 'POST /local/invite/revoke': {
1216
+ const revoked = Number(q.revokeInvites.run().changes)
1217
+ rotate()
1218
+ return reply(res, 200, { revoked })
1219
+ }
1220
+ case 'POST /local/remove': {
1221
+ let host
1222
+ try { host = normHost(d.host) } catch { throw httpErr(400, `bad machine name "${d.host}"`) }
1223
+ const m = roster.byHost.get(host) || roster.byHost.get(peerRow(host)?.alias_of || '')
1224
+ if (!m) throw httpErr(404, `${host} is not a member of this network`)
1225
+ if (m.fp === me.fp) throw httpErr(400, 'to take this machine off the network, run `natter uninstall` (or remove it from another member)')
1226
+ if (!roster.members.has(me.fp)) throw httpErr(409, 'this machine is not a member of its network')
1227
+ if (!roster.canRemove(me.fp, m.fp)) {
1228
+ const earlier = [...roster.members.values()].filter(x => x.rank < m.rank).map(x => x.host)
1229
+ throw httpErr(403, `${m.host} joined the network before this machine, so only an earlier machine can remove it: ${earlier.join(', ')}`)
1230
+ }
1231
+ try { roster.remove(me, m.fp) } catch (e) { throw httpErr(507, `could not remove ${m.host}: ${errMsg(e)}`) }
1232
+ roster.save()
1233
+ log(`removed ${m.host} (key ${shortFp(m.fp)}) from the network`)
1234
+ discovery?.announce()
1235
+ setImmediate(helloAll)
1236
+ return reply(res, 200, { removed: m.host })
1237
+ }
1238
+ case 'POST /local/compact': {
1239
+ if (me.fp !== roster.net) throw httpErr(403, `only the founding machine can do this: ${roster.ranked.get(roster.net)?.host ?? 'the first machine on the network'}`)
1240
+ // what the others know first, from a round begun now: an add we haven't heard of would be lost
1241
+ const asked = now()
1242
+ const catchUp = async () => { await helloing; await helloAll(); return true }
1243
+ /** @type {NodeJS.Timeout | undefined} */
1244
+ let limit
1245
+ const caughtUp = await Promise.race([catchUp(), new Promise(res => { limit = setTimeout(() => res(false), COMPACT_WAIT_MS) })]).finally(() => clearTimeout(limit))
1246
+ const list = [...roster.members.values()].map(m => m.host)
1247
+ const offline = list.filter(h => h !== cfg.host && !(Number(reachedAt.get(h)) >= asked))
1248
+ if (!d.confirm) return reply(res, 200, { members: list, offline, caughtUp, ...rosterStatus() })
1249
+ if (req.socket.destroyed) return // the command was interrupted: that's a no
1250
+ if (!caughtUp && !d.force) throw httpErr(504, `not every machine could be checked in time, so nothing was changed. Not reached: ${offline.join(', ')}. Run it again, or add --force to go ahead without them`)
1251
+ const before = rosterStatus().bytes
1252
+ try { roster.compact(me) } catch (e) { throw httpErr(507, `could not compact: ${errMsg(e)}`) }
1253
+ roster.save()
1254
+ log(`compacted the member list: ${before} bytes to ${rosterStatus().bytes}`)
1255
+ discovery?.announce()
1256
+ setImmediate(helloAll)
1257
+ return reply(res, 200, { members: list, offline, before, ...rosterStatus() })
1258
+ }
1259
+ case 'POST /local/shutdown':
1260
+ reply(res, 200, { ok: true })
1261
+ return setTimeout(() => shutdown(), 50)
1262
+ }
1263
+ return reply(res, 404, { error: 'not found' })
1264
+ }
1265
+
1266
+ log(`nattermatic node ${VERSION} up: ${cfg.host}:${cfg.port} (network ${shortFp(cfg.net)}, key ${shortFp(me.fp)}, pid ${process.pid})`)
1267
+
1268
+ // ---------- discovery ----------
1269
+ const discovery = cfg.discovery ? startDiscovery(cfg, () => roster.gateDks(), ({ host, fp, addr }) => {
1270
+ const m = roster.members.get(fp)
1271
+ if (m && m.host === host && m.fp !== me.fp) probe(m.host, addr) // beacons are only hints: the handshake verifies
1272
+ }, log) : null
1273
+
1274
+ const timers = [
1275
+ setInterval(flush, 5000),
1276
+ setInterval(helloAll, HELLO_EVERY),
1277
+ setInterval(gc, 3600_000),
1278
+ setInterval(rotate, 60_000), // an expired invite's key stops being presented
1279
+ ]
1280
+ helloAll()
1281
+ kick()
1282
+ gc()
1283
+
1284
+ let closing = false
1285
+ function shutdown() {
1286
+ if (closing) return
1287
+ closing = true
1288
+ log('shutting down')
1289
+ timers.forEach(clearInterval)
1290
+ for (const s of sessions.values()) s.res.end()
1291
+ discovery?.stop()
1292
+ server.close()
1293
+ server.closeAllConnections?.()
1294
+ setTimeout(() => { try { db.close() } catch {}; process.exit(0) }, 300)
1295
+ }
1296
+ process.on('SIGTERM', shutdown)
1297
+ process.on('SIGINT', shutdown)
1298
+ return { server, shutdown }
1299
+ }
1300
+
1301
+ /**
1302
+ * What another machine says its sessions are called, keeping only names as a node makes them: these
1303
+ * go straight to a session, so nothing else may ride along.
1304
+ * @param {unknown} list
1305
+ */
1306
+ export const sessionNames = list => Array.isArray(list) ? list.filter(s => typeof s === 'string' && sanitizeName(s) === s).slice(0, 200) : []
1307
+
1308
+ /** @type {Promise<string> | null} */
1309
+ let packingApp = null
1310
+ /** Zip the app this node runs from, for a Mac joining through us. @param {string} dir @param {string} bundle */
1311
+ function packApp(dir, bundle) {
1312
+ return packingApp ||= (async () => {
1313
+ const out = path.join(dir, 'dist', 'app')
1314
+ fs.mkdirSync(out, { recursive: true })
1315
+ const zip = path.join(out, 'nattermatic-app.zip')
1316
+ fs.rmSync(zip, { force: true })
1317
+ await execFileP('ditto', ['-c', '-k', '--keepParent', bundle, zip])
1318
+ return zip
1319
+ })().finally(() => setTimeout(() => { packingApp = null }, 60_000))
1320
+ }
1321
+
1322
+ /** @type {Promise<string> | null} */
1323
+ let packing = null
1324
+ /** npm-pack this installation so another machine can install it from us. @param {string} dir @returns {Promise<string>} path of the tarball */
1325
+ function packTarball(dir) {
1326
+ return packing ||= (async () => {
1327
+ const out = path.join(dir, 'dist', 'pkg') // its own folder: the app's zip may be in use
1328
+ fs.rmSync(out, { recursive: true, force: true })
1329
+ fs.mkdirSync(out, { recursive: true })
1330
+ if (SEA) { // the app carries the npm package it was built from
1331
+ const file = path.join(out, 'nattermatic.tgz')
1332
+ fs.writeFileSync(file, readAsset('nattermatic.tgz'))
1333
+ return file
1334
+ }
1335
+ // the npm that came with this node, if any: a private runtime is on no one's PATH
1336
+ const bin = path.dirname(process.execPath)
1337
+ const npm = fs.existsSync(path.join(bin, 'npm')) ? path.join(bin, 'npm') : 'npm'
1338
+ const { stdout } = await execFileP(npm, ['pack', '--silent', '--pack-destination', out], { cwd: PKG_ROOT, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } })
1339
+ return path.join(out, stdout.trim().split('\n').pop() || '')
1340
+ })().finally(() => setTimeout(() => { packing = null }, 60_000))
1341
+ }
1342
+
1343
+ /**
1344
+ * install.sh preset to join this network through us: where to fetch the package, the key to pin
1345
+ * (the invite key), the invite token, and the network (so a member re-joining keeps its key).
1346
+ * @param {string | undefined} hostHeader @param {Pick<Config, 'host' | 'port' | 'net'>} cfg @param {string} token @param {string} pin
1347
+ */
1348
+ export function installScript(hostHeader, cfg, token, pin) {
1349
+ const sh = shQuote
1350
+ // the name the joining machine used to reach us is, by definition, one that works for it
1351
+ const hh = String(hostHeader || '')
1352
+ const used = validAddr(hh) ? hh : validAddr(`${hh}:${cfg.port}`) ? `${hh}:${cfg.port}` : `${cfg.host}:${cfg.port}`
1353
+ // our own name too, unless that's the same machine name: its address would replace the one that worked
1354
+ const peer = parsePeer(used)?.host === cfg.host ? '' : ` --peer ${sh(`${cfg.host}:${cfg.port}`)}`
1355
+ const preset = [
1356
+ `NATTERMATIC_PKG=${sh(`https://${used}/join/pkg.tgz?t=${token}`)}`,
1357
+ // a Mac joining a machine that runs the app gets the same app (Apple silicon only)
1358
+ ...(appBundle() ? [`NATTERMATIC_APP_URL=${sh(`https://${used}/join/app.zip?t=${token}`)}`] : []),
1359
+ `NATTERMATIC_PIN=${sh(pin)}`,
1360
+ // the token is passed in the environment: setup's command line is visible to other users (ps)
1361
+ `NATTERMATIC_TOKEN=${sh(token)}; export NATTERMATIC_TOKEN`,
1362
+ // what the user added (`| sh -s -- --host <name>`) comes after: it's theirs, and it stays
1363
+ `set -- --join ${sh(used)} --pin ${sh(pin)} --net ${sh(cfg.net)}${peer} "$@"`,
1364
+ ].join('\n')
1365
+ return readAsset('install.sh').toString('utf8').replace('#@@PRESET@@', () => preset) // no $-patterns
1366
+ }
1367
+
1368
+ /** @param {http.ServerResponse} res @param {string} event @param {unknown} data */
1369
+ function sse(res, event, data) { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) }
1370
+ /** An error carrying the HTTP status to reply with. @param {number} status @param {string} message */
1371
+ function httpErr(status, message) { return Object.assign(new Error(message), { status }) }
1372
+ const isLoopback = (/** @type {string | undefined} */ a) => a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'
1373
+
1374
+ /** @param {http.IncomingMessage} req @returns {Promise<string>} */
1375
+ function readBody(req, max = MAX_REQ) {
1376
+ return new Promise((resolve, reject) => {
1377
+ let size = 0
1378
+ /** @type {Buffer[]} */
1379
+ const chunks = []
1380
+ req.on('data', (/** @type {Buffer} */ c) => {
1381
+ size += c.length
1382
+ if (size > max) { reject(httpErr(413, 'request too large')); req.destroy() } else chunks.push(c)
1383
+ })
1384
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
1385
+ req.on('error', reject)
1386
+ })
1387
+ }