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/LICENSE +21 -0
- package/README.md +229 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +232 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +411 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +130 -0
- package/src/local.js +219 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +464 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
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
|
+
}
|