@norskvideo/ctl-test-harness 0.1.63 → 0.1.65
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/package.json +1 -1
- package/source-pump.js +58 -19
package/package.json
CHANGED
package/source-pump.js
CHANGED
|
@@ -65,21 +65,52 @@ function secretForDaemonPort(port) {
|
|
|
65
65
|
const storeDir = storeDirForDaemonPort(port);
|
|
66
66
|
return storeDir === undefined ? undefined : readProxySecret(storeDir);
|
|
67
67
|
}
|
|
68
|
+
/** The secret to stamp on the NEXT request, resolved per request rather than
|
|
69
|
+
* once per call.
|
|
70
|
+
*
|
|
71
|
+
* The ingress guard issues a fresh `crypto.randomUUID()` and REWRITES
|
|
72
|
+
* `<store>/proxy-secret` on every proxy configure, and the proxy is recreated
|
|
73
|
+
* mid-run whenever its mounts or ports stop matching the expected config. So a
|
|
74
|
+
* secret read once and reused for a 120s poll goes stale the moment that
|
|
75
|
+
* happens: the POST is admitted, a recreation follows, and every later GET is
|
|
76
|
+
* 307'd until the window runs out. (commentary's integration, red from
|
|
77
|
+
* 2026-09-24 — the earlier fix covered only the never-sent-one case.)
|
|
78
|
+
*
|
|
79
|
+
* A caller-supplied `proxySecret` is returned as given: it belongs to a daemon
|
|
80
|
+
* this process did not start, so this process has no store to re-read, and
|
|
81
|
+
* keeping it fresh is the caller's. */
|
|
82
|
+
function currentSecret(opts) {
|
|
83
|
+
return opts.proxySecret ?? secretForDaemonPort(opts.daemonPort);
|
|
84
|
+
}
|
|
68
85
|
/** A 307/308 off `/api/*` is the daemon's guard, not a route that moved: the
|
|
69
86
|
* API itself never redirects. */
|
|
70
87
|
function isGuardRedirect(res) {
|
|
71
88
|
return res.status === 307 || res.status === 308;
|
|
72
89
|
}
|
|
73
|
-
|
|
90
|
+
/** Why the guard refused, in the words of what this process can actually see.
|
|
91
|
+
*
|
|
92
|
+
* `sentSecret` is the secret that WAS stamped on the refused request, not the
|
|
93
|
+
* caller's `proxySecret` option. Reporting the option instead made every
|
|
94
|
+
* refusal claim "no secret was sent / this process started no daemon on that
|
|
95
|
+
* port" — including the ones where a secret came from the registry and was
|
|
96
|
+
* simply stale, which is the case that actually had commentary red. The
|
|
97
|
+
* wrong half of the message is the half an operator acts on. */
|
|
98
|
+
function guardRedirectError(method, res, sentSecret, storeKnown) {
|
|
74
99
|
const where = res.headers.get("location");
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
const why = sentSecret
|
|
101
|
+
? "The x-norsk-proxy secret that was sent is not the one this daemon holds. The ingress re-issues it and rewrites " +
|
|
102
|
+
"<store>/proxy-secret on every proxy configure — including a mid-run recreation — so a secret cached for the " +
|
|
103
|
+
"length of a poll goes stale."
|
|
104
|
+
: storeKnown
|
|
105
|
+
? "No x-norsk-proxy secret was sent: this process started the daemon on that port, but its store holds no " +
|
|
106
|
+
"proxy-secret yet. The ingress writes it as the proxy comes up, so either the proxy has not come up or this " +
|
|
107
|
+
"daemon is `test-unguarded`."
|
|
79
108
|
: "No x-norsk-proxy secret was sent, and RFC 0003 D10b removed the loopback bypass, so a local caller needs " +
|
|
80
109
|
"one too. This process started no daemon on that port, so there was no store to read one from: either start " +
|
|
81
110
|
"it through the harness's startDaemon, or read the secret yourself with `readProxySecret(storeDir)` and pass " +
|
|
82
|
-
"it as `proxySecret`."
|
|
111
|
+
"it as `proxySecret`.";
|
|
112
|
+
return new Error(`${method} /api/sources was refused by the daemon's ingress guard (${res.status} -> ${where ?? "the ingress port"}). ` +
|
|
113
|
+
`${why}` +
|
|
83
114
|
" Following this redirect instead is what turns a missing secret into an unrelated-looking ConnectionRefused or " +
|
|
84
115
|
"a `Failed to parse JSON` on nginx's HTML.");
|
|
85
116
|
}
|
|
@@ -90,12 +121,14 @@ export async function startCameraSource(opts) {
|
|
|
90
121
|
const asset = opts.asset ?? { preset: opts.preset ?? "camera1" };
|
|
91
122
|
const name = opts.name ?? defaultSourceName(asset);
|
|
92
123
|
const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
|
|
93
|
-
// Resolved
|
|
94
|
-
// none has the store dir, which is exactly why the explicit option
|
|
95
|
-
// unpassed
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
124
|
+
// Resolved per request, not at the call site: every caller has the port and
|
|
125
|
+
// almost none has the store dir, which is exactly why the explicit option
|
|
126
|
+
// went unpassed — and the secret is re-issued under us, so one read per call
|
|
127
|
+
// is not enough (see currentSecret).
|
|
128
|
+
const stamp = () => {
|
|
129
|
+
const secret = currentSecret(opts);
|
|
130
|
+
return { secret, headers: secret ? { "x-norsk-proxy": secret } : {} };
|
|
131
|
+
};
|
|
99
132
|
const base = `http://localhost:${opts.daemonPort}`;
|
|
100
133
|
const body = {
|
|
101
134
|
instanceId: opts.instanceId,
|
|
@@ -105,9 +138,10 @@ export async function startCameraSource(opts) {
|
|
|
105
138
|
};
|
|
106
139
|
if (opts.streamId !== undefined)
|
|
107
140
|
body.streamId = opts.streamId;
|
|
141
|
+
const post = stamp();
|
|
108
142
|
const startRes = await fetchImpl(`${base}/api/sources`, {
|
|
109
143
|
method: "POST",
|
|
110
|
-
headers: { ...headers, "Content-Type": "application/json" },
|
|
144
|
+
headers: { ...post.headers, "Content-Type": "application/json" },
|
|
111
145
|
body: JSON.stringify(body),
|
|
112
146
|
// MANUAL. The guard answers an unsecreted /api/* with a 307 to the ingress
|
|
113
147
|
// port, and a followed redirect hides which call was refused: on a runner
|
|
@@ -118,14 +152,16 @@ export async function startCameraSource(opts) {
|
|
|
118
152
|
redirect: "manual",
|
|
119
153
|
signal: AbortSignal.timeout(10_000),
|
|
120
154
|
});
|
|
121
|
-
if (isGuardRedirect(startRes))
|
|
122
|
-
throw guardRedirectError("POST", startRes, opts.
|
|
155
|
+
if (isGuardRedirect(startRes)) {
|
|
156
|
+
throw guardRedirectError("POST", startRes, post.secret, storeDirForDaemonPort(opts.daemonPort) !== undefined);
|
|
157
|
+
}
|
|
123
158
|
if (!startRes.ok) {
|
|
124
159
|
throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
|
|
125
160
|
}
|
|
126
161
|
await pollUntil(async () => {
|
|
162
|
+
const poll = stamp();
|
|
127
163
|
const r = await fetchImpl(`${base}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, {
|
|
128
|
-
headers,
|
|
164
|
+
headers: poll.headers,
|
|
129
165
|
redirect: "manual",
|
|
130
166
|
signal: AbortSignal.timeout(5000),
|
|
131
167
|
});
|
|
@@ -133,8 +169,9 @@ export async function startCameraSource(opts) {
|
|
|
133
169
|
// condition, and pollUntil carries the last error into its timeout
|
|
134
170
|
// message. Returning false spends the whole window and then says only
|
|
135
171
|
// that the source never ran.
|
|
136
|
-
if (isGuardRedirect(r))
|
|
137
|
-
throw guardRedirectError("GET", r, opts.
|
|
172
|
+
if (isGuardRedirect(r)) {
|
|
173
|
+
throw guardRedirectError("GET", r, poll.secret, storeDirForDaemonPort(opts.daemonPort) !== undefined);
|
|
174
|
+
}
|
|
138
175
|
if (!r.ok)
|
|
139
176
|
return false;
|
|
140
177
|
// GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
|
|
@@ -151,7 +188,9 @@ export async function startCameraSource(opts) {
|
|
|
151
188
|
async stop() {
|
|
152
189
|
await fetchImpl(`${base}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, {
|
|
153
190
|
method: "DELETE",
|
|
154
|
-
|
|
191
|
+
// Stamped now, not at launch: teardown is the furthest a cached secret
|
|
192
|
+
// ever gets from the one the ingress currently holds.
|
|
193
|
+
headers: stamp().headers,
|
|
155
194
|
// Swallowed either way (stopAll sweeps the instance), but not chased:
|
|
156
195
|
// following the guard's redirect to a port this process may not reach
|
|
157
196
|
// spends the full 10s timeout on every source at teardown.
|