@homeflare/alchemy 0.55.0 → 0.57.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/dist/launchd/index.js +4 -2
- package/dist/launchd/index.js.map +3 -3
- package/dist/launchd/sudo-guard-mode.d.ts +10 -4
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +9 -1
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +15 -8
- package/dist/linux/index.js.map +6 -6
- package/dist/linux/sudo-guard.d.ts +10 -1
- package/dist/linux/sudo-guard.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts +3 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/linux/sudo-write.d.ts +4 -1
- package/dist/linux/sudo-write.d.ts.map +1 -1
- package/dist/unifi/acl-rule-ordering.d.ts +1 -1
- package/dist/unifi/acl-rule.d.ts +1 -1
- package/dist/unifi/dns-policy.d.ts +1 -1
- package/dist/unifi/firewall-policy-create.d.ts +55 -0
- package/dist/unifi/firewall-policy-create.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +9 -2
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +1 -1
- package/dist/unifi/firewall-policy.d.ts +45 -12
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/index.d.ts +1 -1
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +112 -21
- package/dist/unifi/index.js.map +10 -9
- package/dist/unifi/network.d.ts +1 -1
- package/dist/unifi/policy.d.ts +22 -0
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +26 -7
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +1 -1
- package/dist/unifi/update-reconcile.d.ts +36 -2
- package/dist/unifi/update-reconcile.d.ts.map +1 -1
- package/dist/unifi/wifi-broadcast.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts +1 -1
- package/dist/unifi/wire-guard.d.ts.map +1 -1
- package/docs/distilled-interim.md +4 -2
- package/docs/linux-sudo-review-2026-09-23.md +27 -0
- package/docs/linux-sudo.md +28 -40
- package/package.json +1 -1
- package/src/launchd/sudo-guard-mode.ts +11 -3
- package/src/linux/fake-sudo-root.ts +15 -0
- package/src/linux/fake-sudo.ts +20 -1
- package/src/linux/sudo-guard.ts +22 -1
- package/src/linux/sudo-runner-privileged-read.test.ts +109 -1
- package/src/linux/sudo-runner.ts +5 -3
- package/src/linux/sudo-write.ts +5 -1
- package/src/talos/talosctl.test.ts +6 -4
- package/src/unifi/firewall-policy-create.ts +94 -0
- package/src/unifi/firewall-policy-form.ts +9 -2
- package/src/unifi/firewall-policy.test.ts +188 -1
- package/src/unifi/firewall-policy.ts +108 -12
- package/src/unifi/index.ts +1 -0
- package/src/unifi/policy.ts +45 -12
- package/src/unifi/resource.test.ts +103 -0
- package/src/unifi/resource.ts +73 -21
- package/src/unifi/update-reconcile.ts +72 -2
- package/src/unifi/wire-guard.test.ts +86 -1
- package/src/unifi/wire-guard.ts +8 -1
- package/src/unifi/write-op-reference.test.ts +21 -7
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
6
|
import type { FileStat, HostRunner } from '../launchd/runner.ts';
|
|
7
|
-
import { SUDO } from './sudo-allowlist.ts';
|
|
7
|
+
import { CHOWN, INSTALL, SUDO } from './sudo-allowlist.ts';
|
|
8
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
8
9
|
import {
|
|
9
10
|
BASE64,
|
|
10
11
|
SudoRefusedError,
|
|
@@ -90,3 +91,110 @@ describe('privilegedReads', () => {
|
|
|
90
91
|
expect(checkPrivilegedReads([SECRETISH]).has(SECRETISH)).toBe(true);
|
|
91
92
|
});
|
|
92
93
|
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The write side of the same grant (the /etc/nut convergence, 2026-10-18): a file the runner
|
|
97
|
+
* reads back as root may be WRITTEN with a mode that hides it from the operator — its read-back
|
|
98
|
+
* goes through `sudo -n base64`, so it cannot strand itself. Every other refusal (setuid,
|
|
99
|
+
* group-writable, the path chain) still applies, and an UNLISTED file with the same mode keeps
|
|
100
|
+
* refusing exactly as before.
|
|
101
|
+
*/
|
|
102
|
+
describe('privilegedReads on the write path', () => {
|
|
103
|
+
const NUTISH = '/etc/nut/upsd.users';
|
|
104
|
+
const bytes = new TextEncoder().encode('new bytes\n');
|
|
105
|
+
const owner = { gid: 113, mode: 0o640, uid: 0 };
|
|
106
|
+
|
|
107
|
+
const mk = (options: { readonly listed?: boolean } = {}) =>
|
|
108
|
+
fakeSudoHost({}, new Set(), {
|
|
109
|
+
operatorGroups: [1000],
|
|
110
|
+
prefixes: ['/etc/nut'],
|
|
111
|
+
...(options.listed === false ? {} : { privilegedReads: [NUTISH] }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('plan-time checkWrite passes for the listed 0640 root:nut file, refuses when unlisted', async () => {
|
|
115
|
+
const { runner } = mk();
|
|
116
|
+
const unlisted = mk({ listed: false });
|
|
117
|
+
if (runner.checkWrite === undefined || unlisted.runner.checkWrite === undefined) {
|
|
118
|
+
throw new Error('sshSudoRunner must expose checkWrite');
|
|
119
|
+
}
|
|
120
|
+
await runner.checkWrite(NUTISH, owner);
|
|
121
|
+
await expect(unlisted.runner.checkWrite(NUTISH, owner)).rejects.toThrow('would hide it');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('writeFileAtomic installs and chowns the listed 0640 root:nut file under the prefix', async () => {
|
|
125
|
+
const { fake, privileged, runner } = mk();
|
|
126
|
+
fake.files.set(NUTISH, {
|
|
127
|
+
bytes: new TextEncoder().encode('old'),
|
|
128
|
+
gid: 113,
|
|
129
|
+
kind: 'file',
|
|
130
|
+
mode: 0o640,
|
|
131
|
+
uid: 0,
|
|
132
|
+
});
|
|
133
|
+
await runner.writeFileAtomic(NUTISH, bytes, owner);
|
|
134
|
+
const root = privileged();
|
|
135
|
+
expect(root.some((call) => call[0] === INSTALL)).toBe(true);
|
|
136
|
+
expect(root.some((call) => call[0] === CHOWN && call[1] === ':+113')).toBe(true);
|
|
137
|
+
const after = fake.files.get(NUTISH);
|
|
138
|
+
expect(new TextDecoder().decode(after?.bytes)).toBe('new bytes\n');
|
|
139
|
+
expect(after?.mode).toBe(0o640);
|
|
140
|
+
expect(after?.uid).toBe(0);
|
|
141
|
+
expect(after?.gid).toBe(113);
|
|
142
|
+
/**
|
|
143
|
+
* ★ THE JOINED GUARANTEE (deepseek round-2 ask): the file the guard let us write in a mode
|
|
144
|
+
* that hides it from the operator reads back AS ROOT through the listed path — never
|
|
145
|
+
* EACCES, never the operator's own read. This is the stranding the guard existed to
|
|
146
|
+
* prevent, now provably absent end-to-end.
|
|
147
|
+
*/
|
|
148
|
+
const before = root.length;
|
|
149
|
+
expect(new TextDecoder().decode(await runner.readFile(NUTISH))).toBe('new bytes\n');
|
|
150
|
+
expect(privileged()[before]).toEqual([BASE64, '--', NUTISH]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('an unlisted 0640 write still refuses, and nothing runs as root', async () => {
|
|
154
|
+
const { privileged, runner } = mk({ listed: false });
|
|
155
|
+
await expect(runner.writeFileAtomic(NUTISH, bytes, owner)).rejects.toThrow('would hide it');
|
|
156
|
+
expect(privileged()).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('with the set non-empty, a sibling under the same prefix still refuses', async () => {
|
|
160
|
+
// The skip is set MEMBERSHIP, never a prefix match: with NUTISH listed, its sibling
|
|
161
|
+
// upsmon.conf — same directory, same shape, same mode — keeps the refusal
|
|
162
|
+
// (glm round-2 ask, 2026-10-18).
|
|
163
|
+
const { privileged, runner } = mk();
|
|
164
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
165
|
+
await expect(runner.checkWrite('/etc/nut/upsmon.conf', owner)).rejects.toThrow('would hide it');
|
|
166
|
+
expect(privileged()).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a listed file still refuses setuid and group-writable modes', async () => {
|
|
170
|
+
const { runner } = mk();
|
|
171
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
172
|
+
await expect(runner.checkWrite(NUTISH, { gid: 0, mode: 0o4755, uid: 0 })).rejects.toThrow(
|
|
173
|
+
'setuid/setgid',
|
|
174
|
+
);
|
|
175
|
+
await expect(runner.checkWrite(NUTISH, { gid: 0, mode: 0o0660, uid: 0 })).rejects.toThrow(
|
|
176
|
+
'writable by group',
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a listed path handed to a non-root uid is refused outright, whatever its mode', async () => {
|
|
181
|
+
// The elevated read itself refuses anything but a root-owned regular file, so a handoff —
|
|
182
|
+
// even one with operator-readable other bits (0644 uid 1001) that the plain read-back check
|
|
183
|
+
// would pass — would land and then fail EVERY read: the stranding the guard exists to
|
|
184
|
+
// prevent (deepseek round-1; the other-readable case is the glm round ask).
|
|
185
|
+
const { privileged, runner } = mk();
|
|
186
|
+
if (runner.checkWrite === undefined) throw new Error('sshSudoRunner must expose checkWrite');
|
|
187
|
+
for (const handoff of [
|
|
188
|
+
{ gid: 113, mode: 0o640, uid: 1001 },
|
|
189
|
+
{ gid: 0, mode: 0o644, uid: 1001 },
|
|
190
|
+
]) {
|
|
191
|
+
await expect(runner.checkWrite(NUTISH, handoff)).rejects.toThrow(
|
|
192
|
+
'Handing it to uid 1001 would land and then fail every read',
|
|
193
|
+
);
|
|
194
|
+
await expect(runner.writeFileAtomic(NUTISH, bytes, handoff)).rejects.toThrow(
|
|
195
|
+
'Keep it root-owned',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
expect(privileged()).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/linux/sudo-runner.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* is the "no silent sudo" failure this exists to prevent.
|
|
9
9
|
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and a file's bytes never reach argv or the
|
|
10
10
|
* log: they travel through the staged file (sudo-stage.ts) alone.
|
|
11
|
-
* ⛔ A PLAN
|
|
11
|
+
* ⛔ A PLAN ELEVATES EXACTLY ONE KIND OF CALL: the `privilegedReads` file's own read (below) —
|
|
12
|
+
* the read a plan diff needs on a declared root-owned file. Every other read, and every
|
|
13
|
+
* `checkWrite` host probe (`id -G`, `ls`, `stat`), runs through `base` — the plain
|
|
12
14
|
* `sshRunner()` — never through sudo. Nothing here probes sudo at construction.
|
|
13
15
|
* ★ PROMISES, NOT EFFECTS, ON PURPOSE — the same divergence `../launchd/runner.ts` documents
|
|
14
16
|
* (H3 in the provider-standard skill): a consumer's own runner is a plain async implementation.
|
|
@@ -120,7 +122,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
120
122
|
|
|
121
123
|
return {
|
|
122
124
|
checkWrite: async (path, options) => {
|
|
123
|
-
await vetWrite(base, prefixes, path, options);
|
|
125
|
+
await vetWrite(base, prefixes, path, options, readable);
|
|
124
126
|
},
|
|
125
127
|
effectiveUid: () => base.effectiveUid(),
|
|
126
128
|
exec: async (argv) => {
|
|
@@ -174,7 +176,7 @@ export const makeSshSudoRunner = (prefixList: readonly string[], deps: SshSudoDe
|
|
|
174
176
|
sleep: (ms) => base.sleep(ms),
|
|
175
177
|
stat: (path) => base.stat(path),
|
|
176
178
|
writeFileAtomic: async (path, bytes, options) => {
|
|
177
|
-
const vetted = await vetWrite(base, prefixes, path, options);
|
|
179
|
+
const vetted = await vetWrite(base, prefixes, path, options, readable);
|
|
178
180
|
if (vetted === undefined) {
|
|
179
181
|
return base.writeFileAtomic(path, bytes, options).catch(outsidePrefixHint(path, prefixes));
|
|
180
182
|
}
|
package/src/linux/sudo-write.ts
CHANGED
|
@@ -73,12 +73,16 @@ export type Elevate = (
|
|
|
73
73
|
* Plan-time and write-time share this: valid mode, the path chain, mode/owner refusals and the
|
|
74
74
|
* read-back — everything a write can be refused for before anything is staged or run as root.
|
|
75
75
|
* `undefined` outside every prefix, where the caller falls back to `base` directly.
|
|
76
|
+
* ★ `privilegedReads` is the runner's exact-path elevated-read set: a file on it is read back as
|
|
77
|
+
* root (sudo-runner.ts's readFile), so the read-back refusal does not apply to it — the other
|
|
78
|
+
* checks in assertInstallable still do.
|
|
76
79
|
*/
|
|
77
80
|
export const vetWrite = async (
|
|
78
81
|
base: HostRunner,
|
|
79
82
|
prefixes: readonly string[],
|
|
80
83
|
path: string,
|
|
81
84
|
options: WriteOptions,
|
|
85
|
+
privilegedReads?: ReadonlySet<string>,
|
|
82
86
|
): Promise<{ readonly mode: string; readonly prefix: string } | undefined> => {
|
|
83
87
|
const prefix = prefixOf(path, prefixes);
|
|
84
88
|
if (prefix === undefined) return undefined;
|
|
@@ -86,7 +90,7 @@ export const vetWrite = async (
|
|
|
86
90
|
if (mode === undefined)
|
|
87
91
|
throw new SudoRefusedError(`sshSudoRunner ${path}: mode must be 0–0o7777`);
|
|
88
92
|
const groups = await operatorGroups(base);
|
|
89
|
-
await assertInstallable(base, prefix, path, options, groups);
|
|
93
|
+
await assertInstallable(base, prefix, path, options, groups, privilegedReads);
|
|
90
94
|
return { mode, prefix };
|
|
91
95
|
};
|
|
92
96
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* talosctl binary override. Fake spawner only — the named path is never executed (it only has to
|
|
3
3
|
* exist and be a trusted file; the fake answers `version --client` with the pinned version).
|
|
4
4
|
*/
|
|
5
|
-
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { afterAll, expect, test } from 'bun:test';
|
|
@@ -26,6 +26,8 @@ trustBoundaryForTests(dir);
|
|
|
26
26
|
const fixture = join(dir, 'talosctl');
|
|
27
27
|
writeFileSync(fixture, '#!/bin/sh\n', { mode: 0o755 });
|
|
28
28
|
chmodSync(fixture, 0o755);
|
|
29
|
+
// macOS tmpdir is /var, a symlink to /private/var. The vetted command is the real path.
|
|
30
|
+
const resolved = realpathSync(fixture);
|
|
29
31
|
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
30
32
|
|
|
31
33
|
const run = (binary: string | undefined, calls: FakeCall[]) =>
|
|
@@ -56,7 +58,7 @@ test('the binary option wins over HF_TALOSCTL', async () => {
|
|
|
56
58
|
await withEnv('/definitely/not/here/talosctl', async () => {
|
|
57
59
|
const calls: FakeCall[] = [];
|
|
58
60
|
await run(fixture, calls);
|
|
59
|
-
expect(calls[0]?.command).toBe(
|
|
61
|
+
expect(calls[0]?.command).toBe(resolved);
|
|
60
62
|
});
|
|
61
63
|
});
|
|
62
64
|
|
|
@@ -64,7 +66,7 @@ test('HF_TALOSCTL selects the binary when the option is omitted', async () => {
|
|
|
64
66
|
await withEnv(fixture, async () => {
|
|
65
67
|
const calls: FakeCall[] = [];
|
|
66
68
|
await run(undefined, calls);
|
|
67
|
-
expect(calls[0]?.command).toBe(
|
|
69
|
+
expect(calls[0]?.command).toBe(resolved);
|
|
68
70
|
});
|
|
69
71
|
});
|
|
70
72
|
|
|
@@ -84,7 +86,7 @@ test('the default talosctl is resolved on PATH, vetted and version-checked by ab
|
|
|
84
86
|
withPath(dir, async () => {
|
|
85
87
|
const calls: FakeCall[] = [];
|
|
86
88
|
await run(undefined, calls);
|
|
87
|
-
expect(calls.map((call) => call.command)).toEqual([
|
|
89
|
+
expect(calls.map((call) => call.command)).toEqual([resolved, resolved]);
|
|
88
90
|
}),
|
|
89
91
|
);
|
|
90
92
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallPolicy`'s CREATE — the one write this family may make, and only for a row whose
|
|
3
|
+
* declared `name` passes `policy.ts`'s `isHfPolicyName` (Tim 2026-10-19 ~16:20Z, "Allow kit to
|
|
4
|
+
* create hf: rules"). THE ONLY FILE UNDER `src/unifi` ALLOWED TO NAME `createFirewallPolicy`
|
|
5
|
+
* (`write-op-reference.test.ts`'s `WRITE_OP_ALLOWLIST` pins that, alongside `network-update.ts`'s
|
|
6
|
+
* own `updateNetwork` entry).
|
|
7
|
+
*
|
|
8
|
+
* ⛔ A CREATE SENDS ONLY THE FIELDS THE DECLARATION OWNS — NEVER A SPREAD OF ANYTHING ELSE. Unlike
|
|
9
|
+
* `network-update.ts`'s whole-object PUT (which must carry the untouched live object forward so
|
|
10
|
+
* an omitted optional block is not read by the controller as "turn it off"), there IS no live
|
|
11
|
+
* object yet: `createBody` builds `CreateFirewallPolicyRequest` field by field from `props`,
|
|
12
|
+
* omitting every optional key `props` itself leaves `undefined` rather than sending it as
|
|
13
|
+
* `null`/`undefined` (UniFi's "missing = off" rule cuts both ways — a key that is actually absent
|
|
14
|
+
* from the JSON body is what "this policy does not restrict on this axis" means here, same as a
|
|
15
|
+
* declaration that never sets `connectionStateFilter` means "match every connection state").
|
|
16
|
+
* ⛔ `checkScope` RUNS BEFORE THE REQUEST, TYPED, NEVER A STRING COMPARISON INLINE AT THE CALL
|
|
17
|
+
* SITE — one function (`policy.ts`'s `isHfPolicyName`) decides "hf:" everywhere this boundary is
|
|
18
|
+
* checked, so a future second caller cannot drift from this one with a slightly different regex.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 `checkScope` ALSO REFUSES A `firewallPolicyId` THAT IS ALREADY SET (Opus red team,
|
|
21
|
+
* 2026-10-19, PRIMARY FINDING). `resource.ts`'s `create` branch runs for EVERY
|
|
22
|
+
* `Unifi.FirewallPolicy` declaration with no prior Alchemy state, not only ones meant to
|
|
23
|
+
* create — including every `firewallPolicy()` import `.pipe(adopt(true))`, and Tim's own live
|
|
24
|
+
* console ALREADY names hand-made rules `hf: <desc>` (this file's sibling `policy.ts` cites
|
|
25
|
+
* `hf: Vpn to MGMT (Magic WAN site-to-site)` as the existing convention). Before this check, an
|
|
26
|
+
* hf:-named IMPORTED row whose `firewallPolicyId` stopped resolving (state lost, the rule
|
|
27
|
+
* hand-deleted and recreated with a new id, a wrong id in a generated declaration) would GET
|
|
28
|
+
* 404, fall into `createReconcile`, pass the name check, and POST A DUPLICATE — silently, with
|
|
29
|
+
* a green plan, where every other family still refuses "declared row absent" outright
|
|
30
|
+
* (`update-reconcile.ts`'s original behavior, unchanged for every family but this one). A
|
|
31
|
+
* declaration carrying a vendor id is BY DEFINITION an existing row, adopted or updated, never
|
|
32
|
+
* a fresh create; `firewallPolicyId !== undefined` is refused here before `isHfPolicyName` is
|
|
33
|
+
* even checked, so this holds regardless of what the name says.
|
|
34
|
+
*/
|
|
35
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
36
|
+
import * as Effect from 'effect/Effect';
|
|
37
|
+
import { isHfPolicyName, refuseWrite } from './policy.ts';
|
|
38
|
+
import type { FirewallPolicyProps } from './firewall-policy-form.ts';
|
|
39
|
+
import type { AllowedWrite } from './wire-guard.ts';
|
|
40
|
+
|
|
41
|
+
/** The site's fixed firewall-policies COLLECTION route — never a per-row path, since there is no
|
|
42
|
+
* row id before this POST creates one. Takes `props` (not just a bare `siteId`) to match
|
|
43
|
+
* `UnifiUpdate.allowedWrite`'s own shape, so `resource.ts`'s wiring treats both the same way. */
|
|
44
|
+
export const firewallPolicyCreateAllowedWrite = (props: FirewallPolicyProps): AllowedWrite => ({
|
|
45
|
+
method: 'POST',
|
|
46
|
+
tail: `/v1/sites/${props.siteId}/firewall/policies`,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const describeByName = (props: FirewallPolicyProps) =>
|
|
50
|
+
`sites/${props.siteId}/firewall/policies/by-name/${props.name}`;
|
|
51
|
+
|
|
52
|
+
/** Refuses, typed, before any request: the one gate `createReconcile` calls before `write`.
|
|
53
|
+
* Order matters for the message only (both refuse the same way) — the identity check runs
|
|
54
|
+
* first because it is the one that must hold regardless of what the name says (see header). */
|
|
55
|
+
export const checkHfCreateScope = (props: FirewallPolicyProps) => {
|
|
56
|
+
if (props.firewallPolicyId !== undefined) {
|
|
57
|
+
return refuseWrite(
|
|
58
|
+
'Unifi.FirewallPolicy',
|
|
59
|
+
`sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
|
|
60
|
+
'create',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return isHfPolicyName(props.name)
|
|
64
|
+
? Effect.void
|
|
65
|
+
: refuseWrite('Unifi.FirewallPolicy', describeByName(props), 'create');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Every field `CreateFirewallPolicyRequest` accepts, straight from the declaration, with an
|
|
69
|
+
* absent optional field left OUT of the body entirely (never sent as `undefined`/`null`) —
|
|
70
|
+
* `JSON.stringify` already drops an `undefined` value, but building the object field-by-field
|
|
71
|
+
* rather than spreading `props` keeps this list the single place that enumerates exactly what a
|
|
72
|
+
* create may send, so a future prop added to `FirewallPolicyProps` for some OTHER purpose cannot
|
|
73
|
+
* silently become a wire field here without this file changing too. */
|
|
74
|
+
export const firewallPolicyCreateBody = (
|
|
75
|
+
props: FirewallPolicyProps,
|
|
76
|
+
): firewall.CreateFirewallPolicyRequest => ({
|
|
77
|
+
siteId: props.siteId,
|
|
78
|
+
action: props.action,
|
|
79
|
+
...(props.connectionStateFilter === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { connectionStateFilter: props.connectionStateFilter }),
|
|
82
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
83
|
+
destination: props.destination,
|
|
84
|
+
enabled: props.enabled,
|
|
85
|
+
ipProtocolScope: props.ipProtocolScope,
|
|
86
|
+
...(props.ipsecFilter === undefined ? {} : { ipsecFilter: props.ipsecFilter }),
|
|
87
|
+
loggingEnabled: props.loggingEnabled,
|
|
88
|
+
name: props.name,
|
|
89
|
+
...(props.schedule === undefined ? {} : { schedule: props.schedule }),
|
|
90
|
+
source: props.source,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const writeFirewallPolicyCreate = (props: FirewallPolicyProps) =>
|
|
94
|
+
firewall.createFirewallPolicy(firewallPolicyCreateBody(props));
|
|
@@ -55,10 +55,17 @@ export const normalizeConnectionStateFilter = (
|
|
|
55
55
|
): string[] | undefined => (value == null ? undefined : sortedSet(value));
|
|
56
56
|
|
|
57
57
|
/** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
|
|
58
|
-
* `network-form.ts`'s own header.
|
|
58
|
+
* `network-form.ts`'s own header.
|
|
59
|
+
*
|
|
60
|
+
* ⛔ `firewallPolicyId` IS OPTIONAL SINCE THE 2026-10-19 hf: CREATE LIFT. An adopt-only
|
|
61
|
+
* declaration (`declareFirewallPolicy`, every imported row) always carries the vendor id it was
|
|
62
|
+
* read from. A fresh `hf:` creatable declaration (`declareHfFirewallPolicy` below) does not have
|
|
63
|
+
* one yet — the vendor assigns it on `createFirewallPolicy`'s response — so `spec.fetchLive`
|
|
64
|
+
* (`firewall-policy.ts`) locates by `name` instead of `id` whenever this is absent (netbox's
|
|
65
|
+
* `locateOne` is the precedent for identity-by-natural-key over identity-by-vendor-id). */
|
|
59
66
|
export interface FirewallPolicyProps {
|
|
60
67
|
siteId: string;
|
|
61
|
-
firewallPolicyId
|
|
68
|
+
firewallPolicyId?: string | undefined;
|
|
62
69
|
action: firewall.FirewallPolicyAction;
|
|
63
70
|
/** Connection states this policy matches — compared as a SET, see the header. */
|
|
64
71
|
connectionStateFilter?: string[] | undefined;
|
|
@@ -23,7 +23,13 @@ import * as Effect from 'effect/Effect';
|
|
|
23
23
|
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
24
24
|
import { attributesOf } from './firewall-policy-form.ts';
|
|
25
25
|
import { driftOf, matches } from './firewall-policy-drift.ts';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type FirewallPolicyProps,
|
|
28
|
+
declareFirewallPolicy,
|
|
29
|
+
hfFirewallPolicy,
|
|
30
|
+
spec,
|
|
31
|
+
} from './firewall-policy.ts';
|
|
32
|
+
import { UnifiCreateDidNotConverge, UnifiWriteRefused } from './policy.ts';
|
|
27
33
|
import { pageAll } from './paginate.ts';
|
|
28
34
|
import { unifiOperations } from './resource.ts';
|
|
29
35
|
|
|
@@ -152,6 +158,187 @@ describe('Unifi.FirewallPolicy write paths never reach the vendor API', () => {
|
|
|
152
158
|
});
|
|
153
159
|
});
|
|
154
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The hf: create lift (Tim 2026-10-19 ~16:20Z) -- the one write `Unifi.FirewallPolicy` may now
|
|
163
|
+
* make, and only for a declared `name` starting `hf: `. Mirrors the write-refusal tests above,
|
|
164
|
+
* plus the one path that now DOES reach the vendor API.
|
|
165
|
+
*/
|
|
166
|
+
const POLICIES_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies';
|
|
167
|
+
const NEW_ID = 'hf-new-1';
|
|
168
|
+
|
|
169
|
+
const emptyPage = () => Response.json({ count: 0, data: [], limit: 0, offset: 0, totalCount: 0 });
|
|
170
|
+
|
|
171
|
+
/** No `firewallPolicyId`: the shape `hfFirewallPolicy` declares before anything is created. */
|
|
172
|
+
const HF_PROPS: FirewallPolicyProps = {
|
|
173
|
+
siteId: 'site-1',
|
|
174
|
+
action: { type: 'ALLOW', allowReturnTraffic: true },
|
|
175
|
+
destination: { zoneId: 'zone-storage' },
|
|
176
|
+
enabled: true,
|
|
177
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
178
|
+
loggingEnabled: true,
|
|
179
|
+
name: 'hf: CT100 to NAS https',
|
|
180
|
+
source: { zoneId: 'zone-internal' },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
describe('Unifi.FirewallPolicy hf: create', () => {
|
|
184
|
+
const ops = unifiOperations(spec);
|
|
185
|
+
|
|
186
|
+
test('an hf:-named create: one locate GET, one POST of only the declared fields, one read-back GET', async () => {
|
|
187
|
+
let createdLive: firewall.FirewallPolicy | undefined;
|
|
188
|
+
const fake = fakeUnifi((method, url) => {
|
|
189
|
+
if (url.pathname === POLICIES_PATH && method === 'GET') return emptyPage();
|
|
190
|
+
if (url.pathname === POLICIES_PATH && method === 'POST') {
|
|
191
|
+
createdLive = {
|
|
192
|
+
...HF_PROPS,
|
|
193
|
+
id: NEW_ID,
|
|
194
|
+
index: 0,
|
|
195
|
+
metadata: { origin: 'USER' },
|
|
196
|
+
} as firewall.FirewallPolicy;
|
|
197
|
+
return Response.json(createdLive);
|
|
198
|
+
}
|
|
199
|
+
if (url.pathname === `${POLICIES_PATH}/${NEW_ID}` && method === 'GET') {
|
|
200
|
+
return Response.json(createdLive);
|
|
201
|
+
}
|
|
202
|
+
return fakeFailure(400, 'unexpected request');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const attrs = await Effect.runPromise(
|
|
206
|
+
ops.reconcile(HF_PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
207
|
+
);
|
|
208
|
+
expect(attrs.firewallPolicyId).toBe(NEW_ID);
|
|
209
|
+
expect(attrs.name).toBe('hf: CT100 to NAS https');
|
|
210
|
+
expect(fake.seen.map((s) => `${s.method} ${s.path}`)).toEqual([
|
|
211
|
+
`GET ${POLICIES_PATH}?offset=0`,
|
|
212
|
+
`POST ${POLICIES_PATH}`,
|
|
213
|
+
`GET ${POLICIES_PATH}/${NEW_ID}`,
|
|
214
|
+
]);
|
|
215
|
+
// ⛔ ONLY the declared fields -- no firewallPolicyId (there was none), no stray key.
|
|
216
|
+
// `siteId` is a path label (`T.Http`'s `uri: "/v1/sites/{siteId}/..."`), not a body field.
|
|
217
|
+
expect(fake.seen[1]?.body).toEqual({
|
|
218
|
+
action: { type: 'ALLOW', allowReturnTraffic: true },
|
|
219
|
+
destination: { zoneId: 'zone-storage' },
|
|
220
|
+
enabled: true,
|
|
221
|
+
ipProtocolScope: { ipVersion: 'IPV4' },
|
|
222
|
+
loggingEnabled: true,
|
|
223
|
+
name: 'hf: CT100 to NAS https',
|
|
224
|
+
source: { zoneId: 'zone-internal' },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('a non-hf: name refuses create, typed, sending the existence GET but no write', async () => {
|
|
229
|
+
// ★ The existence GET (does a row this name already matches exist?) runs for every name --
|
|
230
|
+
// the SAME GET a plain cold adoption needs (H6) -- and answers empty here. Only the WRITE
|
|
231
|
+
// is refused: `fake.seen.every(GET)` is this file's own standing idiom for that proof.
|
|
232
|
+
const fake = fakeUnifi((method, url) =>
|
|
233
|
+
method === 'GET' && url.pathname === POLICIES_PATH
|
|
234
|
+
? emptyPage()
|
|
235
|
+
: fakeFailure(400, 'unexpected request'),
|
|
236
|
+
);
|
|
237
|
+
const notHf: FirewallPolicyProps = { ...HF_PROPS, name: 'Not hf-named' };
|
|
238
|
+
const failure = await Effect.runPromise(
|
|
239
|
+
Effect.flip(ops.reconcile(notHf).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
240
|
+
);
|
|
241
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
242
|
+
expect((failure as UnifiWriteRefused).action).toBe('create');
|
|
243
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
244
|
+
expect(fake.seen.length).toBeGreaterThan(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('"hf:foo" with no space after the colon is NOT an hf: name -- refused, no write', async () => {
|
|
248
|
+
const fake = fakeUnifi((method, url) =>
|
|
249
|
+
method === 'GET' && url.pathname === POLICIES_PATH
|
|
250
|
+
? emptyPage()
|
|
251
|
+
: fakeFailure(400, 'unexpected request'),
|
|
252
|
+
);
|
|
253
|
+
const noSpace: FirewallPolicyProps = { ...HF_PROPS, name: 'hf:no-space' };
|
|
254
|
+
const failure = await Effect.runPromise(
|
|
255
|
+
Effect.flip(ops.reconcile(noSpace).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
256
|
+
);
|
|
257
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
258
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('a read-back mismatch after the POST fails typed (UnifiCreateDidNotConverge), not silently', async () => {
|
|
262
|
+
const fake = fakeUnifi((method, url) => {
|
|
263
|
+
if (url.pathname === POLICIES_PATH && method === 'GET') return emptyPage();
|
|
264
|
+
if (url.pathname === POLICIES_PATH && method === 'POST') {
|
|
265
|
+
return Response.json({ ...HF_PROPS, id: NEW_ID, index: 0, metadata: { origin: 'USER' } });
|
|
266
|
+
}
|
|
267
|
+
if (url.pathname === `${POLICIES_PATH}/${NEW_ID}` && method === 'GET') {
|
|
268
|
+
// The read-back disagrees with what was declared (e.g. the console normalized `enabled`).
|
|
269
|
+
return Response.json({
|
|
270
|
+
...HF_PROPS,
|
|
271
|
+
id: NEW_ID,
|
|
272
|
+
index: 0,
|
|
273
|
+
metadata: { origin: 'USER' },
|
|
274
|
+
enabled: false,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return fakeFailure(400, 'unexpected request');
|
|
278
|
+
});
|
|
279
|
+
const failure = await Effect.runPromise(
|
|
280
|
+
Effect.flip(ops.reconcile(HF_PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
281
|
+
);
|
|
282
|
+
expect(failure).toBeInstanceOf(UnifiCreateDidNotConverge);
|
|
283
|
+
expect((failure as UnifiCreateDidNotConverge).fields).toEqual(['enabled']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('an hf:-named row with an id that 404s refuses -- never silently creates a duplicate (Opus red team)', async () => {
|
|
287
|
+
// ★ THE SCENARIO THE RED TEAM FOUND: an hf:-named row that ALREADY has Alchemy state's
|
|
288
|
+
// `firewallPolicyId` but whose live object is gone (state lost and rebuilt with no `olds`/
|
|
289
|
+
// `output`, a hand-deleted-and-recreated rule, a wrong id in a generated declaration) must
|
|
290
|
+
// still refuse "create" -- not fall through and POST a second copy just because the name
|
|
291
|
+
// happens to start `hf: ` (Tim's own hand-made console rules already do).
|
|
292
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-gone' };
|
|
293
|
+
const fake = fakeUnifi((method, url) =>
|
|
294
|
+
method === 'GET' && url.pathname === `${POLICIES_PATH}/fp-gone`
|
|
295
|
+
? fakeFailure(404, 'not found')
|
|
296
|
+
: fakeFailure(400, 'unexpected request'),
|
|
297
|
+
);
|
|
298
|
+
const failure = await Effect.runPromise(
|
|
299
|
+
Effect.flip(ops.reconcile(hfWithId).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
|
|
300
|
+
);
|
|
301
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
302
|
+
expect((failure as UnifiWriteRefused).action).toBe('create');
|
|
303
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('hfFirewallPolicy throws immediately when props already carries a firewallPolicyId', () => {
|
|
307
|
+
const withId = { ...HF_PROPS, firewallPolicyId: 'fp-1' };
|
|
308
|
+
expect(() => hfFirewallPolicy('bad', withId)).toThrow(/already carries a firewallPolicyId/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('update (drift) of an existing hf: row still refuses -- the lift is create only', async () => {
|
|
312
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-hf-1' };
|
|
313
|
+
const fake = fakeUnifi((method, url) =>
|
|
314
|
+
method === 'GET' && url.pathname === `${POLICIES_PATH}/fp-hf-1`
|
|
315
|
+
? Response.json({
|
|
316
|
+
...HF_PROPS,
|
|
317
|
+
id: 'fp-hf-1',
|
|
318
|
+
index: 0,
|
|
319
|
+
metadata: { origin: 'USER' },
|
|
320
|
+
enabled: false,
|
|
321
|
+
})
|
|
322
|
+
: fakeFailure(400, 'unexpected request'),
|
|
323
|
+
);
|
|
324
|
+
const exit = await Effect.runPromiseExit(
|
|
325
|
+
ops.reconcile(hfWithId).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
326
|
+
);
|
|
327
|
+
expect(exit._tag).toBe('Failure');
|
|
328
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('delete of an hf: row still refuses -- no DELETE is ever sent', async () => {
|
|
332
|
+
const hfWithId: FirewallPolicyProps = { ...HF_PROPS, firewallPolicyId: 'fp-hf-1' };
|
|
333
|
+
const exit = await Effect.runPromiseExit(ops.destroy(hfWithId));
|
|
334
|
+
expect(exit._tag).toBe('Failure');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('hfFirewallPolicy throws immediately for a name that is not hf:-prefixed', () => {
|
|
338
|
+
expect(() => hfFirewallPolicy('bad', { ...HF_PROPS, name: 'Not hf-named' })).toThrow(/hf: /);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
155
342
|
describe("T9/B0b -- pageAll walks this family's own page shape (decode proof)", () => {
|
|
156
343
|
test('every row across several pages of getFirewallPolicies decodes, in order', async () => {
|
|
157
344
|
const rows = Array.from({ length: 7 }, (_, i) =>
|