instar 1.3.1348 → 1.3.1349
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +15 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/core/LifelineIngressSecret.d.ts +83 -0
- package/dist/core/LifelineIngressSecret.d.ts.map +1 -0
- package/dist/core/LifelineIngressSecret.js +203 -0
- package/dist/core/LifelineIngressSecret.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +15 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
- package/dist/core/WriteDomainRegistry.js +17 -0
- package/dist/core/WriteDomainRegistry.js.map +1 -1
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/keyMaterialPaths.d.ts.map +1 -1
- package/dist/core/keyMaterialPaths.js +16 -0
- package/dist/core/keyMaterialPaths.js.map +1 -1
- package/dist/core/types.d.ts +23 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/version-skew.d.ts +4 -2
- package/dist/core/version-skew.d.ts.map +1 -1
- package/dist/core/version-skew.js +3 -1
- package/dist/core/version-skew.js.map +1 -1
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts +49 -0
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +202 -17
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/lifeline/forwardErrors.d.ts +11 -0
- package/dist/lifeline/forwardErrors.d.ts.map +1 -1
- package/dist/lifeline/forwardErrors.js +15 -1
- package/dist/lifeline/forwardErrors.js.map +1 -1
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +24 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +4 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/LifelineIngressGuard.d.ts +326 -0
- package/dist/server/LifelineIngressGuard.d.ts.map +1 -0
- package/dist/server/LifelineIngressGuard.js +713 -0
- package/dist/server/LifelineIngressGuard.js.map +1 -0
- package/dist/server/TelegramCallbackDedupe.d.ts +58 -0
- package/dist/server/TelegramCallbackDedupe.d.ts.map +1 -0
- package/dist/server/TelegramCallbackDedupe.js +151 -0
- package/dist/server/TelegramCallbackDedupe.js.map +1 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +218 -10
- package/dist/server/routes.js.map +1 -1
- package/dist/testing/selfActionRegistry.d.ts.map +1 -1
- package/dist/testing/selfActionRegistry.js +98 -0
- package/dist/testing/selfActionRegistry.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/src/data/state-coherence-registry.json +19 -0
- package/src/scaffold/templates.ts +4 -0
- package/upgrades/1.3.1349.md +59 -0
- package/upgrades/side-effects/telegram-internal-route-lifeline-secret.md +187 -0
- package/upgrades/1.3.1348.md +0 -24
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LifelineIngressGuard — decides whether a request on the internal Telegram
|
|
3
|
+
* routes (`/internal/telegram-forward`, `/internal/telegram-callback`,
|
|
4
|
+
* `/internal/lifeline-proof`) proved it came from the lifeline.
|
|
5
|
+
*
|
|
6
|
+
* Spec: docs/specs/telegram-internal-route-lifeline-secret.md
|
|
7
|
+
*
|
|
8
|
+
* Signal vs. authority: this is an exact, deterministic credential check
|
|
9
|
+
* (constant-time compare of a random secret), not a judgment about meaning, so
|
|
10
|
+
* it may hold blocking authority. Uncertainty about OUR side (secret file
|
|
11
|
+
* unreadable, state file unwritable) fails toward DELIVERY of messages and is
|
|
12
|
+
* counted — the agent must stay reachable — but never toward trusting a sender.
|
|
13
|
+
*
|
|
14
|
+
* Two modes:
|
|
15
|
+
* observe — accept a message without the secret, and count a `would-refuse`.
|
|
16
|
+
* enforce — refuse it (typed 401).
|
|
17
|
+
*
|
|
18
|
+
* The rules:
|
|
19
|
+
* 1. ARM ON PROOF. Nothing is refused until this guard has seen a request
|
|
20
|
+
* from the lifeline carrying the valid secret (persisted). A lifeline
|
|
21
|
+
* that predates this feature has never proven itself, so it is never
|
|
22
|
+
* refused — its requests are accepted and counted, and the restart signal
|
|
23
|
+
* moves it onto the new version. A current lifeline proves itself at
|
|
24
|
+
* startup (`/internal/lifeline-proof`), so arming does not wait for a
|
|
25
|
+
* Telegram message. Where the lifeline is not the Telegram ingress at all
|
|
26
|
+
* (the server polls Telegram itself, or Telegram is not configured),
|
|
27
|
+
* there is no lifeline to wait for and the guard arms at once.
|
|
28
|
+
* 2. NO SENDER TRUST WITHOUT PROOF, ONCE PROOF IS POSSIBLE. After arming, a
|
|
29
|
+
* message accepted without the secret (observe) is still delivered, but
|
|
30
|
+
* its body-supplied sender id is not an authenticated sender; and a
|
|
31
|
+
* button tap — which is nothing but a privileged act — is refused in
|
|
32
|
+
* either mode. In ENFORCE the same holds once the guard has been
|
|
33
|
+
* unarmed for `PRE_ARM_TRUST_MS`: there, the pre-change behaviour (trust
|
|
34
|
+
* the body) is a bounded migration allowance. In OBSERVE an unarmed
|
|
35
|
+
* guard changes nothing for as long as it stays unarmed — a fleet agent
|
|
36
|
+
* whose lifeline has not updated is reported loudly, but its operator's
|
|
37
|
+
* real messages and taps are never degraded without a soak (the
|
|
38
|
+
* maturation path: development agents take the strict behaviour first).
|
|
39
|
+
* Messages are never REFUSED before arming, in any mode.
|
|
40
|
+
* 3. OBSERVE HAS AN EXIT. With no explicit setting on a fleet agent, observe
|
|
41
|
+
* graduates to enforce by itself after `EXIT_QUIET_MS` with zero
|
|
42
|
+
* would-refuse and at least `EXIT_MIN_VERIFIED` verified Telegram
|
|
43
|
+
* requests in that window — and, whatever the counts say, no later than
|
|
44
|
+
* `EXIT_DEADLINE_MS` after arming. The deadline exists because a caller
|
|
45
|
+
* holding only the Bearer token can keep the quiet window from closing by
|
|
46
|
+
* sending secretless requests. The evidence is persisted, so a server
|
|
47
|
+
* restart does not reset it.
|
|
48
|
+
*/
|
|
49
|
+
import fs from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import { resolveDevAgentGate } from '../core/devAgentGate.js';
|
|
52
|
+
import { LifelineIngressSecretHolder, lifelineSecretMatches, LIFELINE_SECRET_HEADER, LIFELINE_INGRESS_CALLER_HEADER, LIFELINE_INGRESS_CALLER_SERVER_SELF, } from '../core/LifelineIngressSecret.js';
|
|
53
|
+
import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
|
|
54
|
+
/** Observe exits after this long with zero would-refuse … */
|
|
55
|
+
export const EXIT_QUIET_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
/** … and at least this many verified Telegram requests inside that window. */
|
|
57
|
+
export const EXIT_MIN_VERIFIED = 10;
|
|
58
|
+
/** Hard ceiling: observe ends this long after arming, whatever the counts say. */
|
|
59
|
+
export const EXIT_DEADLINE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
60
|
+
/**
|
|
61
|
+
* Minimum gap between restart-signal writes BEFORE the lifeline has proven
|
|
62
|
+
* itself (the rollout nudge for a lifeline still on older code). Two a day at
|
|
63
|
+
* most: lifeline restarts on this signal share a daily cap of three with
|
|
64
|
+
* genuine version-skew restarts, and must not be able to use it up.
|
|
65
|
+
*/
|
|
66
|
+
export const SIGNAL_GAP_UNARMED_MS = 12 * 60 * 60 * 1000;
|
|
67
|
+
/**
|
|
68
|
+
* Minimum gap AFTER arming. A secretless request is then almost never a
|
|
69
|
+
* version problem (the lifeline has shown it sends the secret and re-reads a
|
|
70
|
+
* stale one by itself), so one write a day is the whole budget — a caller with
|
|
71
|
+
* only the Bearer token cannot drive lifeline restarts faster than that.
|
|
72
|
+
*/
|
|
73
|
+
export const SIGNAL_GAP_ARMED_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
/** A lifeline that has not proven itself for this long is reported. */
|
|
75
|
+
export const UNARMED_REPORT_AFTER_MS = 60 * 60 * 1000;
|
|
76
|
+
/**
|
|
77
|
+
* In ENFORCE, how long an UNARMED guard keeps the pre-change behaviour
|
|
78
|
+
* (body-supplied sender trusted, taps accepted) for a lifeline still on older
|
|
79
|
+
* code. After it, an unproven message is delivered without sender trust and an
|
|
80
|
+
* unproven tap is refused, armed or not. In OBSERVE the allowance does not
|
|
81
|
+
* expire; passing this age only raises the operator attention item. Messages
|
|
82
|
+
* are never refused before arming.
|
|
83
|
+
*/
|
|
84
|
+
export const PRE_ARM_TRUST_MS = 24 * 60 * 60 * 1000;
|
|
85
|
+
/**
|
|
86
|
+
* A current lifeline proves itself within one 15-second tick of the server
|
|
87
|
+
* being up. This long after construction, a guard that is still unarmed asks
|
|
88
|
+
* the lifeline to restart (one budgeted signal write) instead of waiting for
|
|
89
|
+
* the first Telegram message to do it.
|
|
90
|
+
*/
|
|
91
|
+
export const BOOT_NUDGE_DELAY_MS = 60 * 1000;
|
|
92
|
+
/** The security attention item is raised at most this often (persisted). */
|
|
93
|
+
export const ATTENTION_GAP_MS = 24 * 60 * 60 * 1000;
|
|
94
|
+
/** A wrong secret BEFORE arming is reported once seen this many times in a row. */
|
|
95
|
+
export const MISMATCH_REPORT_AFTER = 3;
|
|
96
|
+
/** Unverified requests write through up to this many times per window … */
|
|
97
|
+
export const PERSIST_BURST = 5;
|
|
98
|
+
/** … then their counter writes are coalesced to one per this. */
|
|
99
|
+
export const PERSIST_COALESCE_MS = 1000;
|
|
100
|
+
/**
|
|
101
|
+
* The state file sits under the secret's own name prefix on purpose: that
|
|
102
|
+
* prefix is in KEY_MATERIAL_PATHS, so the file routes never serve or edit it,
|
|
103
|
+
* backups skip it and git ignores it — without those, the Bearer token alone
|
|
104
|
+
* could rewrite `armedAt` through the file editor.
|
|
105
|
+
*/
|
|
106
|
+
export const LIFELINE_INGRESS_STATE_RELPATH = path.join('state', 'lifeline-ingress-secret.guard.json');
|
|
107
|
+
function freshState(nowIso) {
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
observingSince: nowIso,
|
|
111
|
+
verified: 0,
|
|
112
|
+
verifiedServerSelf: 0,
|
|
113
|
+
wouldRefuse: 0,
|
|
114
|
+
refused: 0,
|
|
115
|
+
secretUnavailable: 0,
|
|
116
|
+
byReason: { missing: 0, mismatch: 0 },
|
|
117
|
+
byRoute: {
|
|
118
|
+
'telegram-forward': { verified: 0, wouldRefuse: 0, refused: 0 },
|
|
119
|
+
'telegram-callback': { verified: 0, wouldRefuse: 0, refused: 0 },
|
|
120
|
+
'lifeline-proof': { verified: 0, wouldRefuse: 0, refused: 0 },
|
|
121
|
+
},
|
|
122
|
+
lastVerifiedAt: null,
|
|
123
|
+
lastWouldRefuseAt: null,
|
|
124
|
+
lastRefusedAt: null,
|
|
125
|
+
lastReason: null,
|
|
126
|
+
verifiedSinceLastWouldRefuse: 0,
|
|
127
|
+
armedAt: null,
|
|
128
|
+
armedBy: null,
|
|
129
|
+
enforceDeadlineAt: null,
|
|
130
|
+
graduatedAt: null,
|
|
131
|
+
graduatedBy: null,
|
|
132
|
+
lastSignalAt: null,
|
|
133
|
+
attentionAt: {},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/* @self-action-controller: lifeline-ingress-restart-signal */
|
|
137
|
+
export class LifelineIngressGuard {
|
|
138
|
+
opts;
|
|
139
|
+
holder;
|
|
140
|
+
statePath;
|
|
141
|
+
now;
|
|
142
|
+
log;
|
|
143
|
+
state;
|
|
144
|
+
stateCorruptAtLoad = false;
|
|
145
|
+
lastReloadAt = 0;
|
|
146
|
+
lastDegradationAt = new Map();
|
|
147
|
+
consecutiveMismatch = 0;
|
|
148
|
+
stateWriteFailures = 0;
|
|
149
|
+
lastPersistAt = 0;
|
|
150
|
+
burstWindowStart = 0;
|
|
151
|
+
burstCount = 0;
|
|
152
|
+
dirty = false;
|
|
153
|
+
flushTimer = null;
|
|
154
|
+
lastLogAt = 0;
|
|
155
|
+
suppressedLogLines = 0;
|
|
156
|
+
bootNudgeTimer = null;
|
|
157
|
+
constructor(opts) {
|
|
158
|
+
this.opts = opts;
|
|
159
|
+
this.now = opts.now ?? Date.now;
|
|
160
|
+
this.holder = opts.holder ?? new LifelineIngressSecretHolder(opts.stateDir, this.now);
|
|
161
|
+
this.statePath = path.join(opts.stateDir, LIFELINE_INGRESS_STATE_RELPATH);
|
|
162
|
+
this.log = opts.log ?? ((line) => console.warn(line));
|
|
163
|
+
this.state = this.loadState();
|
|
164
|
+
if (this.state.armedAt && !this.state.enforceDeadlineAt) {
|
|
165
|
+
this.state.enforceDeadlineAt = new Date(Date.parse(this.state.armedAt) + EXIT_DEADLINE_MS).toISOString();
|
|
166
|
+
}
|
|
167
|
+
if (this.stateCorruptAtLoad) {
|
|
168
|
+
// Absent evidence is the normal first boot and stays quiet. CORRUPT
|
|
169
|
+
// evidence may have un-armed or un-graduated this guard: say so.
|
|
170
|
+
this.degrade(this.now(), 'state-corrupt', 'the lifeline ingress guard state file was unreadable and has been reset', 'The guard starts unarmed and in its default mode until the lifeline proves itself again (seconds on a current lifeline).');
|
|
171
|
+
}
|
|
172
|
+
const delay = opts.bootNudgeDelayMs === undefined ? BOOT_NUDGE_DELAY_MS : opts.bootNudgeDelayMs;
|
|
173
|
+
if (delay !== null) {
|
|
174
|
+
this.bootNudgeTimer = setTimeout(() => { this.bootNudgeTimer = null; this.bootNudge(); }, delay);
|
|
175
|
+
this.bootNudgeTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Run once shortly after boot. Pins the clocks this guard relies on (the
|
|
180
|
+
* secret file and the persisted `observingSince`) so they do not slide
|
|
181
|
+
* across restarts, arms at once where there is no lifeline to wait for, and
|
|
182
|
+
* — if a lifeline is expected and still has not proven itself — asks it to
|
|
183
|
+
* restart, through the same persisted budget as every other signal write.
|
|
184
|
+
* Without this, a quiet machine would only be nudged by its first message.
|
|
185
|
+
*/
|
|
186
|
+
bootNudge() {
|
|
187
|
+
try {
|
|
188
|
+
if (!fs.existsSync(this.opts.stateDir))
|
|
189
|
+
return; // torn-down fixture / removed agent
|
|
190
|
+
if (this.bypassed())
|
|
191
|
+
return;
|
|
192
|
+
const nowMs = this.now();
|
|
193
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
194
|
+
this.holder.get(); // creates the secret if absent — dates the feature on this disk
|
|
195
|
+
this.armIfNoLifeline(nowMs, nowIso);
|
|
196
|
+
if (!this.state.armedAt && this.spendSignalBudget()) {
|
|
197
|
+
let wrote = false;
|
|
198
|
+
try {
|
|
199
|
+
wrote = this.opts.writeRestartSignal({ previousVersion: 'unknown' });
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
// @silent-fallback-ok — logged, and the budget is released below so
|
|
203
|
+
// the first unverified request writes the signal instead.
|
|
204
|
+
this.log(`[lifeline-ingress] boot nudge could not write the lifeline-restart signal: ${err instanceof Error ? err.message : err}`);
|
|
205
|
+
wrote = false;
|
|
206
|
+
}
|
|
207
|
+
if (wrote === false)
|
|
208
|
+
this.state.lastSignalAt = null; // declined: budget unspent
|
|
209
|
+
}
|
|
210
|
+
this.persist(nowMs);
|
|
211
|
+
}
|
|
212
|
+
catch { /* the nudge is an optimisation; requests do the same work */ }
|
|
213
|
+
}
|
|
214
|
+
/** No lifeline to wait for on this machine: arm now. */
|
|
215
|
+
armIfNoLifeline(nowMs, nowIso) {
|
|
216
|
+
if (this.state.armedAt)
|
|
217
|
+
return;
|
|
218
|
+
if (this.opts.lifelineIsIngress && !this.opts.lifelineIsIngress())
|
|
219
|
+
this.arm(nowMs, nowIso, 'no-lifeline-ingress');
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* When the 24-hour pre-arm allowance started: the EARLIER of this guard's
|
|
223
|
+
* first recorded run and the secret file's own timestamp. The second clock
|
|
224
|
+
* survives a lost or corrupt state file, so resetting the state cannot buy a
|
|
225
|
+
* fresh allowance on a machine that has had this feature for longer.
|
|
226
|
+
*/
|
|
227
|
+
allowanceStartMs() {
|
|
228
|
+
const observing = Date.parse(this.state.observingSince);
|
|
229
|
+
const secretAt = this.holder.fileMtimeMs();
|
|
230
|
+
return secretAt !== null && secretAt < observing ? secretAt : observing;
|
|
231
|
+
}
|
|
232
|
+
/** The configured intent, before the arm-on-proof rule. */
|
|
233
|
+
mode() {
|
|
234
|
+
const explicit = this.opts.explicitEnforce();
|
|
235
|
+
if (explicit !== undefined)
|
|
236
|
+
return explicit ? 'enforce' : 'observe';
|
|
237
|
+
if (resolveDevAgentGate(undefined, this.opts.config))
|
|
238
|
+
return 'enforce';
|
|
239
|
+
// Observe exists to watch a real lifeline's traffic before refusing it.
|
|
240
|
+
// Where there is no lifeline ingress there is nothing to watch.
|
|
241
|
+
if (this.state.armedBy === 'no-lifeline-ingress')
|
|
242
|
+
return 'enforce';
|
|
243
|
+
return this.state.graduatedAt ? 'enforce' : 'observe';
|
|
244
|
+
}
|
|
245
|
+
/** Is the full rollback (`bypass: true`) set? */
|
|
246
|
+
bypassed() {
|
|
247
|
+
try {
|
|
248
|
+
return this.opts.bypass?.() === true;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// @silent-fallback-ok — a config read fault must never switch the guard
|
|
252
|
+
// OFF: "not bypassed" is the protective direction, not a degraded one.
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Guard-posture row (GUARD_MANIFEST key `telegramIngress.lifelineSecret`).
|
|
258
|
+
* `enabled` = the guard is actually protecting: armed (or there is no
|
|
259
|
+
* lifeline to wait for) and not bypassed. `dryRun` = observe.
|
|
260
|
+
*/
|
|
261
|
+
guardStatus() {
|
|
262
|
+
const nowMs = this.now();
|
|
263
|
+
this.armIfNoLifeline(nowMs, new Date(nowMs).toISOString());
|
|
264
|
+
return {
|
|
265
|
+
enabled: !this.bypassed() && this.state.armedAt !== null,
|
|
266
|
+
// "Dry run" here means observe HELD by config. Default observe is not a
|
|
267
|
+
// dry run: once armed it already drops unproven sender identity and
|
|
268
|
+
// refuses unproven taps, and it ends by itself (the exit rule), so it
|
|
269
|
+
// cannot become watch-only-forever. A held observe can, and shows as one.
|
|
270
|
+
dryRun: this.opts.explicitEnforce() === false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Why the mode is what it is — for /health. */
|
|
274
|
+
modeSource() {
|
|
275
|
+
if (this.opts.explicitEnforce() !== undefined)
|
|
276
|
+
return 'config';
|
|
277
|
+
if (resolveDevAgentGate(undefined, this.opts.config))
|
|
278
|
+
return 'development-agent';
|
|
279
|
+
if (this.state.armedBy === 'no-lifeline-ingress')
|
|
280
|
+
return 'no-lifeline-ingress';
|
|
281
|
+
return this.state.graduatedAt ? 'observe-exit-rule' : 'default-observe';
|
|
282
|
+
}
|
|
283
|
+
evaluate(headers, route, info = {}) {
|
|
284
|
+
const nowMs = this.now();
|
|
285
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
286
|
+
const presented = headers[LIFELINE_SECRET_HEADER];
|
|
287
|
+
const serverSelf = headers[LIFELINE_INGRESS_CALLER_HEADER] === LIFELINE_INGRESS_CALLER_SERVER_SELF;
|
|
288
|
+
let expected = this.holder.get();
|
|
289
|
+
if (!expected) {
|
|
290
|
+
// OUR side is broken and this process has never held a secret. A message
|
|
291
|
+
// is still delivered (reachability) but with no sender trust; a tap is
|
|
292
|
+
// nothing but a privileged act, so it is refused until we can verify.
|
|
293
|
+
this.state.secretUnavailable++;
|
|
294
|
+
this.persistSoon(nowMs);
|
|
295
|
+
if (this.bypassed()) {
|
|
296
|
+
// The full rollback is total: prior behaviour, whatever our own state.
|
|
297
|
+
return {
|
|
298
|
+
action: 'accept', verified: false, mode: this.mode(), reason: 'secret-unavailable',
|
|
299
|
+
senderTrusted: true, staleSecretHint: false,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Self-heal first: the holder retries the load every 30 s. Report only
|
|
303
|
+
// once a retry has failed too.
|
|
304
|
+
if (this.holder.failedLoads >= 2) {
|
|
305
|
+
this.degrade(nowMs, 'secret-unavailable', 'lifeline ingress secret file could not be read or created', 'Messages on the internal Telegram routes are delivered without proof of the sender, button taps are refused, and no operator is bound, until the file is readable.');
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
action: route === 'telegram-callback' ? 'refuse' : 'accept',
|
|
309
|
+
verified: false, mode: this.mode(), reason: 'secret-unavailable',
|
|
310
|
+
senderTrusted: false, staleSecretHint: false,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
let ok = lifelineSecretMatches(presented, expected);
|
|
314
|
+
if (!ok && typeof presented === 'string' && presented.length > 0 && nowMs - this.lastReloadAt > 5_000) {
|
|
315
|
+
// The other process may have replaced the file since we cached it. A
|
|
316
|
+
// failed re-read keeps our last good value (never fails open).
|
|
317
|
+
this.lastReloadAt = nowMs;
|
|
318
|
+
expected = this.holder.reload();
|
|
319
|
+
ok = lifelineSecretMatches(presented, expected);
|
|
320
|
+
}
|
|
321
|
+
if (ok) {
|
|
322
|
+
this.consecutiveMismatch = 0;
|
|
323
|
+
this.state.verified++;
|
|
324
|
+
this.state.byRoute[route].verified++;
|
|
325
|
+
this.state.lastVerifiedAt = nowIso;
|
|
326
|
+
if (serverSelf) {
|
|
327
|
+
// The label is read only AFTER the secret verified, and it can only
|
|
328
|
+
// REMOVE evidence (this request does not arm or count as traffic).
|
|
329
|
+
this.state.verifiedServerSelf++;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// A startup proof arms the guard but is not Telegram traffic, so it
|
|
333
|
+
// does not count toward the quiet-window arm of the exit rule.
|
|
334
|
+
if (route !== 'lifeline-proof')
|
|
335
|
+
this.state.verifiedSinceLastWouldRefuse++;
|
|
336
|
+
this.arm(nowMs, nowIso, 'lifeline-proof');
|
|
337
|
+
this.maybeGraduate(nowMs, nowIso);
|
|
338
|
+
}
|
|
339
|
+
this.persist(nowMs); // verified traffic is lifeline-rate; write through
|
|
340
|
+
return { action: 'accept', verified: true, mode: this.mode(), senderTrusted: true, staleSecretHint: false };
|
|
341
|
+
}
|
|
342
|
+
const reason = typeof presented === 'string' && presented.length > 0 ? 'mismatch' : 'missing';
|
|
343
|
+
this.consecutiveMismatch = reason === 'mismatch' ? this.consecutiveMismatch + 1 : 0;
|
|
344
|
+
if (this.bypassed()) {
|
|
345
|
+
// Full rollback: handled exactly as before this change. Still counted,
|
|
346
|
+
// and reported — a bypassed guard is itself an incident.
|
|
347
|
+
this.state.byReason[reason]++;
|
|
348
|
+
this.state.lastReason = reason;
|
|
349
|
+
this.state.wouldRefuse++;
|
|
350
|
+
this.state.byRoute[route].wouldRefuse++;
|
|
351
|
+
this.state.lastWouldRefuseAt = nowIso;
|
|
352
|
+
this.state.verifiedSinceLastWouldRefuse = 0;
|
|
353
|
+
this.degrade(nowMs, 'bypassed', `request without a valid lifeline secret on ${route} accepted because the guard is bypassed by config`, 'telegramIngress.lifelineSecret.bypass is true: the internal Telegram routes are unprotected.');
|
|
354
|
+
this.attention(nowMs, nowIso, 'bypassed', 'My Telegram intake check is switched off', `A request without the lifeline secret reached the internal Telegram ${route === 'telegram-callback' ? 'button' : 'message'} route and was accepted, because the bypass setting is on. While it is on, a session can send a message or a button tap as you.`);
|
|
355
|
+
this.persistSoon(nowMs);
|
|
356
|
+
return { action: 'accept', verified: false, mode: this.mode(), reason, senderTrusted: true, staleSecretHint: false };
|
|
357
|
+
}
|
|
358
|
+
this.armIfNoLifeline(nowMs, nowIso);
|
|
359
|
+
this.maybeGraduate(nowMs, nowIso); // the deadline arm needs no verified request
|
|
360
|
+
const mode = this.mode();
|
|
361
|
+
const armed = this.state.armedAt !== null;
|
|
362
|
+
// Rule 2: once armed — or, in enforce, once the bounded pre-arm allowance
|
|
363
|
+
// has run out — an unproven request carries no sender trust and an
|
|
364
|
+
// unproven tap is refused. A MESSAGE is refused only when armed + enforce.
|
|
365
|
+
const allowanceOver = nowMs - this.allowanceStartMs() >= PRE_ARM_TRUST_MS;
|
|
366
|
+
const trustAllowed = !armed && (mode === 'observe' || !allowanceOver);
|
|
367
|
+
const refuse = route === 'telegram-callback' ? !trustAllowed : armed && mode === 'enforce';
|
|
368
|
+
this.state.byReason[reason]++;
|
|
369
|
+
this.state.lastReason = reason;
|
|
370
|
+
this.state.verifiedSinceLastWouldRefuse = 0;
|
|
371
|
+
if (refuse) {
|
|
372
|
+
this.state.refused++;
|
|
373
|
+
this.state.byRoute[route].refused++;
|
|
374
|
+
this.state.lastRefusedAt = nowIso;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
this.state.wouldRefuse++;
|
|
378
|
+
this.state.byRoute[route].wouldRefuse++;
|
|
379
|
+
this.state.lastWouldRefuseAt = nowIso;
|
|
380
|
+
}
|
|
381
|
+
this.logLimited(nowMs, `[lifeline-ingress] ${refuse ? 'refused' : 'would-refuse'} ${route}: ${reason} (mode=${mode}, armed=${armed})`);
|
|
382
|
+
if (armed) {
|
|
383
|
+
// SECURITY class — reported on the same request, no heal-first delay,
|
|
384
|
+
// whether the header was missing or wrong (the server's own re-read
|
|
385
|
+
// above already ruled out a stale copy on OUR side): after arming, a
|
|
386
|
+
// request without the valid secret is not a healthy lifeline.
|
|
387
|
+
const what = reason === 'missing' ? 'without the lifeline secret' : 'with a wrong lifeline secret';
|
|
388
|
+
const impact = refuse
|
|
389
|
+
? 'The request was refused. Something other than the lifeline called the route with the ordinary token, or the lifeline cannot read the secret file (its messages stay queued).'
|
|
390
|
+
: 'The message was delivered (observe) with no sender identity. Something other than the lifeline called the route with the ordinary token.';
|
|
391
|
+
this.degrade(nowMs, 'unproven-after-arming', `request ${what} on ${route} after the guard armed`, impact);
|
|
392
|
+
// …and it reaches the operator.
|
|
393
|
+
this.attention(nowMs, nowIso, 'unproven-after-arming', 'Something other than the lifeline called my Telegram intake', `A request ${what} reached the internal Telegram ${route === 'telegram-callback' ? 'button' : 'message'} route. `
|
|
394
|
+
+ (refuse ? 'It was refused. ' : 'It was delivered without a sender identity. ')
|
|
395
|
+
+ 'If Telegram messages are also not getting through, the lifeline may be unable to read its secret file.');
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
this.reportUnarmed(nowMs, nowIso, reason, allowanceOver, trustAllowed);
|
|
399
|
+
}
|
|
400
|
+
// Move a lifeline that is still running old code onto the current version.
|
|
401
|
+
// A lifeline already on the server's version clears the signal without
|
|
402
|
+
// restarting. The budget is persisted and deliberately small — see the
|
|
403
|
+
// SIGNAL_GAP_* constants.
|
|
404
|
+
const gap = armed ? SIGNAL_GAP_ARMED_MS : SIGNAL_GAP_UNARMED_MS;
|
|
405
|
+
const lastSignalMs = this.state.lastSignalAt ? Date.parse(this.state.lastSignalAt) : NaN;
|
|
406
|
+
if (!Number.isFinite(lastSignalMs) || nowMs - lastSignalMs >= gap) {
|
|
407
|
+
try {
|
|
408
|
+
const wrote = this.opts.writeRestartSignal({
|
|
409
|
+
previousVersion: typeof info.lifelineVersion === 'string' ? info.lifelineVersion.slice(0, 64) : 'unknown',
|
|
410
|
+
});
|
|
411
|
+
if (wrote !== false)
|
|
412
|
+
this.state.lastSignalAt = nowIso;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
this.log(`[lifeline-ingress] failed to write lifeline-restart signal: ${err instanceof Error ? err.message : err}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
this.persistSoon(nowMs);
|
|
419
|
+
return {
|
|
420
|
+
action: refuse ? 'refuse' : 'accept',
|
|
421
|
+
verified: false,
|
|
422
|
+
mode,
|
|
423
|
+
reason,
|
|
424
|
+
senderTrusted: trustAllowed,
|
|
425
|
+
staleSecretHint: !refuse && reason === 'mismatch',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Spend one unit of the persisted restart-signal budget on behalf of
|
|
430
|
+
* another signal writer acting on an UNVERIFIED request (the route's 426
|
|
431
|
+
* path). True = the caller may write.
|
|
432
|
+
*/
|
|
433
|
+
spendSignalBudget() {
|
|
434
|
+
const nowMs = this.now();
|
|
435
|
+
const gap = this.state.armedAt !== null ? SIGNAL_GAP_ARMED_MS : SIGNAL_GAP_UNARMED_MS;
|
|
436
|
+
const last = this.state.lastSignalAt ? Date.parse(this.state.lastSignalAt) : NaN;
|
|
437
|
+
if (Number.isFinite(last) && nowMs - last < gap)
|
|
438
|
+
return false;
|
|
439
|
+
this.state.lastSignalAt = new Date(nowMs).toISOString();
|
|
440
|
+
this.persistSoon(nowMs);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
/** The block published on the authed /health. No secret material. */
|
|
444
|
+
health() {
|
|
445
|
+
const nowMs = this.now();
|
|
446
|
+
this.armIfNoLifeline(nowMs, new Date(nowMs).toISOString());
|
|
447
|
+
this.maybeGraduate(nowMs, new Date(nowMs).toISOString(), true);
|
|
448
|
+
const mode = this.mode();
|
|
449
|
+
const explicit = this.opts.explicitEnforce();
|
|
450
|
+
const quietSince = this.quietSinceMs();
|
|
451
|
+
const s = this.state;
|
|
452
|
+
const secretAvailable = this.holder.fileReadable(); // a read of the FILE, never a create
|
|
453
|
+
return {
|
|
454
|
+
mode,
|
|
455
|
+
modeSource: this.modeSource(),
|
|
456
|
+
armed: s.armedAt !== null,
|
|
457
|
+
armedBy: s.armedBy,
|
|
458
|
+
bypassedByConfig: this.bypassed(),
|
|
459
|
+
// Refusal needs a secret HELD in memory; `secretAvailable` is the file.
|
|
460
|
+
refusing: !this.bypassed() && mode === 'enforce' && s.armedAt !== null && this.holder.held,
|
|
461
|
+
// Taps are refused once armed (either mode), in enforce once the
|
|
462
|
+
// pre-arm allowance has run out, and whenever no secret is held.
|
|
463
|
+
refusingTaps: !this.bypassed() && (s.armedAt !== null || !this.holder.held
|
|
464
|
+
|| (mode === 'enforce' && nowMs - this.allowanceStartMs() >= PRE_ARM_TRUST_MS)),
|
|
465
|
+
// Only enforce has an expiring pre-arm allowance; observe never degrades
|
|
466
|
+
// an unarmed machine's traffic.
|
|
467
|
+
preArmTrustEndsAt: s.armedAt === null && mode === 'enforce'
|
|
468
|
+
? new Date(this.allowanceStartMs() + PRE_ARM_TRUST_MS).toISOString()
|
|
469
|
+
: null,
|
|
470
|
+
secretAvailable,
|
|
471
|
+
observeExit: {
|
|
472
|
+
rule: `zero would-refuse for ${EXIT_QUIET_MS / 3_600_000}h and at least ${EXIT_MIN_VERIFIED} verified Telegram requests from the lifeline in that time, or ${EXIT_DEADLINE_MS / 86_400_000} days after arming, whichever comes first`,
|
|
473
|
+
pinnedObserveByConfig: explicit === false,
|
|
474
|
+
deadlineAt: s.enforceDeadlineAt,
|
|
475
|
+
graduatedAt: s.graduatedAt,
|
|
476
|
+
graduatedBy: s.graduatedBy,
|
|
477
|
+
quietSince: new Date(quietSince).toISOString(),
|
|
478
|
+
quietHours: Math.max(0, Math.round(((nowMs - quietSince) / 3_600_000) * 10) / 10),
|
|
479
|
+
verifiedSinceLastWouldRefuse: s.verifiedSinceLastWouldRefuse,
|
|
480
|
+
},
|
|
481
|
+
observingSince: s.observingSince,
|
|
482
|
+
armedAt: s.armedAt,
|
|
483
|
+
verified: s.verified,
|
|
484
|
+
verifiedServerSelf: s.verifiedServerSelf,
|
|
485
|
+
wouldRefuse: s.wouldRefuse,
|
|
486
|
+
refused: s.refused,
|
|
487
|
+
secretUnavailable: s.secretUnavailable,
|
|
488
|
+
byReason: { ...s.byReason },
|
|
489
|
+
byRoute: {
|
|
490
|
+
'telegram-forward': { ...s.byRoute['telegram-forward'] },
|
|
491
|
+
'telegram-callback': { ...s.byRoute['telegram-callback'] },
|
|
492
|
+
'lifeline-proof': { ...s.byRoute['lifeline-proof'] },
|
|
493
|
+
},
|
|
494
|
+
lastVerifiedAt: s.lastVerifiedAt,
|
|
495
|
+
lastWouldRefuseAt: s.lastWouldRefuseAt,
|
|
496
|
+
lastRefusedAt: s.lastRefusedAt,
|
|
497
|
+
lastReason: s.lastReason,
|
|
498
|
+
lastSignalAt: s.lastSignalAt,
|
|
499
|
+
stateCorruptAtLoad: this.stateCorruptAtLoad,
|
|
500
|
+
stateWriteFailures: this.stateWriteFailures,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** Write any coalesced counters now and stop the timers (shutdown, tests). */
|
|
504
|
+
flush() {
|
|
505
|
+
if (this.bootNudgeTimer) {
|
|
506
|
+
clearTimeout(this.bootNudgeTimer);
|
|
507
|
+
this.bootNudgeTimer = null;
|
|
508
|
+
}
|
|
509
|
+
if (this.flushTimer) {
|
|
510
|
+
clearTimeout(this.flushTimer);
|
|
511
|
+
this.flushTimer = null;
|
|
512
|
+
}
|
|
513
|
+
if (this.dirty)
|
|
514
|
+
this.persist(this.now());
|
|
515
|
+
}
|
|
516
|
+
arm(nowMs, nowIso, by) {
|
|
517
|
+
if (this.state.armedAt)
|
|
518
|
+
return;
|
|
519
|
+
this.state.armedAt = nowIso;
|
|
520
|
+
this.state.armedBy = by;
|
|
521
|
+
this.state.enforceDeadlineAt = new Date(nowMs + EXIT_DEADLINE_MS).toISOString();
|
|
522
|
+
console.warn(by === 'lifeline-proof'
|
|
523
|
+
? '[lifeline-ingress] armed: the lifeline proved it sends the ingress secret'
|
|
524
|
+
: '[lifeline-ingress] armed: the lifeline is not the Telegram ingress on this machine');
|
|
525
|
+
}
|
|
526
|
+
quietSinceMs() {
|
|
527
|
+
const candidates = [this.state.observingSince, this.state.lastWouldRefuseAt, this.state.lastRefusedAt]
|
|
528
|
+
.map((v) => (v ? Date.parse(v) : NaN))
|
|
529
|
+
.filter((n) => Number.isFinite(n));
|
|
530
|
+
return candidates.length > 0 ? Math.max(...candidates) : this.now();
|
|
531
|
+
}
|
|
532
|
+
maybeGraduate(nowMs, nowIso, persist = false) {
|
|
533
|
+
if (this.state.graduatedAt)
|
|
534
|
+
return;
|
|
535
|
+
if (!this.state.armedAt)
|
|
536
|
+
return;
|
|
537
|
+
const deadline = this.state.enforceDeadlineAt ? Date.parse(this.state.enforceDeadlineAt) : NaN;
|
|
538
|
+
const quiet = this.state.verifiedSinceLastWouldRefuse >= EXIT_MIN_VERIFIED &&
|
|
539
|
+
nowMs - this.quietSinceMs() >= EXIT_QUIET_MS;
|
|
540
|
+
const pastDeadline = Number.isFinite(deadline) && nowMs >= deadline;
|
|
541
|
+
if (!quiet && !pastDeadline)
|
|
542
|
+
return;
|
|
543
|
+
this.state.graduatedAt = nowIso;
|
|
544
|
+
this.state.graduatedBy = quiet ? 'quiet-window' : 'deadline';
|
|
545
|
+
console.warn(`[lifeline-ingress] observe exit rule met (${this.state.graduatedBy}) — the default mode is now enforce`);
|
|
546
|
+
if (persist)
|
|
547
|
+
this.persist(nowMs);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Reports for an unverified request while UNARMED. The three checks are
|
|
551
|
+
* independent: a persistently wrong secret must not hide the fact that the
|
|
552
|
+
* guard is still unarmed, or that a day has passed.
|
|
553
|
+
*/
|
|
554
|
+
reportUnarmed(nowMs, nowIso, reason, allowanceOver, trustAllowed) {
|
|
555
|
+
if (reason === 'mismatch' && this.consecutiveMismatch >= MISMATCH_REPORT_AFTER) {
|
|
556
|
+
// RECOVERABLE — both sides re-read the file first (the server in
|
|
557
|
+
// evaluate, the lifeline on a refusal or a stale hint). Report only if
|
|
558
|
+
// it persists.
|
|
559
|
+
this.degrade(nowMs, 'secret-mismatch', `${this.consecutiveMismatch} requests in a row carried a lifeline secret that is not this server's`, 'The caller and the server disagree about the secret file — for example two state directories.');
|
|
560
|
+
}
|
|
561
|
+
if (nowMs - this.allowanceStartMs() >= UNARMED_REPORT_AFTER_MS) {
|
|
562
|
+
// RECOVERABLE — the restart signal is the heal and was written by the
|
|
563
|
+
// boot nudge (or the first such request). Still unarmed an hour later.
|
|
564
|
+
this.degrade(nowMs, 'unarmed', 'the lifeline has not yet proven it sends the ingress secret; requests without it are still being accepted', 'The internal Telegram routes are not yet protected on this machine. Restart the lifeline onto the current version.');
|
|
565
|
+
}
|
|
566
|
+
if (allowanceOver) {
|
|
567
|
+
// A day on, the lifeline still has not updated. Tell the operator.
|
|
568
|
+
this.attention(nowMs, nowIso, 'unarmed', 'My Telegram lifeline has not updated', trustAllowed
|
|
569
|
+
? 'My lifeline is still running an older version a day after my server updated, so the check that Telegram messages really come through the lifeline is not active yet. Restarting the lifeline fixes it.'
|
|
570
|
+
: 'My lifeline is still running an older version a day after my server updated, so Telegram messages now reach me without a verified sender and button taps are refused. Restarting the lifeline fixes it.');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* One operator attention item per kind per ATTENTION_GAP_MS at most. The
|
|
575
|
+
* throttle is persisted and the id is dated, so a later episode raises a
|
|
576
|
+
* NEW item even after the operator resolved an earlier one. If nothing
|
|
577
|
+
* reached the operator, the throttle is released.
|
|
578
|
+
*/
|
|
579
|
+
attention(nowMs, nowIso, kind, title, summary) {
|
|
580
|
+
if (!this.opts.raiseAttention)
|
|
581
|
+
return;
|
|
582
|
+
const previous = this.state.attentionAt[kind];
|
|
583
|
+
const last = previous ? Date.parse(previous) : NaN;
|
|
584
|
+
if (Number.isFinite(last) && nowMs - last < ATTENTION_GAP_MS)
|
|
585
|
+
return;
|
|
586
|
+
this.state.attentionAt[kind] = nowIso;
|
|
587
|
+
const release = () => {
|
|
588
|
+
if (this.state.attentionAt[kind] !== nowIso)
|
|
589
|
+
return;
|
|
590
|
+
if (previous)
|
|
591
|
+
this.state.attentionAt[kind] = previous;
|
|
592
|
+
else
|
|
593
|
+
delete this.state.attentionAt[kind];
|
|
594
|
+
this.persistSoon(this.now());
|
|
595
|
+
};
|
|
596
|
+
try {
|
|
597
|
+
const result = this.opts.raiseAttention({ id: `telegram-ingress:${kind}:${nowIso.slice(0, 10)}`, title, summary });
|
|
598
|
+
if (result === false)
|
|
599
|
+
release();
|
|
600
|
+
else if (result && typeof result.then === 'function') {
|
|
601
|
+
result.then((ok) => { if (ok === false)
|
|
602
|
+
release(); }, () => release());
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
// @silent-fallback-ok — logged; the throttle is released so the next
|
|
607
|
+
// occurrence tries again, and the degradation record already exists.
|
|
608
|
+
this.log(`[lifeline-ingress] attention item could not be raised (${kind}): ${err instanceof Error ? err.message : err}`);
|
|
609
|
+
release();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** One report per kind per hour. */
|
|
613
|
+
degrade(nowMs, kind, reason, impact) {
|
|
614
|
+
const last = this.lastDegradationAt.get(kind);
|
|
615
|
+
if (last !== undefined && nowMs - last < 60 * 60 * 1000)
|
|
616
|
+
return;
|
|
617
|
+
this.lastDegradationAt.set(kind, nowMs);
|
|
618
|
+
try {
|
|
619
|
+
this.opts.reportDegradation?.({ kind, reason, impact });
|
|
620
|
+
}
|
|
621
|
+
catch { /* reporting is best-effort */ }
|
|
622
|
+
}
|
|
623
|
+
/** At most one log line per 10 s; the next line says how many were skipped. */
|
|
624
|
+
logLimited(nowMs, line) {
|
|
625
|
+
if (this.lastLogAt !== 0 && nowMs - this.lastLogAt < 10_000) {
|
|
626
|
+
this.suppressedLogLines++;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const suffix = this.suppressedLogLines > 0 ? ` (+${this.suppressedLogLines} similar not logged)` : '';
|
|
630
|
+
this.suppressedLogLines = 0;
|
|
631
|
+
this.lastLogAt = nowMs;
|
|
632
|
+
this.log(line + suffix);
|
|
633
|
+
}
|
|
634
|
+
loadState() {
|
|
635
|
+
const base = freshState(new Date(this.now()).toISOString());
|
|
636
|
+
let raw;
|
|
637
|
+
try {
|
|
638
|
+
raw = fs.readFileSync(this.statePath, 'utf-8');
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
// @silent-fallback-ok — no evidence yet (first boot): a fresh window,
|
|
642
|
+
// nothing armed, nothing graduated, so nothing refused.
|
|
643
|
+
return base;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(raw);
|
|
647
|
+
if (parsed && parsed.version === 1 && typeof parsed.observingSince === 'string') {
|
|
648
|
+
return {
|
|
649
|
+
...base,
|
|
650
|
+
...parsed,
|
|
651
|
+
attentionAt: parsed.attentionAt && typeof parsed.attentionAt === 'object' ? { ...parsed.attentionAt } : {},
|
|
652
|
+
byReason: { ...base.byReason, ...(parsed.byReason ?? {}) },
|
|
653
|
+
byRoute: {
|
|
654
|
+
'telegram-forward': { ...base.byRoute['telegram-forward'], ...(parsed.byRoute?.['telegram-forward'] ?? {}) },
|
|
655
|
+
'telegram-callback': { ...base.byRoute['telegram-callback'], ...(parsed.byRoute?.['telegram-callback'] ?? {}) },
|
|
656
|
+
'lifeline-proof': { ...base.byRoute['lifeline-proof'], ...(parsed.byRoute?.['lifeline-proof'] ?? {}) },
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch { /* falls through to the corrupt case */ }
|
|
662
|
+
this.stateCorruptAtLoad = true;
|
|
663
|
+
return base;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Unverified requests can arrive at any rate (the caller this guard exists
|
|
667
|
+
* for is a looping session), so their counter writes are bounded: the first
|
|
668
|
+
* PERSIST_BURST in a window write through, the rest are coalesced into one
|
|
669
|
+
* trailing write per PERSIST_COALESCE_MS.
|
|
670
|
+
*/
|
|
671
|
+
persistSoon(nowMs) {
|
|
672
|
+
if (nowMs - this.burstWindowStart >= PERSIST_COALESCE_MS) {
|
|
673
|
+
this.burstWindowStart = nowMs;
|
|
674
|
+
this.burstCount = 0;
|
|
675
|
+
}
|
|
676
|
+
if (this.burstCount < PERSIST_BURST) {
|
|
677
|
+
this.burstCount++;
|
|
678
|
+
this.persist(nowMs);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
this.dirty = true;
|
|
682
|
+
if (!this.flushTimer) {
|
|
683
|
+
this.flushTimer = setTimeout(() => {
|
|
684
|
+
this.flushTimer = null;
|
|
685
|
+
if (this.dirty)
|
|
686
|
+
this.persist(this.now());
|
|
687
|
+
}, PERSIST_COALESCE_MS);
|
|
688
|
+
this.flushTimer.unref?.();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** Single writer (the server process), synchronous, tmp + rename. */
|
|
692
|
+
persist(nowMs) {
|
|
693
|
+
this.lastPersistAt = nowMs;
|
|
694
|
+
this.dirty = false;
|
|
695
|
+
const tmp = `${this.statePath}.${process.pid}.tmp`;
|
|
696
|
+
try {
|
|
697
|
+
fs.mkdirSync(path.dirname(this.statePath), { recursive: true });
|
|
698
|
+
/* state-registry: lifeline-ingress-guard */
|
|
699
|
+
fs.writeFileSync(tmp, JSON.stringify(this.state), { mode: 0o600 });
|
|
700
|
+
fs.renameSync(tmp, this.statePath);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
this.stateWriteFailures++;
|
|
704
|
+
try {
|
|
705
|
+
if (fs.existsSync(tmp)) {
|
|
706
|
+
SafeFsExecutor.safeUnlinkSync(tmp, { operation: 'src/server/LifelineIngressGuard.ts:persist-cleanup' });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch { /* best-effort */ }
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=LifelineIngressGuard.js.map
|