@mountainpass/addressr 2.5.2 → 2.6.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/lib/src/proxy-auth.js +16 -2
- package/lib/src/read-shadow.js +109 -2
- 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
|
}
|
|
@@ -79,7 +95,12 @@ function buildClientOptions(environment) {
|
|
|
79
95
|
const protocol = environment[PROTOCOL_VAR] || 'https';
|
|
80
96
|
const username = environment[USERNAME_VAR];
|
|
81
97
|
const password = environment[PASSWORD_VAR];
|
|
82
|
-
|
|
98
|
+
// P035 production-discovered bug: base64-derived passwords commonly contain
|
|
99
|
+
// '/', '+', '=', ':' which are URL-reserved. Without encoding, the resulting
|
|
100
|
+
// node URL `https://user:pa/ss@host:443` makes `new Client(...)` throw
|
|
101
|
+
// synchronously (URL parser treats '/' as path delimiter). Encode the
|
|
102
|
+
// credentials so any password the operator generates is safe.
|
|
103
|
+
const node = isNonEmpty(username) ? `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}` : `${protocol}://${host}:${port}`;
|
|
83
104
|
return {
|
|
84
105
|
node
|
|
85
106
|
};
|
|
@@ -114,7 +135,27 @@ function getTimeoutMs(environment) {
|
|
|
114
135
|
const parsed = Number.parseInt(raw, 10);
|
|
115
136
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
|
116
137
|
}
|
|
138
|
+
|
|
139
|
+
// Closed enum mapping (P035, ADR 024 information-disclosure remediation):
|
|
140
|
+
// arbitrary error shapes from the OpenSearch client get bucketed into a
|
|
141
|
+
// fixed-size set so the /debug/shadow-config response cannot leak free-text
|
|
142
|
+
// (which routinely contains hostnames, IPs, ARNs in OpenSearch errors).
|
|
143
|
+
function classifyError(reason) {
|
|
144
|
+
if (reason && reason.name === 'AbortError') return 'AbortError';
|
|
145
|
+
if (reason && (reason.code === 'ECONNREFUSED' || reason.code === 'ENOTFOUND' || reason.code === 'ETIMEDOUT' || reason.code === 'EAI_AGAIN' || reason.code === 'ECONNRESET')) {
|
|
146
|
+
return 'ConnectionError';
|
|
147
|
+
}
|
|
148
|
+
if (reason && (reason.statusCode === 401 || reason.statusCode === 403)) {
|
|
149
|
+
return 'AuthError';
|
|
150
|
+
}
|
|
151
|
+
return 'UnknownError';
|
|
152
|
+
}
|
|
117
153
|
function swallowError(reason) {
|
|
154
|
+
shadowFailures += 1;
|
|
155
|
+
lastShadowError = {
|
|
156
|
+
class: classifyError(reason),
|
|
157
|
+
ts: new Date().toISOString()
|
|
158
|
+
};
|
|
118
159
|
if (reason && reason.name === 'AbortError') {
|
|
119
160
|
error('read-shadow: request aborted by timeout');
|
|
120
161
|
return;
|
|
@@ -154,6 +195,7 @@ function mirrorRequest({
|
|
|
154
195
|
if (!client) {
|
|
155
196
|
return; // feature disabled (HOST unset)
|
|
156
197
|
}
|
|
198
|
+
shadowAttempts += 1;
|
|
157
199
|
const timeoutMs = getTimeoutMs(environment);
|
|
158
200
|
const controller = new AbortController();
|
|
159
201
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -174,9 +216,18 @@ function mirrorRequest({
|
|
|
174
216
|
clearTimeout(timer);
|
|
175
217
|
return;
|
|
176
218
|
}
|
|
219
|
+
// Architect §3 / risk R2: success-callback body is wrapped in try so a
|
|
220
|
+
// counter-increment exception (or any other synchronous throw) cannot
|
|
221
|
+
// propagate as an unhandled rejection. Failure paths land in swallowError
|
|
222
|
+
// via .catch which is itself try-shaped.
|
|
177
223
|
promise.then(() => {
|
|
178
224
|
clearTimeout(timer);
|
|
179
|
-
|
|
225
|
+
try {
|
|
226
|
+
shadowSuccesses += 1;
|
|
227
|
+
logger('read-shadow: %s ok', method);
|
|
228
|
+
} catch (callbackError) {
|
|
229
|
+
swallowError(callbackError);
|
|
230
|
+
}
|
|
180
231
|
return;
|
|
181
232
|
}).catch(error_ => {
|
|
182
233
|
clearTimeout(timer);
|
|
@@ -184,9 +235,65 @@ function mirrorRequest({
|
|
|
184
235
|
});
|
|
185
236
|
}
|
|
186
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Returns a snapshot of read-shadow runtime state for the
|
|
240
|
+
* `/debug/shadow-config` endpoint. Pure read of module-scoped state plus
|
|
241
|
+
* env-var presence checks; no I/O, no env-var values returned, no free-text
|
|
242
|
+
* error messages, no stack traces.
|
|
243
|
+
*
|
|
244
|
+
* Response shape:
|
|
245
|
+
* - `hostSet` / `credentialsSet`: booleans only — env vars present, not their values.
|
|
246
|
+
* - `clientConstructed`: true after the first `mirrorRequest` invocation that
|
|
247
|
+
* reached the client construction step (i.e., HOST set and shadow has been
|
|
248
|
+
* exercised at least once since boot).
|
|
249
|
+
* - `attempts` / `successes` / `failures`: integer counters since process start.
|
|
250
|
+
* Reset on process restart; CloudWatch covers persistent metrics.
|
|
251
|
+
* - `lastError.class`: closed enum, never free text. One of:
|
|
252
|
+
* - `'AbortError'` — request timeout (AbortController fired)
|
|
253
|
+
* - `'ConnectionError'` — TCP/TLS/DNS/network reach failure (ECONNREFUSED, ENOTFOUND, etc.)
|
|
254
|
+
* - `'AuthError'` — 401/403 from shadow target
|
|
255
|
+
* - `'UnknownError'` — anything else (rare; the catch-all)
|
|
256
|
+
* - `lastError.ts`: ISO 8601 timestamp of the last failure.
|
|
257
|
+
*
|
|
258
|
+
* P035 / ADR 024 information-disclosure remediation: the bounded enum
|
|
259
|
+
* mechanically prevents leakage of hostnames, IPs, ARNs, or stack traces
|
|
260
|
+
* even though the endpoint is on the proxy-auth ALLOWLIST.
|
|
261
|
+
*
|
|
262
|
+
* @returns {{
|
|
263
|
+
* hostSet: boolean,
|
|
264
|
+
* credentialsSet: boolean,
|
|
265
|
+
* clientConstructed: boolean,
|
|
266
|
+
* attempts: number,
|
|
267
|
+
* successes: number,
|
|
268
|
+
* failures: number,
|
|
269
|
+
* lastError: { class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null,
|
|
270
|
+
* }}
|
|
271
|
+
*/
|
|
272
|
+
function getShadowStatus(environment = process.env) {
|
|
273
|
+
return {
|
|
274
|
+
hostSet: isNonEmpty(environment[HOST_VAR]),
|
|
275
|
+
credentialsSet: isNonEmpty(environment[USERNAME_VAR]) && isNonEmpty(environment[PASSWORD_VAR]),
|
|
276
|
+
clientConstructed: !!cachedClient,
|
|
277
|
+
attempts: shadowAttempts,
|
|
278
|
+
successes: shadowSuccesses,
|
|
279
|
+
failures: shadowFailures,
|
|
280
|
+
lastError: lastShadowError
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
187
284
|
// Internal: lets unit tests reset the singleton between cases. Not part of
|
|
188
285
|
// the public contract; flagged with a leading underscore by convention.
|
|
189
286
|
function _resetShadowClientForTesting() {
|
|
190
287
|
cachedClient = undefined;
|
|
191
288
|
cachedClientFingerprint = undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Internal: lets unit tests reset counters between cases. Same convention
|
|
292
|
+
// as _resetShadowClientForTesting.
|
|
293
|
+
function _resetShadowCountersForTesting() {
|
|
294
|
+
shadowAttempts = 0;
|
|
295
|
+
shadowSuccesses = 0;
|
|
296
|
+
shadowFailures = 0;
|
|
297
|
+
// eslint-disable-next-line unicorn/no-null -- match initial value semantics for the public lastError contract
|
|
298
|
+
lastShadowError = null;
|
|
192
299
|
}
|
|
@@ -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