@mountainpass/addressr 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/proxy-auth.js +16 -2
- package/lib/src/read-shadow.js +103 -1
- package/lib/src/waycharter-server.js +16 -0
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/lib/src/proxy-auth.js
CHANGED
|
@@ -16,8 +16,22 @@ const HEADER_VAR = 'ADDRESSR_PROXY_AUTH_HEADER';
|
|
|
16
16
|
const VALUE_VAR = 'ADDRESSR_PROXY_AUTH_VALUE';
|
|
17
17
|
|
|
18
18
|
// Closed list per ADR 024: /health for monitoring, /api-docs for gateway
|
|
19
|
-
// OpenAPI imports (ADR 023)
|
|
20
|
-
|
|
19
|
+
// OpenAPI imports (ADR 023), /debug/shadow-config for operator diagnostics
|
|
20
|
+
// (P035). Exact-match, not prefix.
|
|
21
|
+
//
|
|
22
|
+
// Debug-endpoint policy (deferred from ADR 024 amendment per the P035 plan
|
|
23
|
+
// 2026-05-03; consolidate into ADR amendment when a 2nd /debug/* endpoint
|
|
24
|
+
// lands):
|
|
25
|
+
// 1. Each new /debug/<name> endpoint requires its own ALLOWLIST entry —
|
|
26
|
+
// no prefix matching, no `/debug/*` glob.
|
|
27
|
+
// 2. Response body must be bounded: booleans, integers, ISO timestamps,
|
|
28
|
+
// and closed enums only. No free-text error messages, no hostnames /
|
|
29
|
+
// IPs / ARNs, no stack traces. Information-disclosure analysis must
|
|
30
|
+
// be trivial by inspection of the response shape.
|
|
31
|
+
// 3. Every new debug endpoint adds a snapshot test in
|
|
32
|
+
// test/js/__tests__/proxy-auth.test.mjs covering both the new exempt
|
|
33
|
+
// path AND a "must NOT exempt" assertion to prevent accidental glob.
|
|
34
|
+
const ALLOWLIST = new Set(['/health', '/api-docs', '/debug/shadow-config']);
|
|
21
35
|
function isNonEmpty(value) {
|
|
22
36
|
return typeof value === 'string' && value.length > 0;
|
|
23
37
|
}
|
package/lib/src/read-shadow.js
CHANGED
|
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports._resetShadowClientForTesting = _resetShadowClientForTesting;
|
|
7
|
+
exports._resetShadowCountersForTesting = _resetShadowCountersForTesting;
|
|
8
|
+
exports.getShadowStatus = getShadowStatus;
|
|
7
9
|
exports.mirrorRequest = mirrorRequest;
|
|
8
10
|
exports.validateReadShadowConfig = validateReadShadowConfig;
|
|
9
11
|
var _debug = _interopRequireDefault(require("debug"));
|
|
@@ -56,6 +58,20 @@ const DEFAULT_TIMEOUT_MS = 3000;
|
|
|
56
58
|
// agents internally).
|
|
57
59
|
let cachedClient;
|
|
58
60
|
let cachedClientFingerprint;
|
|
61
|
+
|
|
62
|
+
// P035: in-memory counters surfaced via /debug/shadow-config so an operator
|
|
63
|
+
// can answer "is shadow firing?" in <200ms without ssh-ing into EB. Reset
|
|
64
|
+
// at process boot; CloudWatch alarms (separate P035 task) cover the
|
|
65
|
+
// persistent + cross-instance surface. Increments are JS-atomic on the
|
|
66
|
+
// single-threaded event loop; no shared-memory worker threads in this
|
|
67
|
+
// process, so no race protection needed. Number.MAX_SAFE_INTEGER is 2^53-1
|
|
68
|
+
// — counter overflow horizon at 1000 q/s is ~285,000 years; not a concern.
|
|
69
|
+
let shadowAttempts = 0;
|
|
70
|
+
let shadowSuccesses = 0;
|
|
71
|
+
let shadowFailures = 0;
|
|
72
|
+
/** @type {{ class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null} */
|
|
73
|
+
// eslint-disable-next-line unicorn/no-null -- explicit null preserves JSON serialization shape (`lastError: null` vs property dropped when undefined); contract documented in getShadowStatus JSDoc
|
|
74
|
+
let lastShadowError = null;
|
|
59
75
|
function isNonEmpty(value) {
|
|
60
76
|
return typeof value === 'string' && value.length > 0;
|
|
61
77
|
}
|
|
@@ -114,7 +130,27 @@ function getTimeoutMs(environment) {
|
|
|
114
130
|
const parsed = Number.parseInt(raw, 10);
|
|
115
131
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
|
116
132
|
}
|
|
133
|
+
|
|
134
|
+
// Closed enum mapping (P035, ADR 024 information-disclosure remediation):
|
|
135
|
+
// arbitrary error shapes from the OpenSearch client get bucketed into a
|
|
136
|
+
// fixed-size set so the /debug/shadow-config response cannot leak free-text
|
|
137
|
+
// (which routinely contains hostnames, IPs, ARNs in OpenSearch errors).
|
|
138
|
+
function classifyError(reason) {
|
|
139
|
+
if (reason && reason.name === 'AbortError') return 'AbortError';
|
|
140
|
+
if (reason && (reason.code === 'ECONNREFUSED' || reason.code === 'ENOTFOUND' || reason.code === 'ETIMEDOUT' || reason.code === 'EAI_AGAIN' || reason.code === 'ECONNRESET')) {
|
|
141
|
+
return 'ConnectionError';
|
|
142
|
+
}
|
|
143
|
+
if (reason && (reason.statusCode === 401 || reason.statusCode === 403)) {
|
|
144
|
+
return 'AuthError';
|
|
145
|
+
}
|
|
146
|
+
return 'UnknownError';
|
|
147
|
+
}
|
|
117
148
|
function swallowError(reason) {
|
|
149
|
+
shadowFailures += 1;
|
|
150
|
+
lastShadowError = {
|
|
151
|
+
class: classifyError(reason),
|
|
152
|
+
ts: new Date().toISOString()
|
|
153
|
+
};
|
|
118
154
|
if (reason && reason.name === 'AbortError') {
|
|
119
155
|
error('read-shadow: request aborted by timeout');
|
|
120
156
|
return;
|
|
@@ -154,6 +190,7 @@ function mirrorRequest({
|
|
|
154
190
|
if (!client) {
|
|
155
191
|
return; // feature disabled (HOST unset)
|
|
156
192
|
}
|
|
193
|
+
shadowAttempts += 1;
|
|
157
194
|
const timeoutMs = getTimeoutMs(environment);
|
|
158
195
|
const controller = new AbortController();
|
|
159
196
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -174,9 +211,18 @@ function mirrorRequest({
|
|
|
174
211
|
clearTimeout(timer);
|
|
175
212
|
return;
|
|
176
213
|
}
|
|
214
|
+
// Architect §3 / risk R2: success-callback body is wrapped in try so a
|
|
215
|
+
// counter-increment exception (or any other synchronous throw) cannot
|
|
216
|
+
// propagate as an unhandled rejection. Failure paths land in swallowError
|
|
217
|
+
// via .catch which is itself try-shaped.
|
|
177
218
|
promise.then(() => {
|
|
178
219
|
clearTimeout(timer);
|
|
179
|
-
|
|
220
|
+
try {
|
|
221
|
+
shadowSuccesses += 1;
|
|
222
|
+
logger('read-shadow: %s ok', method);
|
|
223
|
+
} catch (callbackError) {
|
|
224
|
+
swallowError(callbackError);
|
|
225
|
+
}
|
|
180
226
|
return;
|
|
181
227
|
}).catch(error_ => {
|
|
182
228
|
clearTimeout(timer);
|
|
@@ -184,9 +230,65 @@ function mirrorRequest({
|
|
|
184
230
|
});
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Returns a snapshot of read-shadow runtime state for the
|
|
235
|
+
* `/debug/shadow-config` endpoint. Pure read of module-scoped state plus
|
|
236
|
+
* env-var presence checks; no I/O, no env-var values returned, no free-text
|
|
237
|
+
* error messages, no stack traces.
|
|
238
|
+
*
|
|
239
|
+
* Response shape:
|
|
240
|
+
* - `hostSet` / `credentialsSet`: booleans only — env vars present, not their values.
|
|
241
|
+
* - `clientConstructed`: true after the first `mirrorRequest` invocation that
|
|
242
|
+
* reached the client construction step (i.e., HOST set and shadow has been
|
|
243
|
+
* exercised at least once since boot).
|
|
244
|
+
* - `attempts` / `successes` / `failures`: integer counters since process start.
|
|
245
|
+
* Reset on process restart; CloudWatch covers persistent metrics.
|
|
246
|
+
* - `lastError.class`: closed enum, never free text. One of:
|
|
247
|
+
* - `'AbortError'` — request timeout (AbortController fired)
|
|
248
|
+
* - `'ConnectionError'` — TCP/TLS/DNS/network reach failure (ECONNREFUSED, ENOTFOUND, etc.)
|
|
249
|
+
* - `'AuthError'` — 401/403 from shadow target
|
|
250
|
+
* - `'UnknownError'` — anything else (rare; the catch-all)
|
|
251
|
+
* - `lastError.ts`: ISO 8601 timestamp of the last failure.
|
|
252
|
+
*
|
|
253
|
+
* P035 / ADR 024 information-disclosure remediation: the bounded enum
|
|
254
|
+
* mechanically prevents leakage of hostnames, IPs, ARNs, or stack traces
|
|
255
|
+
* even though the endpoint is on the proxy-auth ALLOWLIST.
|
|
256
|
+
*
|
|
257
|
+
* @returns {{
|
|
258
|
+
* hostSet: boolean,
|
|
259
|
+
* credentialsSet: boolean,
|
|
260
|
+
* clientConstructed: boolean,
|
|
261
|
+
* attempts: number,
|
|
262
|
+
* successes: number,
|
|
263
|
+
* failures: number,
|
|
264
|
+
* lastError: { class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null,
|
|
265
|
+
* }}
|
|
266
|
+
*/
|
|
267
|
+
function getShadowStatus(environment = process.env) {
|
|
268
|
+
return {
|
|
269
|
+
hostSet: isNonEmpty(environment[HOST_VAR]),
|
|
270
|
+
credentialsSet: isNonEmpty(environment[USERNAME_VAR]) && isNonEmpty(environment[PASSWORD_VAR]),
|
|
271
|
+
clientConstructed: !!cachedClient,
|
|
272
|
+
attempts: shadowAttempts,
|
|
273
|
+
successes: shadowSuccesses,
|
|
274
|
+
failures: shadowFailures,
|
|
275
|
+
lastError: lastShadowError
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
187
279
|
// Internal: lets unit tests reset the singleton between cases. Not part of
|
|
188
280
|
// the public contract; flagged with a leading underscore by convention.
|
|
189
281
|
function _resetShadowClientForTesting() {
|
|
190
282
|
cachedClient = undefined;
|
|
191
283
|
cachedClientFingerprint = undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Internal: lets unit tests reset counters between cases. Same convention
|
|
287
|
+
// as _resetShadowClientForTesting.
|
|
288
|
+
function _resetShadowCountersForTesting() {
|
|
289
|
+
shadowAttempts = 0;
|
|
290
|
+
shadowSuccesses = 0;
|
|
291
|
+
shadowFailures = 0;
|
|
292
|
+
// eslint-disable-next-line unicorn/no-null -- match initial value semantics for the public lastError contract
|
|
293
|
+
lastShadowError = null;
|
|
192
294
|
}
|
|
@@ -987,6 +987,22 @@ function startRest2Server() {
|
|
|
987
987
|
};
|
|
988
988
|
}
|
|
989
989
|
});
|
|
990
|
+
|
|
991
|
+
// P035: operator-diagnostic endpoint for read-shadow runtime introspection.
|
|
992
|
+
// Returns config-presence booleans + counters + closed-enum lastError;
|
|
993
|
+
// never returns hostnames, secrets, or free-text error messages.
|
|
994
|
+
// ALLOWLIST'd in src/proxy-auth.js per the debug-endpoint policy.
|
|
995
|
+
waycharter.registerResourceType({
|
|
996
|
+
path: '/debug/shadow-config',
|
|
997
|
+
loader: async () => {
|
|
998
|
+
return {
|
|
999
|
+
body: (0, _readShadow.getShadowStatus)(),
|
|
1000
|
+
headers: {
|
|
1001
|
+
'cache-control': 'no-cache'
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
990
1006
|
waycharter.registerResourceType({
|
|
991
1007
|
path: '/',
|
|
992
1008
|
loader: async () => {
|
package/lib/version.js
CHANGED