Skip to content

Commit c42473b

Browse files
authored
feat(install): pass auth options (CA_FILE, REJECT_UNAUTHORIZED) through to systemd/launchd units (#189)
Adds CACHE_FIX_PROXY_CA_FILE and CACHE_FIX_PROXY_REJECT_UNAUTHORIZED to the rendered systemd/launchd unit envs so corp-proxy / custom-CA setups work after install-service. Hardens systemdEscape for % (specifier expansion) and \\ (C-string unescape), and xmlEscape for plist entities. Regression tests cover both escape paths empirically against systemd-analyze + plistlib round-trip. Co-authored-by: Aleksandr Usenko <nisqatsi@users.noreply.github.com>
1 parent 19b38b7 commit c42473b

7 files changed

Lines changed: 295 additions & 17 deletions

bin/install-service.mjs

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
1313
import { fileURLToPath } from "node:url";
1414
import { dirname, resolve, join } from "node:path";
1515
import { homedir, platform } from "node:os";
16+
import { systemdEscape, xmlEscape } from "../proxy/helpers.mjs";
1617

1718
const __dirname = dirname(fileURLToPath(import.meta.url));
1819
const TEMPLATE_DIR = resolve(__dirname, "..", "templates");
@@ -22,6 +23,8 @@ function getDefaults() {
2223
return {
2324
port: validatePort(process.env.CACHE_FIX_PROXY_PORT || "9801"),
2425
upstream: process.env.CACHE_FIX_PROXY_UPSTREAM || "",
26+
caFile: process.env.CACHE_FIX_PROXY_CA_FILE || "",
27+
rejectUnauthorized: process.env.CACHE_FIX_PROXY_REJECT_UNAUTHORIZED || "",
2528
debug: process.env.CACHE_FIX_DEBUG || "",
2629
// Hot-reload is opt-in as of v4.0.0 (#196). Capture from env at install
2730
// time so the operator can bake `CACHE_FIX_HOT_RELOAD=on` into the
@@ -93,10 +96,16 @@ function getPaths(plat = platform()) {
9396

9497
function renderSystemdTemplate(template, vars) {
9598
const upstreamLine = vars.upstream
96-
? `Environment=CACHE_FIX_PROXY_UPSTREAM=${vars.upstream}`
99+
? `Environment=CACHE_FIX_PROXY_UPSTREAM=${systemdEscape(vars.upstream)}`
100+
: "";
101+
const caFileLine = vars.caFile
102+
? `Environment=CACHE_FIX_PROXY_CA_FILE=${systemdEscape(vars.caFile)}`
103+
: "";
104+
const rejectUnauthorizedLine = vars.rejectUnauthorized
105+
? `Environment=CACHE_FIX_PROXY_REJECT_UNAUTHORIZED=${systemdEscape(vars.rejectUnauthorized)}`
97106
: "";
98107
const debugLine = vars.debug
99-
? `Environment=CACHE_FIX_DEBUG=${vars.debug}`
108+
? `Environment=CACHE_FIX_DEBUG=${systemdEscape(vars.debug)}`
100109
: "";
101110
const hotReloadLine = vars.hotReload
102111
? `Environment=CACHE_FIX_HOT_RELOAD=${vars.hotReload}`
@@ -111,6 +120,8 @@ function renderSystemdTemplate(template, vars) {
111120
.replaceAll("{{SERVER_PATH}}", vars.serverPath)
112121
.replaceAll("{{PORT}}", vars.port)
113122
.replaceAll("{{UPSTREAM_LINE}}", upstreamLine)
123+
.replaceAll("{{CA_FILE_LINE}}", caFileLine)
124+
.replaceAll("{{REJECT_UNAUTHORIZED_LINE}}", rejectUnauthorizedLine)
114125
.replaceAll("{{DEBUG_LINE}}", debugLine)
115126
.replaceAll("{{HOT_RELOAD_LINE}}", hotReloadLine)
116127
.replaceAll("{{REQUIRES_LINE}}", requiresLine)
@@ -121,10 +132,16 @@ function renderSystemdTemplate(template, vars) {
121132

122133
function renderLaunchdTemplate(template, vars) {
123134
const upstreamPlist = vars.upstream
124-
? ` <key>CACHE_FIX_PROXY_UPSTREAM</key>\n <string>${vars.upstream}</string>`
135+
? ` <key>CACHE_FIX_PROXY_UPSTREAM</key>\n <string>${xmlEscape(vars.upstream)}</string>`
136+
: "";
137+
const caFilePlist = vars.caFile
138+
? ` <key>CACHE_FIX_PROXY_CA_FILE</key>\n <string>${xmlEscape(vars.caFile)}</string>`
139+
: "";
140+
const rejectUnauthorizedPlist = vars.rejectUnauthorized
141+
? ` <key>CACHE_FIX_PROXY_REJECT_UNAUTHORIZED</key>\n <string>${xmlEscape(vars.rejectUnauthorized)}</string>`
125142
: "";
126143
const debugPlist = vars.debug
127-
? ` <key>CACHE_FIX_DEBUG</key>\n <string>${vars.debug}</string>`
144+
? ` <key>CACHE_FIX_DEBUG</key>\n <string>${xmlEscape(vars.debug)}</string>`
128145
: "";
129146
const hotReloadPlist = vars.hotReload
130147
? ` <key>CACHE_FIX_HOT_RELOAD</key>\n <string>${vars.hotReload}</string>`
@@ -134,6 +151,8 @@ function renderLaunchdTemplate(template, vars) {
134151
.replaceAll("{{SERVER_PATH}}", vars.serverPath)
135152
.replaceAll("{{PORT}}", vars.port)
136153
.replaceAll("{{UPSTREAM_PLIST}}", upstreamPlist)
154+
.replaceAll("{{CA_FILE_PLIST}}", caFilePlist)
155+
.replaceAll("{{REJECT_UNAUTHORIZED_PLIST}}", rejectUnauthorizedPlist)
137156
.replaceAll("{{DEBUG_PLIST}}", debugPlist)
138157
.replaceAll("{{HOT_RELOAD_PLIST}}", hotReloadPlist)
139158
.replaceAll("{{WORKING_DIR}}", vars.workingDir)
@@ -186,12 +205,8 @@ async function installSystemd({ paths, defaults, force = false } = {}) {
186205
const rendered = renderSystemdTemplate(template, {
187206
node: process.execPath,
188207
serverPath: SERVER_PATH,
189-
port: defaults.port,
190-
upstream: defaults.upstream,
191-
debug: defaults.debug,
192-
hotReload: defaults.hotReload,
193-
workingDir: defaults.workingDir,
194208
requires: "",
209+
...defaults,
195210
});
196211
await mkdir(paths.configDir, { recursive: true });
197212
await writeFile(targetPath, rendered);
@@ -286,12 +301,8 @@ async function installLaunchd({ paths, defaults, force = false } = {}) {
286301
const rendered = renderLaunchdTemplate(template, {
287302
node: process.execPath,
288303
serverPath: SERVER_PATH,
289-
port: defaults.port,
290-
upstream: defaults.upstream,
291-
debug: defaults.debug,
292-
hotReload: defaults.hotReload,
293-
workingDir: defaults.workingDir,
294304
logDir: paths.logDir,
305+
...defaults,
295306
});
296307
await mkdir(paths.configDir, { recursive: true });
297308
await writeFile(targetPath, rendered);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Review: PR #189
2+
3+
Date: 2026-06-08
4+
Reviewed: PR #189 at `8159303bf58045561f2d6831736b2efee5bf632c`
5+
Round: 4
6+
Label applied: approved-by-codex-agent
7+
8+
## What Is Correct
9+
10+
`systemdEscape()` now closes the remaining round-3 gap in the helper itself. It escapes `%` before entering the quoting branch, and the quote trigger now includes bare backslashes, so a value containing `\` is forced down the quote-and-escape path instead of being emitted raw. Confirmed in `proxy/helpers.mjs:17-21`.
11+
12+
The new helper-level regression tests cover the right behaviors: bare `%`, bare `\`, a combined `%`/space/`\`/`"` case, and an explicit ordering proof that `%` escaping happens before quote-wrapping. Confirmed in `test/proxy-helpers.test.mjs:36-83`.
13+
14+
The renderer-level tests pin the rendered `Environment=` lines for the two concrete PR #189 regressions: percent-encoded upstream URLs and backslashes in CA-file paths. Confirmed in `test/install-service.test.mjs:93-125`.
15+
16+
I also re-ran the empirical checks against HEAD. A helper-rendered systemd unit passed `systemd-analyze verify`, and a live `systemctl --user` oneshot unit received:
17+
18+
- `CACHE_FIX_PROXY_UPSTREAM=https://example.com/a%20b`
19+
- `CACHE_FIX_PROXY_CA_FILE=/path/with\backslash.pem`
20+
21+
For control, the raw unescaped unit still reproduced the old failures: the upstream var was dropped with `Failed to resolve specifiers ... Invalid slot`, and the CA path arrived as `/path/with<0x08>ackslash.pem`.
22+
23+
`node --test test/proxy-helpers.test.mjs test/install-service.test.mjs` passed locally (56/56).
24+
25+
## Blockers
26+
27+
None.
28+
29+
## What Needs Attention
30+
31+
None.
32+
33+
## Bloat / Non-Functional
34+
35+
None.
36+
37+
## Recommendations
38+
39+
Approve and merge once the PR label state is updated.
40+
41+
## Bottom Line
42+
43+
The remaining round-3 systemd escaping gap is closed at `8159303`. The helper logic is now correct for `%` and `\`, the new regression tests cover the previously missing cases, and the live user-manager repro matches the intended behavior. This is ready for approval.

proxy/helpers.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Escape a value for safe rendering into a systemd `Environment=KEY=VALUE` line.
2+
//
3+
// Per systemd.exec(5) Environment= and systemd.unit(5) Specifier Expansion:
4+
// - Literal `%` is the specifier-expansion marker; to embed one in a value
5+
// the unit file must write `%%`. Without escaping, `a%20b` is parsed as
6+
// a failed `%20` specifier expansion, systemd logs "Invalid slot" and
7+
// silently drops the variable (empirically reproduced 2026-06-07).
8+
// - Backslash is a C-string escape inside quoted strings AND inside the
9+
// Environment= value parser; `\b` becomes byte 0x08 (backspace), `\n`
10+
// becomes LF, etc. To embed a literal `\` the unit must write `\\`.
11+
// - `"` requires `\"` (after the backslash escape rule above).
12+
// - Whitespace requires the whole value to be quoted (`"..."`).
13+
//
14+
// Order matters: escape `%` first (it produces `%%`, neither of which we
15+
// want to re-escape later), then handle `\` and `"` together inside the
16+
// quoting branch.
17+
export const systemdEscape = (v) => {
18+
const percentEscaped = v.replace(/%/g, '%%');
19+
const needsQuoting = /[\s"\\]/.test(v);
20+
if (!needsQuoting) return percentEscaped;
21+
return `"${percentEscaped.replace(/[\\"]/g, '\\$&')}"`;
22+
};
23+
24+
export const xmlEscape = (v) => v.replace(/[&<>'"]/g, c => ({
25+
'&': '&amp;',
26+
'<': '&lt;',
27+
'>': '&gt;',
28+
"'": '&apos;',
29+
'"': '&quot;'
30+
})[c]);

templates/cache-fix-proxy.service.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Restart=on-failure
1010
RestartSec=5
1111
Environment=CACHE_FIX_PROXY_PORT={{PORT}}
1212
{{UPSTREAM_LINE}}
13+
{{CA_FILE_LINE}}
14+
{{REJECT_UNAUTHORIZED_LINE}}
1315
{{DEBUG_LINE}}
1416
{{HOT_RELOAD_LINE}}
1517
WorkingDirectory={{WORKING_DIR}}

templates/com.cnighswonger.cache-fix-proxy.plist.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
<key>CACHE_FIX_PROXY_PORT</key>
1515
<string>{{PORT}}</string>
1616
{{UPSTREAM_PLIST}}
17+
{{CA_FILE_PLIST}}
18+
{{REJECT_UNAUTHORIZED_PLIST}}
1719
{{DEBUG_PLIST}}
1820
{{HOT_RELOAD_PLIST}}
1921
</dict>

test/install-service.test.mjs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const sampleVars = {
3838
serverPath: "/opt/cache-fix/proxy/server.mjs",
3939
port: "9801",
4040
upstream: "",
41+
caFile: "",
42+
rejectUnauthorized: "",
4143
debug: "",
4244
workingDir: "/opt/cache-fix",
4345
requires: "",
@@ -58,20 +60,26 @@ test("renderSystemdTemplate: omits empty optional Environment lines", async () =
5860
const tpl = await readFile(join(TEMPLATE_DIR, "cache-fix-proxy.service.template"), "utf-8");
5961
const out = renderSystemdTemplate(tpl, sampleVars);
6062
assert.ok(!out.includes("CACHE_FIX_PROXY_UPSTREAM"));
63+
assert.ok(!out.includes("CACHE_FIX_PROXY_CA_FILE"));
64+
assert.ok(!out.includes("CACHE_FIX_PROXY_REJECT_UNAUTHORIZED"));
6165
assert.ok(!out.includes("CACHE_FIX_DEBUG"));
6266
// No leftover empty placeholders
6367
assert.ok(!out.includes("{{"));
6468
assert.ok(!out.includes("}}"));
6569
});
6670

67-
test("renderSystemdTemplate: includes UPSTREAM and DEBUG when set", async () => {
71+
test("renderSystemdTemplate: includes UPSTREAM, CA_FILE, REJECT_UNAUTHORIZED and DEBUG when set", async () => {
6872
const tpl = await readFile(join(TEMPLATE_DIR, "cache-fix-proxy.service.template"), "utf-8");
6973
const out = renderSystemdTemplate(tpl, {
7074
...sampleVars,
7175
upstream: "http://127.0.0.1:8080",
76+
caFile: "/etc/ssl/ca \" file.pem", // with space and "
77+
rejectUnauthorized: "0",
7278
debug: "1",
7379
});
7480
assert.ok(out.includes("Environment=CACHE_FIX_PROXY_UPSTREAM=http://127.0.0.1:8080"));
81+
assert.ok(out.includes("Environment=CACHE_FIX_PROXY_CA_FILE=\"/etc/ssl/ca \\\" file.pem\""));
82+
assert.ok(out.includes("Environment=CACHE_FIX_PROXY_REJECT_UNAUTHORIZED=0"));
7583
assert.ok(out.includes("Environment=CACHE_FIX_DEBUG=1"));
7684
});
7785

@@ -82,6 +90,40 @@ test("renderSystemdTemplate: requires line wires both Requires and After", async
8290
assert.ok(out.includes("After=llm-relay.service"));
8391
});
8492

93+
// PR #189 regression — bare % triggers systemd specifier expansion and
94+
// silently drops the variable. Verified 2026-06-07 against `systemctl --user`:
95+
// the unit line `Environment=X=a%%20b` delivers `a%20b` to the spawned
96+
// process, while `Environment=X=a%20b` (unescaped) delivers an empty string
97+
// after a "Failed to resolve specifiers ... Invalid slot" log entry.
98+
test("renderSystemdTemplate: bare % in upstream URL is escaped to %% (PR #189)", async () => {
99+
const tpl = await readFile(join(TEMPLATE_DIR, "cache-fix-proxy.service.template"), "utf-8");
100+
const out = renderSystemdTemplate(tpl, {
101+
...sampleVars,
102+
upstream: "http://10.0.0.1:8080/path%20with%20encoded",
103+
});
104+
assert.ok(
105+
out.includes("Environment=CACHE_FIX_PROXY_UPSTREAM=http://10.0.0.1:8080/path%%20with%%20encoded"),
106+
"bare % must be escaped to %% in the rendered Environment= line",
107+
);
108+
assert.ok(!/=http:\/\/10\.0\.0\.1:8080\/path%20/.test(out), "no unescaped %20 should appear");
109+
});
110+
111+
// PR #189 regression — bare \ triggers systemd C-string unescape and
112+
// produces a control byte. Verified 2026-06-07: `Environment=X=/path/with\backslash.pem`
113+
// delivers /path/with<0x08>ackslash.pem to the process; the quoted form
114+
// `Environment=X="/path/with\\backslash.pem"` delivers the literal value.
115+
test("renderSystemdTemplate: backslash in CA file path is escaped to \\\\ (PR #189)", async () => {
116+
const tpl = await readFile(join(TEMPLATE_DIR, "cache-fix-proxy.service.template"), "utf-8");
117+
const out = renderSystemdTemplate(tpl, {
118+
...sampleVars,
119+
caFile: "/etc/ssl/with\\backslash.pem",
120+
});
121+
assert.ok(
122+
out.includes('Environment=CACHE_FIX_PROXY_CA_FILE="/etc/ssl/with\\\\backslash.pem"'),
123+
"bare \\ must be escaped to \\\\ inside the quoted Environment= value",
124+
);
125+
});
126+
85127
test("renderLaunchdTemplate: substitutes core fields and renders valid plist", async () => {
86128
const tpl = await readFile(
87129
join(TEMPLATE_DIR, "com.cnighswonger.cache-fix-proxy.plist.template"),
@@ -99,7 +141,31 @@ test("renderLaunchdTemplate: substitutes core fields and renders valid plist", a
99141
assert.ok(!out.includes("{{"));
100142
});
101143

102-
test("renderLaunchdTemplate: omits CACHE_FIX_PROXY_UPSTREAM/DEBUG when not set", async () => {
144+
test("renderLaunchdTemplate: includes UPSTREAM, CA_FILE, REJECT_UNAUTHORIZED and DEBUG when set", async () => {
145+
const tpl = await readFile(
146+
join(TEMPLATE_DIR, "com.cnighswonger.cache-fix-proxy.plist.template"),
147+
"utf-8",
148+
);
149+
const out = renderLaunchdTemplate(tpl, {
150+
...sampleVars,
151+
upstream: "http://127.0.0.1:8080",
152+
caFile: "/etc/ssl/ca & < > ' \" file.pem", // with XLM spec symbols
153+
rejectUnauthorized: "0",
154+
debug: "1",
155+
logDir: "/Users/test/Library/Logs",
156+
});
157+
assert.ok(out.includes("<string>com.cnighswonger.cache-fix-proxy</string>"));
158+
assert.ok(out.includes("<string>/usr/local/bin/node</string>"));
159+
assert.ok(out.includes("<string>/opt/cache-fix/proxy/server.mjs</string>"));
160+
assert.ok(out.includes("<string>9801</string>"));
161+
assert.ok(out.includes("<string>http://127.0.0.1:8080</string>"));
162+
assert.ok(out.includes("<string>/etc/ssl/ca &amp; &lt; &gt; &apos; &quot; file.pem</string>"));
163+
assert.ok(out.includes("<string>0</string>"));
164+
assert.ok(out.includes("<string>/Users/test/Library/Logs/cache-fix-proxy.log</string>"));
165+
assert.ok(!out.includes("{{"));
166+
});
167+
168+
test("renderLaunchdTemplate: omits CACHE_FIX_PROXY_UPSTREAM/CA_FILE/REJECT_UNAUTHORIZED/DEBUG when not set", async () => {
103169
const tpl = await readFile(
104170
join(TEMPLATE_DIR, "com.cnighswonger.cache-fix-proxy.plist.template"),
105171
"utf-8",
@@ -109,6 +175,8 @@ test("renderLaunchdTemplate: omits CACHE_FIX_PROXY_UPSTREAM/DEBUG when not set",
109175
logDir: "/tmp/logs",
110176
});
111177
assert.ok(!out.includes("CACHE_FIX_PROXY_UPSTREAM"));
178+
assert.ok(!out.includes("CACHE_FIX_PROXY_CA_FILE"));
179+
assert.ok(!out.includes("CACHE_FIX_PROXY_REJECT_UNAUTHORIZED"));
112180
assert.ok(!out.includes("CACHE_FIX_DEBUG"));
113181
});
114182

@@ -268,10 +336,12 @@ test("installSystemd: writes file to configDir; uninstall removes it", async ()
268336
healthcheckServiceFile: "cache-fix-proxy-healthcheck.service",
269337
healthcheckTimerFile: "cache-fix-proxy-healthcheck.timer",
270338
};
271-
const r1 = await installSystemd({ paths, defaults: { port: "9999", upstream: "", debug: "", workingDir: "/tmp" } });
339+
const r1 = await installSystemd({ paths, defaults: { port: "9999", upstream: "", caFile: "/etc/ssl/ca.pem", rejectUnauthorized: "0", debug: "", workingDir: "/tmp" } });
272340
assert.ok(r1.ok);
273341
const onDisk = await readFile(join(dir, "cache-fix-proxy.service"), "utf-8");
274342
assert.ok(onDisk.includes("CACHE_FIX_PROXY_PORT=9999"));
343+
assert.ok(onDisk.includes("CACHE_FIX_PROXY_CA_FILE=/etc/ssl/ca.pem"));
344+
assert.ok(onDisk.includes("CACHE_FIX_PROXY_REJECT_UNAUTHORIZED=0"));
275345

276346
const r2 = await uninstallSystemd({ paths });
277347
assert.ok(r2.ok);

0 commit comments

Comments
 (0)