@homeflare/alchemy 0.39.0 → 0.40.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/README.md +10 -8
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1609 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +15 -6
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/qemu-volume.d.ts +12 -2
- package/dist/proxmox/qemu-volume.d.ts.map +1 -1
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +7 -0
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +588 -3
- package/dist/unifi/index.js.map +24 -4
- package/dist/unifi/paginate.d.ts +0 -12
- package/dist/unifi/paginate.d.ts.map +1 -1
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +10 -5
- package/package.json +6 -2
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/index.ts +1 -0
- package/src/proxmox/qemu-volume.test.ts +153 -4
- package/src/proxmox/qemu-volume.ts +71 -14
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/index.ts +63 -0
- package/src/unifi/paginate.test.ts +24 -0
- package/src/unifi/paginate.ts +15 -3
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +8 -54
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# UniFi Traffic Matching Lists — `Unifi.TrafficMatchingList`
|
|
2
|
+
|
|
3
|
+
⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** Same posture as `docs/unifi.md`'s `Unifi.Network`/
|
|
4
|
+
`Unifi.FirewallZone`: `reconcile`/`delete` both fail with a typed `UnifiWriteRefused`, and the
|
|
5
|
+
`GetOnlyHttpClient` wire guard (`resource.ts`) refuses any non-`GET` request before it reaches the
|
|
6
|
+
transport. This family never calls `createTrafficMatchingList`, `updateTrafficMatchingList` or
|
|
7
|
+
`deleteTrafficMatchingList` anywhere — `write-op-reference.test.ts` proves it statically.
|
|
8
|
+
|
|
9
|
+
## The simplest object shape in this directory
|
|
10
|
+
|
|
11
|
+
`TrafficMatchingList` is `{id, name, type, items?}` — no `metadata` field at all, unlike every
|
|
12
|
+
other family here (`FirewallZone`/`DNSPolicy`/`ACLRule`/`FirewallPolicy` all carry a server-derived
|
|
13
|
+
`metadata.origin`). Nothing in `TrafficMatchingListAttributes` is attributes-only the way
|
|
14
|
+
`metadataOrigin`/`index` are elsewhere: every field this family reads is also declarable.
|
|
15
|
+
|
|
16
|
+
## `items` is a real TS union post-A3, compared wholesale
|
|
17
|
+
|
|
18
|
+
`items` decodes as `Array<IPv4Matching> | Array<IPv6Matching> | Array<PortMatching>`, keyed by the
|
|
19
|
+
sibling `type` field (A3, `8ee11f1`) — previously opaque `unknown`. The runtime schema is still a
|
|
20
|
+
passthrough `S.Unknown` (Argo CD's `union:` callback): narrower for a reader at the TYPE level only,
|
|
21
|
+
so decode keeps every key including one this pinned spec doesn't yet declare, the same proof
|
|
22
|
+
`discriminated-filter-decode.test.ts` runs for `FirewallPolicy`/`AclRule`.
|
|
23
|
+
|
|
24
|
+
⚠️ **Known gap (T15, same shape `acl-rule-form.ts`/`docs/unifi-firewall-policy.md` both carry)**: a
|
|
25
|
+
traffic matching list's `items` is semantically a SET of match entries — matching is "is this
|
|
26
|
+
traffic covered by ANY entry," not order-dependent — but each entry is an OBJECT
|
|
27
|
+
(`{type, value?, start?, stop?}`), not a bare string, so there is no cheap canonical sort key the
|
|
28
|
+
way `sortedSet` gives a `string[]`. `deepEqual` compares `items` order-sensitively, so a
|
|
29
|
+
console-side reorder with no membership change would report a spurious drift this read-only
|
|
30
|
+
family's `reconcile` then refuses forever. Accepted, not fixed: a generic object-set normalizer is
|
|
31
|
+
bigger, separate modelling work, not a mechanical extension of typing `items` per-discriminator.
|
|
32
|
+
|
|
33
|
+
## T11 gate: per-tag closure diff, 10.4.57 vs 10.6.97 (checked 2026-09-26)
|
|
34
|
+
|
|
35
|
+
Same method `docs/unifi-acl-rule.md`/`docs/unifi-firewall-policy.md` use, run here for the
|
|
36
|
+
`Traffic Matching Lists` tag's 5 operations (`getTrafficMatchingLists`, `createTrafficMatchingList`,
|
|
37
|
+
`deleteTrafficMatchingList`, `getTrafficMatchingList`, `updateTrafficMatchingList`).
|
|
38
|
+
|
|
39
|
+
- **Pin**: `~/.cache/homeflare/schemas/unifi/network_v10.4.57_openapi.json`, re-verified against
|
|
40
|
+
its own `SHA256SUMS` today (`3773947b…c9df59d`).
|
|
41
|
+
- **Mirror**: `raw.githubusercontent.com/beezly/unifi-apis/main/unifi-network/10.6.97.json`,
|
|
42
|
+
diffing aid only, never a spec of record — re-fetched today, still `info.version: "10.6.97"`,
|
|
43
|
+
`sha256: 44fd0e7f81603bb1279889a528a443d8f1a0102436e4a29675d8451adf32bd78`, unchanged since mirror
|
|
44
|
+
commit `f979dd64ec0ea6008c2a698c278fe49a10e102c8` (Andrew Beresford, 2026-08-20, "Add UniFi API
|
|
45
|
+
specs: Network 10.6.97", GitHub-verified signature) — the same file every other family doc cites.
|
|
46
|
+
- **Operation count**: both versions carry exactly the same 5 operations for `Traffic Matching
|
|
47
|
+
Lists` — zero added, zero removed, either direction.
|
|
48
|
+
- **Full raw operation objects** (parameters, request body, responses, unresolved `$ref`s included):
|
|
49
|
+
byte-identical for all 5, both versions.
|
|
50
|
+
- **Reachability** (`$ref` AND `discriminator.mapping`): the closure is **19 schema names**
|
|
51
|
+
(`IPv4Matching`/`IPv6Matching`/`Port matching` and each of the three list-shape Dto pairs, plus
|
|
52
|
+
their subnet/range/address variant Dtos), identical set in both versions. **Zero overlap** with
|
|
53
|
+
the 14 schemas that changed elsewhere in the document — re-diffed all 19 with an
|
|
54
|
+
order-independent structural compare: **zero differ**.
|
|
55
|
+
- **Corroboration**: the A3 changeset's own broader re-check
|
|
56
|
+
(`.changeset/unifi-network-a3-typed-discriminator-filters.md`) independently walked all 25
|
|
57
|
+
FirewallPolicy/ACL rule/TrafficMatchingList operations together — a 139-schema closure, also
|
|
58
|
+
byte-identical between versions — against a mirror fetch the same day; see
|
|
59
|
+
`docs/unifi-firewall-policy.md`'s own T11 section for why that citation's different-looking
|
|
60
|
+
mirror commit is the same unchanged file.
|
|
61
|
+
|
|
62
|
+
**Answer: `TrafficMatchingList` would not decode differently against 10.6.97.** This does not
|
|
63
|
+
generalize past 10.6.97 or to any other tag — re-run this check, following BOTH `$ref` and
|
|
64
|
+
`discriminator.mapping`, before importing or declaring against `Clients`, `Switching`, or any tag
|
|
65
|
+
not yet covered by an existing `docs/unifi-*.md`.
|
|
66
|
+
|
|
67
|
+
## The declaration renderer
|
|
68
|
+
|
|
69
|
+
`declareTrafficMatchingList(live, siteId)` is a pure function, same contract as every other
|
|
70
|
+
family's own (`docs/unifi.md`): given one `getTrafficMatchingList` response, it returns the `Props`
|
|
71
|
+
a declaration needs so its plan is a no-op.
|
|
72
|
+
|
|
73
|
+
## Field-level drift
|
|
74
|
+
|
|
75
|
+
`traffic-matching-list.ts` exports a pure `driftOf(live, props)` (`traffic-matching-list-drift.ts`),
|
|
76
|
+
built on the same `drift.ts`'s `makeDriftOf` framework as every other family — `matches` is exactly
|
|
77
|
+
`fieldDrift(...).length === 0`, never a second hand-written comparison (MEDIUM-4).
|
|
78
|
+
|
|
79
|
+
## Vendor version
|
|
80
|
+
|
|
81
|
+
UniFi Network Integration API **10.4.57**, same pin as `docs/unifi.md`, via
|
|
82
|
+
`@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.3.0`.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# UniFi WiFi Broadcasts — `Unifi.WifiBroadcast`
|
|
2
|
+
|
|
3
|
+
⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** Same posture as `docs/unifi.md`'s `Unifi.Network`/
|
|
4
|
+
`Unifi.FirewallZone`: `reconcile`/`delete` both fail with a typed `UnifiWriteRefused`, and the
|
|
5
|
+
`GetOnlyHttpClient` wire guard (`resource.ts`) refuses any non-`GET` request before it reaches the
|
|
6
|
+
transport. This family calls no SDK write op anywhere — `write-op-reference.test.ts` proves it
|
|
7
|
+
statically, and `wifi-broadcast.test.ts`'s own write-path tests prove `reconcile`/`destroy` never
|
|
8
|
+
send anything but `GET`s.
|
|
9
|
+
|
|
10
|
+
## T23: the passphrase-bearing details call is never made
|
|
11
|
+
|
|
12
|
+
The pinned spec's WiFi Broadcast "details" shape (`getWifiBroadcastDetails`, and the create/update
|
|
13
|
+
request bodies) — `WifiSecurityConfigurationDetailObject` — carries the WPA passphrase AND every
|
|
14
|
+
PPSK entry's own passphrase, with **no `writeOnly` flag on either** (verified: 4 `passphrase`
|
|
15
|
+
properties, 0 `writeOnly` flags, in the staged 10.4.57 spec). `README.md`'s claim that these fields
|
|
16
|
+
are "write-only" was wrong; A2 (kit PR #300) fixed the SDK layer by decoding them `Redacted`
|
|
17
|
+
instead of a plain string, proven at the wire in `errors-and-secrets.test.ts`.
|
|
18
|
+
|
|
19
|
+
This family goes further and **never calls that shape at all.** The only SDK call
|
|
20
|
+
`wifi-broadcast.ts` makes is `getWifiBroadcastPage` — the LIST endpoint, which answers
|
|
21
|
+
`WifiBroadcastOverview` rows. That type's own `securityConfiguration`
|
|
22
|
+
(`WifiSecurityConfigurationOverview`) is `{type, presharedKeyNetworkIds}`: no passphrase field
|
|
23
|
+
exists on it, so there is no DECLARED field for `attributesOf`/`declareWifiBroadcast` to name a
|
|
24
|
+
passphrase into. A static guard (`wifi-broadcast-details-call.test.ts`) additionally bans any
|
|
25
|
+
source reference to `getWifiBroadcastDetails` itself under `src/unifi` (red team, IMPORTANT-2),
|
|
26
|
+
with one named exception: `errors-and-secrets.test.ts` (A2) calls it directly on purpose, to prove
|
|
27
|
+
the SDK's own redaction happens at the wire — a different, lower-layer claim than this family ever
|
|
28
|
+
needing to make that call.
|
|
29
|
+
|
|
30
|
+
⛔ **This is NOT enforced by the type system alone** (red team, IMPORTANT-1) — corrected from an
|
|
31
|
+
earlier version of this doc that overclaimed it was. `@distilled.cloud/core`'s wire decode does not
|
|
32
|
+
strip a key a schema doesn't declare, so a stray key nested inside `network`, `hotspotConfiguration`,
|
|
33
|
+
`broadcastingDeviceFilter`, or one `presharedKeyNetworkIds` element could ride through a naive
|
|
34
|
+
pass-through exactly like the top-level `securityConfiguration` object could. See "The stray-key
|
|
35
|
+
defense" below — enumeration at runtime, not the TS annotation, is what actually stops it.
|
|
36
|
+
|
|
37
|
+
### No get-by-id call exists for the overview shape
|
|
38
|
+
|
|
39
|
+
Unlike every other family here (`getNetworkDetails`, `getFirewallZone`, `getDnsPolicy`,
|
|
40
|
+
`getAclRule`), `wifi_broadcasts.ts` has no single-object GET that returns the overview shape — only
|
|
41
|
+
the list (safe) and the details call (forbidden above). `wifi-broadcast.ts`'s `fetchLive` walks
|
|
42
|
+
every page with B0b's `pageAll` (`paginate.ts`) and finds the row matching `wifiBroadcastId` — the
|
|
43
|
+
first real resource-level consumer of that pager, previously exercised only in isolation by
|
|
44
|
+
`paginate.test.ts`. A genuine site-not-found 404 on ANY page of the walk — not only the first —
|
|
45
|
+
folds to `undefined` like every other family, discarding whatever rows the walk had already
|
|
46
|
+
collected (red team, MINOR-4: an earlier version of this doc said "on the first page call", which
|
|
47
|
+
overstated how narrowly the catch is scoped); the target id simply not being among the collected
|
|
48
|
+
rows folds to `undefined` too — there is no separate vendor signal to tell any of these cases apart,
|
|
49
|
+
and no other family's `fetchLive` has one either.
|
|
50
|
+
|
|
51
|
+
### The stray-key defense (why `normalizeSecurityConfiguration` never spreads)
|
|
52
|
+
|
|
53
|
+
Nothing in `@distilled.cloud/core`'s wire key-mapping strips an object key the schema does not
|
|
54
|
+
declare — it only renames declared ones (measured directly against the installed
|
|
55
|
+
`@distilled.cloud/core@1.0.0-rc.12`: a struct decode keeps every key from the raw response,
|
|
56
|
+
declared or not). So a stray `passphrase`-shaped key on the WIRE `securityConfiguration` object — a
|
|
57
|
+
vendor bug, or a future console version — physically survives into the `live` object `fetchLive`
|
|
58
|
+
returns, even though `WifiBroadcastOverview`'s own TS type has no such field.
|
|
59
|
+
|
|
60
|
+
`wifi-broadcast-form.ts`'s `normalizeSecurityConfiguration` is the one normalizer in this whole
|
|
61
|
+
`Unifi.*` tree that does **not** use `{...value}` (every sibling normalizer, `network-form.ts`'s
|
|
62
|
+
included, spreads its input). It enumerates exactly `{type, presharedKeyNetworkIds}` instead —
|
|
63
|
+
`attributesOf`/`declareWifiBroadcast` are built the same way, field by field, never `{...live}`.
|
|
64
|
+
|
|
65
|
+
⛔ **The same discipline applies to every OTHER nested object this family handles, not just
|
|
66
|
+
`securityConfiguration`** (red team, IMPORTANT-1) — `normalizeRef` rebuilds `network` and each
|
|
67
|
+
`presharedKeyNetworkIds` element as `{type, networkId?}`, `normalizeHotspot` rebuilds
|
|
68
|
+
`hotspotConfiguration` as `{type}`, and `normalizeDeviceFilter` no longer spreads
|
|
69
|
+
`broadcastingDeviceFilter` either. A `presharedKeyNetworkIds` element is the single most plausible
|
|
70
|
+
leak site of the four: `IntegrationWifiPresharedKeyDto`, the details-call shape for a PPSK entry, is
|
|
71
|
+
literally `{network: WifiNetworkReference, passphrase}` — the same reference shape this family's
|
|
72
|
+
overview also uses, just with one more field the overview type doesn't declare.
|
|
73
|
+
|
|
74
|
+
`wifi-broadcast-secrets.test.ts`'s sentinel tests prove the pipeline for all five locations
|
|
75
|
+
(`securityConfiguration`, a `presharedKeyNetworkIds` element, `network`, `hotspotConfiguration`,
|
|
76
|
+
`broadcastingDeviceFilter`): each plants a raw sentinel key that genuinely does survive into `live`
|
|
77
|
+
(asserted directly, not assumed away), then proves `attributesOf`, `declareWifiBroadcast`, and the
|
|
78
|
+
literal string a later import script would render into `alchemy.run.ts` none of them carry it
|
|
79
|
+
forward.
|
|
80
|
+
|
|
81
|
+
### The forced decode-failure test
|
|
82
|
+
|
|
83
|
+
`wifi-broadcast-secrets.test.ts`'s decode-failure test sends a non-JSON 200 body (a genuine decode
|
|
84
|
+
failure — this SDK's REST decode has no schema-validating step to reject a malformed body with; see
|
|
85
|
+
`@distilled.cloud/core`'s `protocol-rest.ts`) that happens to contain a sentinel value, the way a
|
|
86
|
+
garbled proxy or debug response might. `pageAll`'s own consistency check (`paginate.ts`) catches it
|
|
87
|
+
— the malformed "page" has no `.offset`, so the walk refuses to trust it — and fails typed as
|
|
88
|
+
`UnifiPaginationInconsistent`. That error's `reason`/`detail`/`message` are built from `offset`/
|
|
89
|
+
`totalCount` values run through `paginate.ts`'s `numOrPlaceholder` (red team, MINOR-3: an earlier
|
|
90
|
+
version of this doc said these fields were "never from the raw response body", but `offset`/
|
|
91
|
+
`totalCount` themselves ARE unvalidated wire values — `paginate.test.ts`'s own test proves a
|
|
92
|
+
non-numeric one renders as a fixed placeholder, never as itself), so none of them can ever echo
|
|
93
|
+
attacker- or vendor-controlled text regardless of what the server actually sent.
|
|
94
|
+
|
|
95
|
+
## Field-level drift
|
|
96
|
+
|
|
97
|
+
Three fields are membership SETS, not ordered sequences, by the vendor description's own wording
|
|
98
|
+
("Defines the custom scope of devices…") and unconfirmed against a live console (same caveat
|
|
99
|
+
`network-form.ts` carries for its own three): `broadcastingDeviceFilter.deviceIds`/`.deviceTagIds`
|
|
100
|
+
and `securityConfiguration.presharedKeyNetworkIds`. `wifi-broadcast-form.ts`'s
|
|
101
|
+
`normalizeDeviceFilter`/`normalizeSecurityConfiguration`/`normalizeFrequencies` sort+dedupe them
|
|
102
|
+
before comparison, on both the live AND declared sides (`wifi-broadcast-drift.ts`), the same
|
|
103
|
+
`firewall-zone-form.ts`'s `networkIds` pattern — otherwise a console returning the same set in a
|
|
104
|
+
different order would plan a spurious `update` that a read-only `reconcile` then refuses.
|
|
105
|
+
|
|
106
|
+
`wifi-broadcast.ts` exports a pure `driftOf(live, props)`, built on `drift.ts`'s `makeDriftOf`
|
|
107
|
+
framework. `matches` is exactly `fieldDrift(...).length === 0` (MEDIUM-4).
|
|
108
|
+
|
|
109
|
+
## The declaration renderer
|
|
110
|
+
|
|
111
|
+
`declareWifiBroadcast(live, siteId)` is a pure function: given one `WifiBroadcastOverview` row (as
|
|
112
|
+
`getWifiBroadcastPage` returns it, never `getWifiBroadcastDetails`), it returns the `Props` a
|
|
113
|
+
declaration needs so its plan is a no-op. A later import script pages every WiFi broadcast on a
|
|
114
|
+
site and writes one `wifiBroadcast('<id>', declareWifiBroadcast(live, siteId))` row per result —
|
|
115
|
+
nothing here performs that write.
|
|
116
|
+
|
|
117
|
+
## Vendor version
|
|
118
|
+
|
|
119
|
+
UniFi Network Integration API **10.4.57**, same pin as `docs/unifi.md`, via
|
|
120
|
+
`@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.3.0`.
|
package/docs/unifi.md
CHANGED
|
@@ -6,6 +6,11 @@ with a typed `UnifiWriteRefused` naming the policy rather than calling any SDK w
|
|
|
6
6
|
Lifting the rule is a kit change — a PR that adds a write path, reviewed as one — not a flag a
|
|
7
7
|
stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
|
|
8
8
|
|
|
9
|
+
This page covers `Unifi.Network`/`Unifi.FirewallZone` only. Every other read-only family has its
|
|
10
|
+
own doc, per family (`docs/unifi.md` would breach the 200-line cap otherwise): [`Unifi.DnsPolicy`](./unifi-dns-policy.md), [`Unifi.AclRule`/`Unifi.AclRuleOrdering`](./unifi-acl-rule.md),
|
|
11
|
+
[`Unifi.WifiBroadcast`](./unifi-wifi.md), [`Unifi.FirewallPolicy`/`Unifi.FirewallPolicyOrdering`](./unifi-firewall-policy.md),
|
|
12
|
+
and [`Unifi.TrafficMatchingList`](./unifi-traffic-matching-list.md).
|
|
13
|
+
|
|
9
14
|
## What's declared, and why these two first
|
|
10
15
|
|
|
11
16
|
`Unifi.Network` (`getNetworkDetails`/`getNetworksOverviewPage`) and `Unifi.FirewallZone`
|
|
@@ -13,9 +18,8 @@ stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
|
|
|
13
18
|
not runtime state (a connected client, a device statistic, a hotspot voucher, a switch stack).
|
|
14
19
|
`Site` was the SDK README's other first suggestion; it has no `getSite`, only the list operation,
|
|
15
20
|
so it fails this house's own "both a list and a get" bar for a first import. `FirewallPolicy` is
|
|
16
|
-
also a list+get config object
|
|
17
|
-
|
|
18
|
-
endpoint replaces the whole rule list — modelling that correctly is a bigger, separate PR.
|
|
21
|
+
also a list+get config object; modelling it correctly needed its own PR (now shipped — [`docs/unifi-firewall-policy.md`](./unifi-firewall-policy.md)) because its ordering endpoint replaces the whole
|
|
22
|
+
rule list per zone pair, and it was gated on A3 typing its filter fields' discriminator variants first.
|
|
19
23
|
|
|
20
24
|
## Credentials
|
|
21
25
|
|
|
@@ -102,8 +106,9 @@ operations (`getNetworksOverviewPage`, `getFirewallZones`, …), none of which p
|
|
|
102
106
|
own — no `smithy.api#paginated` trait exists in the pinned spec, and neither the SDK nor
|
|
103
107
|
`@distilled.cloud/core` gained one for this. It stops on the response's `totalCount`, never on
|
|
104
108
|
`data.length < limit` (a page can legitimately come back short of the requested limit without
|
|
105
|
-
being the last page).
|
|
106
|
-
`
|
|
109
|
+
being the last page). `Unifi.WifiBroadcast` (`docs/unifi-wifi.md`) is its first resource-level
|
|
110
|
+
consumer, walking every page because that family has no get-by-id call at all; `docs/unifi-firewall-policy.md`'s own decode-proof test exercises the same page shape for `getFirewallPolicies`
|
|
111
|
+
(hundreds of rows on a real console — T9), though `Unifi.FirewallPolicy` itself still reads by id.
|
|
107
112
|
|
|
108
113
|
It also fails with a typed `UnifiPaginationInconsistent` — never silently returns a wrong row
|
|
109
114
|
set — the moment a paged response contradicts itself: a page's echoed `offset` doesn't match what
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -56,6 +56,10 @@
|
|
|
56
56
|
"types": "./dist/proxmox/index.d.ts",
|
|
57
57
|
"default": "./dist/proxmox/index.js"
|
|
58
58
|
},
|
|
59
|
+
"./ceph": {
|
|
60
|
+
"types": "./dist/ceph/index.d.ts",
|
|
61
|
+
"default": "./dist/ceph/index.js"
|
|
62
|
+
},
|
|
59
63
|
"./launchd": {
|
|
60
64
|
"types": "./dist/launchd/index.d.ts",
|
|
61
65
|
"default": "./dist/launchd/index.js"
|
|
@@ -131,7 +135,7 @@
|
|
|
131
135
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
132
136
|
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.3",
|
|
133
137
|
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
|
|
134
|
-
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.
|
|
138
|
+
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.4.0"
|
|
135
139
|
},
|
|
136
140
|
"peerDependencies": {
|
|
137
141
|
"@distilled.cloud/argocd": "1.0.0-rc.12",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Ceph.AuthEntity` (K-A4) — a Ceph client auth entity (`client.k8s-*`), minted over the ssh
|
|
3
|
+
* mon-command transport and stored only in OpenBao. Design: docs/plans/2026-09-26-ceph-mon-
|
|
4
|
+
* transport.md, "`Ceph.AuthEntity` (K-A4)"; the loop itself lives in ceph-auth-reconcile.ts, the
|
|
5
|
+
* transport in ceph-transport.ts, the allowlist in ceph-argv.ts.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `read` AND `diff` NEVER SSH. "A plan never elevates" (../linux/sudo-runner.ts's own header
|
|
8
|
+
* makes the same rule for the file family): this family's props/state comparison is the whole
|
|
9
|
+
* of `diff`, and `read` hands back exactly the attributes state already holds. Live drift is
|
|
10
|
+
* caught only at `reconcile` (which does elevate, on the admin lane) and by the post-deploy
|
|
11
|
+
* operator check — this family cannot prove live convergence at plan time, and says so rather
|
|
12
|
+
* than pretending an unprivileged probe exists where none does.
|
|
13
|
+
* ⛔ `delete` IS REFUSED UNCONDITIONALLY. `defaultRemovalPolicy: 'retain'` keeps Alchemy from ever
|
|
14
|
+
* calling it on an ordinary `destroy`, and the handler itself refuses too — belt and suspenders
|
|
15
|
+
* with ceph-argv.ts's own refusal of the `auth del` shape, so there is no path, `--adopt` or a
|
|
16
|
+
* resource-level removal-policy override included, that ever reaches it (D3).
|
|
17
|
+
* ⛔ NEVER ADOPTED. See ceph-auth-reconcile.ts's header for why the generic `refuseTakeover` is not
|
|
18
|
+
* used here.
|
|
19
|
+
* ⛔ ROWS ARE CREATES THROUGH THE FIRST-CREATE GATE, NO `adopt()` — `list` answers empty, the same
|
|
20
|
+
* as every Ceph family in this kit (ceph-pool.ts's header has the reason a non-empty list would
|
|
21
|
+
* invite exactly the takeover this family refuses).
|
|
22
|
+
*/
|
|
23
|
+
import { Resource } from 'alchemy';
|
|
24
|
+
import { isResolved } from 'alchemy/Diff';
|
|
25
|
+
import * as Provider from 'alchemy/Provider';
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
28
|
+
import type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
|
|
29
|
+
import { planCephAuthEntity } from './ceph-auth-form.ts';
|
|
30
|
+
import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
|
|
31
|
+
|
|
32
|
+
export type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
|
|
33
|
+
export type { CephCaps } from './ceph-auth-parse.ts';
|
|
34
|
+
|
|
35
|
+
export interface CephAuthEntity extends Resource<
|
|
36
|
+
'Ceph.AuthEntity',
|
|
37
|
+
CephAuthEntityProps,
|
|
38
|
+
CephAuthEntityAttributes,
|
|
39
|
+
never,
|
|
40
|
+
HttpClient.HttpClient
|
|
41
|
+
> {}
|
|
42
|
+
|
|
43
|
+
/** ★ `retain` — the delete handler below refuses regardless, but this stops Alchemy calling it. */
|
|
44
|
+
export const CephAuthEntity = Resource<CephAuthEntity>('Ceph.AuthEntity', {
|
|
45
|
+
defaultRemovalPolicy: 'retain',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const refusedDelete = (entity: string): Error =>
|
|
49
|
+
new Error(
|
|
50
|
+
`Ceph.AuthEntity ${entity}: 'ceph auth del' is refused entirely — lockout safety, never ` +
|
|
51
|
+
'auto-delete (D3). Nothing was sent to any mon. If the entity truly must go, remove it by ' +
|
|
52
|
+
'hand, deliberately, along with its OpenBao key — this family will never do it for you.',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const CephAuthEntityProvider = () =>
|
|
56
|
+
Provider.effect(
|
|
57
|
+
CephAuthEntity,
|
|
58
|
+
Effect.succeed(
|
|
59
|
+
CephAuthEntity.Provider.of({
|
|
60
|
+
list: () => Effect.succeed([]),
|
|
61
|
+
|
|
62
|
+
/** State only — see the header. Never `Unowned`: this family is never adopted. */
|
|
63
|
+
read: ({ output }) => Effect.succeed(output),
|
|
64
|
+
|
|
65
|
+
/** State only — see the header. `output === undefined` lets reconcile decide (a create). */
|
|
66
|
+
diff: ({ news, output }) =>
|
|
67
|
+
Effect.succeed(isResolved(news) ? planCephAuthEntity(news, output) : undefined),
|
|
68
|
+
|
|
69
|
+
reconcile: ({ news, output }) => reconcileCephAuthEntity(news, output, {}),
|
|
70
|
+
|
|
71
|
+
delete: ({ output }) => Effect.die(refusedDelete(output.entity)),
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ceph config get/set/rm` half of the argv allowlist — split out of ceph-argv.ts (which
|
|
3
|
+
* re-exports `configProblem`) purely to keep that file under the house's 250-line file cap; the
|
|
4
|
+
* split follows the same line the design docs already draw (2026-09-26-ceph-config-options.md is
|
|
5
|
+
* its own companion to 2026-09-26-ceph-mon-transport.md).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ `<who>` IS NOT YET BOUNDED TO K-D's "declared daemons" (the config-options design's own
|
|
8
|
+
* acceptance test #1). `client.admin` still matches this shape. Latent today:
|
|
9
|
+
* `NAMED_CONFIG_OPTIONS` starts empty, so `namedOptionProblem` refuses every `config get/set/rm`
|
|
10
|
+
* regardless of `who` — LAND red team, 2026-09-26, flagged this as armed only once K-D's first
|
|
11
|
+
* named option lands, and narrowing it needs that family's own declared-daemon list, which does
|
|
12
|
+
* not exist yet. Tracked for `CephConfigOption` (K-D), not fixed here.
|
|
13
|
+
*/
|
|
14
|
+
import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
|
|
15
|
+
|
|
16
|
+
/** `config`'s `<who>`: a daemon class, optionally `.id` — never a path, never free text. */
|
|
17
|
+
const WHO = /^(global|mon|osd|mgr|mds|client)(\.[A-Za-z0-9_-]{1,32})?$/;
|
|
18
|
+
/** A ceph option name's own shape — snake_case, lowercase. Independent of the named list below. */
|
|
19
|
+
const OPTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
20
|
+
/**
|
|
21
|
+
* No shell metacharacters; the named list (below) is what actually decides secrecy, not this.
|
|
22
|
+
* ⛔ NO LEADING `-` EXCEPT A NEGATIVE INTEGER, AND NEVER EMPTY. LAND red team (2026-09-26),
|
|
23
|
+
* CONFIRMED: the prior pattern let a config VALUE start with `-`, and this argv reaches
|
|
24
|
+
* `sudo -n /usr/bin/ceph` with no shell in between — no shell means no quoting boundary, so
|
|
25
|
+
* `ceph config set global x -o/etc/ceph/ceph.conf` parses as ceph's own `-o <file>` flag, not
|
|
26
|
+
* as the string `-o/etc/ceph/ceph.conf`. Accepted flag-shaped values included
|
|
27
|
+
* `--admin-daemon=/var/run/ceph/ceph-mon.a.asok`, `--conf=/tmp/x`, `-n`, and the empty string.
|
|
28
|
+
* The sudoers entry has no argument restriction of its own (that is D4, a later step), so this
|
|
29
|
+
* allowlist is the only thing standing between a config value and an arbitrary ceph CLI flag.
|
|
30
|
+
* Latent while `NAMED_CONFIG_OPTIONS` is empty; K-D's first entry arms it.
|
|
31
|
+
*/
|
|
32
|
+
const CONFIG_VALUE = /^(?:[A-Za-z0-9][A-Za-z0-9 ._:/=,-]{0,255}|-[0-9]{1,255})$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `undefined` when `value` is a safe ceph config value — exported (like `boundedEntityProblem`)
|
|
36
|
+
* so the shape itself is directly testable, since `NAMED_CONFIG_OPTIONS` being empty today means
|
|
37
|
+
* `cephCommandProblem` never reaches this check on any real argv (`namedOptionProblem` refuses
|
|
38
|
+
* first) — this stays exercised until K-D's first named option arms the path for real.
|
|
39
|
+
*/
|
|
40
|
+
export const configValueProblem = (value: string): string | undefined =>
|
|
41
|
+
CONFIG_VALUE.test(value)
|
|
42
|
+
? undefined
|
|
43
|
+
: `config value ${JSON.stringify(value)} contains characters outside the safe set`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `undefined` when `name` may be declared, adopted, read by value or written — see
|
|
47
|
+
* ceph-config-options.ts's header for why the list starts empty and stays reviewed line by line.
|
|
48
|
+
*/
|
|
49
|
+
const namedOptionProblem = (name: string): string | undefined => {
|
|
50
|
+
if (!OPTION_NAME.test(name)) return `${JSON.stringify(name)} is not a plain ceph option name`;
|
|
51
|
+
if (isLockoutConfigOption(name))
|
|
52
|
+
return `${name} is a lockout-class option and can never be declared`;
|
|
53
|
+
return NAMED_CONFIG_OPTIONS.has(name)
|
|
54
|
+
? undefined
|
|
55
|
+
: `${name} is not on the named option list (it starts empty — see ceph-config-options.ts)`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** `config get|set|rm <who> <name> [value]`, called with the operands after `config` stripped. */
|
|
59
|
+
export const configProblem = (rest: readonly string[]): string | undefined => {
|
|
60
|
+
const [verb, who, name, ...extra] = rest;
|
|
61
|
+
if (who === undefined || !WHO.test(who)) {
|
|
62
|
+
return 'config takes a bounded `<who>` (global/mon/osd/mgr/mds/client[.id])';
|
|
63
|
+
}
|
|
64
|
+
if (name === undefined) return 'config takes a `<name>` from the named option list';
|
|
65
|
+
if (verb === 'get' || verb === 'rm') {
|
|
66
|
+
return extra.length === 0
|
|
67
|
+
? namedOptionProblem(name)
|
|
68
|
+
: `config ${String(verb)} takes exactly \`<who> <name>\``;
|
|
69
|
+
}
|
|
70
|
+
if (verb === 'set') {
|
|
71
|
+
const [value, ...more] = extra;
|
|
72
|
+
if (value === undefined || more.length !== 0)
|
|
73
|
+
return 'config set takes exactly `<who> <name> <value>`';
|
|
74
|
+
const named = namedOptionProblem(name);
|
|
75
|
+
return named !== undefined ? named : configValueProblem(value);
|
|
76
|
+
}
|
|
77
|
+
return `ceph config ${String(verb)} is not on the allowlist`;
|
|
78
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ceph allowlist, as pure argv checks — no ssh, no fake dial. Every acceptance is a shape
|
|
3
|
+
* ceph-argv.ts's own builders actually produce; every refusal is a shape that would do real damage
|
|
4
|
+
* (a printed key, a cut mon, a de-authenticated cluster) if it ever reached `sudo -n ceph`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import {
|
|
8
|
+
CEPH_BIN,
|
|
9
|
+
authCapsArgv,
|
|
10
|
+
authGetArgv,
|
|
11
|
+
authGetOrCreateArgv,
|
|
12
|
+
boundedEntityProblem,
|
|
13
|
+
cephCommandProblem,
|
|
14
|
+
configValueProblem,
|
|
15
|
+
quorumStatusArgv,
|
|
16
|
+
} from './ceph-argv.ts';
|
|
17
|
+
|
|
18
|
+
const ENTITY = 'client.k8s-rbd';
|
|
19
|
+
const CAPS = {
|
|
20
|
+
mgr: 'profile rbd pool=k8s-rbd',
|
|
21
|
+
mon: 'profile rbd',
|
|
22
|
+
osd: 'profile rbd pool=k8s-rbd',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('the allowed shapes, byte-for-byte', () => {
|
|
26
|
+
test('auth get', () => {
|
|
27
|
+
expect(cephCommandProblem(authGetArgv(ENTITY))).toBeUndefined();
|
|
28
|
+
expect(authGetArgv(ENTITY)).toEqual([CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'json']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('auth get-or-create', () => {
|
|
32
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
|
|
33
|
+
expect(authGetOrCreateArgv(ENTITY, CAPS)).toEqual([
|
|
34
|
+
CEPH_BIN,
|
|
35
|
+
'auth',
|
|
36
|
+
'get-or-create',
|
|
37
|
+
ENTITY,
|
|
38
|
+
'mon',
|
|
39
|
+
CAPS.mon,
|
|
40
|
+
'osd',
|
|
41
|
+
CAPS.osd,
|
|
42
|
+
'mgr',
|
|
43
|
+
CAPS.mgr,
|
|
44
|
+
'-f',
|
|
45
|
+
'json',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('auth caps', () => {
|
|
50
|
+
expect(cephCommandProblem(authCapsArgv(ENTITY, CAPS))).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('quorum_status', () => {
|
|
54
|
+
expect(cephCommandProblem(quorumStatusArgv())).toBeUndefined();
|
|
55
|
+
expect(quorumStatusArgv()).toEqual([CEPH_BIN, 'quorum_status', '-f', 'json']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the entity prefix', () => {
|
|
60
|
+
test('accepts a bounded client.k8s- name', () => {
|
|
61
|
+
expect(boundedEntityProblem('client.k8s-rbd')).toBeUndefined();
|
|
62
|
+
expect(boundedEntityProblem('client.k8s-cephfs')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each([
|
|
66
|
+
['the cluster admin keyring', 'client.admin'],
|
|
67
|
+
['a mon keyring', 'mon.a'],
|
|
68
|
+
['an osd keyring', 'osd.0'],
|
|
69
|
+
['an mgr keyring', 'mgr.a'],
|
|
70
|
+
['the PVE storage client', 'client.pve-storage'],
|
|
71
|
+
['a bare client prefix', 'client.k8s-'],
|
|
72
|
+
['no client prefix at all', 'k8s-rbd'],
|
|
73
|
+
])('refuses %s', (_name, entity) => {
|
|
74
|
+
expect(boundedEntityProblem(entity)).toBeString();
|
|
75
|
+
expect(cephCommandProblem(authGetArgv(entity))).toBeString();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('shell metacharacters never reach the allowlist as data', () => {
|
|
80
|
+
test.each([
|
|
81
|
+
['in the entity', authGetArgv('client.k8s-rbd; rm -rf /')],
|
|
82
|
+
['in a cap, backtick', authGetOrCreateArgv(ENTITY, { ...CAPS, osd: 'profile rbd `id`' })],
|
|
83
|
+
['in a cap, command substitution', authCapsArgv(ENTITY, { ...CAPS, mon: '$(whoami)' })],
|
|
84
|
+
['in a cap, a pipe', authCapsArgv(ENTITY, { ...CAPS, mgr: 'profile rbd | cat /etc/passwd' })],
|
|
85
|
+
[
|
|
86
|
+
'in a cap, a semicolon',
|
|
87
|
+
authCapsArgv(ENTITY, { ...CAPS, mon: 'profile rbd; ceph auth del client.admin' }),
|
|
88
|
+
],
|
|
89
|
+
])('refuses %s', (_name, argv) => {
|
|
90
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('K-A4 finding 3: caps are the design’s literal shape, not a general grammar', () => {
|
|
95
|
+
test.each([
|
|
96
|
+
['plain rwx, no profile at all', 'osd', 'allow rwx'],
|
|
97
|
+
['a non-rbd profile', 'osd', 'profile osd'],
|
|
98
|
+
['bootstrap-osd', 'osd', 'profile bootstrap-osd'],
|
|
99
|
+
['a comma-chained extra grant', 'osd', 'profile rbd pool=k8s-rbd, allow rwx'],
|
|
100
|
+
['osd rbd with no pool — every pool, VM disks included', 'osd', 'profile rbd'],
|
|
101
|
+
['mgr rbd with no pool', 'mgr', 'profile rbd'],
|
|
102
|
+
['mon widened past the plain profile', 'mon', 'profile rbd pool=k8s-rbd'],
|
|
103
|
+
])('refuses %s (%s cap %j)', (_name, daemon, value) => {
|
|
104
|
+
const caps = { ...CAPS, [daemon]: value };
|
|
105
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, caps))).toBeString();
|
|
106
|
+
expect(cephCommandProblem(authCapsArgv(ENTITY, caps))).toBeString();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the design’s own three caps are still accepted', () => {
|
|
110
|
+
expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('the never-allowed auth verbs', () => {
|
|
115
|
+
test('auth ls is refused, naming the reason', () => {
|
|
116
|
+
const problem = cephCommandProblem([CEPH_BIN, 'auth', 'ls', '-f', 'json']);
|
|
117
|
+
expect(problem).toContain('prints every key');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('auth del is refused, naming lockout safety, for any entity', () => {
|
|
121
|
+
for (const entity of [ENTITY, 'client.admin', 'mon.a']) {
|
|
122
|
+
const problem = cephCommandProblem([CEPH_BIN, 'auth', 'del', entity, '-f', 'json']);
|
|
123
|
+
expect(problem).toContain('never auto-delete');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('shape mismatches', () => {
|
|
129
|
+
test.each([
|
|
130
|
+
['bare ceph, no group', [CEPH_BIN]],
|
|
131
|
+
['an unknown group', [CEPH_BIN, 'status', '-f', 'json']],
|
|
132
|
+
['auth get with an extra operand', [CEPH_BIN, 'auth', 'get', ENTITY, 'extra', '-f', 'json']],
|
|
133
|
+
['auth get with an unexpected flag', [CEPH_BIN, 'auth', 'get', ENTITY, '--yes', '-f', 'json']],
|
|
134
|
+
[
|
|
135
|
+
'auth get-or-create missing a daemon',
|
|
136
|
+
[CEPH_BIN, 'auth', 'get-or-create', ENTITY, 'mon', CAPS.mon, '-f', 'json'],
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
'auth caps with daemons out of order',
|
|
140
|
+
[
|
|
141
|
+
CEPH_BIN,
|
|
142
|
+
'auth',
|
|
143
|
+
'caps',
|
|
144
|
+
ENTITY,
|
|
145
|
+
'osd',
|
|
146
|
+
CAPS.osd,
|
|
147
|
+
'mon',
|
|
148
|
+
CAPS.mon,
|
|
149
|
+
'mgr',
|
|
150
|
+
CAPS.mgr,
|
|
151
|
+
'-f',
|
|
152
|
+
'json',
|
|
153
|
+
],
|
|
154
|
+
],
|
|
155
|
+
['missing the -f json suffix', [CEPH_BIN, 'auth', 'get', ENTITY]],
|
|
156
|
+
['-f without json', [CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'plain']],
|
|
157
|
+
['a relative ceph, not absolute', ['ceph', 'auth', 'get', ENTITY, '-f', 'json']],
|
|
158
|
+
['quorum_status with an operand', [CEPH_BIN, 'quorum_status', ENTITY, '-f', 'json']],
|
|
159
|
+
])('refuses %s', (_name, argv) => {
|
|
160
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('config: the named list starts empty, so every shape is refused today', () => {
|
|
165
|
+
test.each([
|
|
166
|
+
['get', [CEPH_BIN, 'config', 'get', 'global', 'mon_allow_pool_delete', '-f', 'json']],
|
|
167
|
+
['set', [CEPH_BIN, 'config', 'set', 'global', 'mon_allow_pool_delete', 'true', '-f', 'json']],
|
|
168
|
+
['rm', [CEPH_BIN, 'config', 'rm', 'global', 'mon_allow_pool_delete', '-f', 'json']],
|
|
169
|
+
])('config %s is refused: not on the named list', (_verb, argv) => {
|
|
170
|
+
const problem = cephCommandProblem(argv);
|
|
171
|
+
expect(problem).toContain('named option list');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a lockout-class name is refused even if someone tried to add it', () => {
|
|
175
|
+
const problem = cephCommandProblem([
|
|
176
|
+
CEPH_BIN,
|
|
177
|
+
'config',
|
|
178
|
+
'get',
|
|
179
|
+
'mon',
|
|
180
|
+
'mon_host',
|
|
181
|
+
'-f',
|
|
182
|
+
'json',
|
|
183
|
+
]);
|
|
184
|
+
expect(problem).toContain('lockout-class');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('an unbounded `who` is refused', () => {
|
|
188
|
+
const problem = cephCommandProblem([
|
|
189
|
+
CEPH_BIN,
|
|
190
|
+
'config',
|
|
191
|
+
'get',
|
|
192
|
+
'literally anything',
|
|
193
|
+
'x',
|
|
194
|
+
'-f',
|
|
195
|
+
'json',
|
|
196
|
+
]);
|
|
197
|
+
expect(problem).toBeString();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a config value with a shell metacharacter is refused', () => {
|
|
201
|
+
const argv = [CEPH_BIN, 'config', 'set', 'global', 'x', 'true; rm -rf /', '-f', 'json'];
|
|
202
|
+
expect(cephCommandProblem(argv)).toBeString();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('K-A4 finding 4: configValueProblem never accepts a flag-shaped value', () => {
|
|
207
|
+
test.each([
|
|
208
|
+
['a short flag', '-n'],
|
|
209
|
+
['a file-overwrite flag glued to its argument', '-o/etc/ceph/ceph.conf'],
|
|
210
|
+
['a long flag with =', '--admin-daemon=/var/run/ceph/ceph-mon.a.asok'],
|
|
211
|
+
['another long flag', '--conf=/tmp/x'],
|
|
212
|
+
['the empty string', ''],
|
|
213
|
+
['a bare dash', '-'],
|
|
214
|
+
])('refuses %s (%j)', (_name, value) => {
|
|
215
|
+
expect(configValueProblem(value)).toBeString();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a negative integer is still a plain value, not a flag', () => {
|
|
219
|
+
expect(configValueProblem('-42')).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('an ordinary value is accepted', () => {
|
|
223
|
+
expect(configValueProblem('true')).toBeUndefined();
|
|
224
|
+
expect(configValueProblem('3600')).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
});
|