@celilo/cli 1.7.0 → 1.8.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/CELILO_CORE_MODULES.md +1 -0
- package/CELILO_SUBSYSTEMS.md +5 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/schema.ts +21 -4
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +19 -1
- package/src/policy/capability-shape-baseline.ts +88 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +117 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +32 -7
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +72 -1
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +6 -2
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.ts +0 -5
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -37,6 +37,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
37
37
|
- **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
|
|
38
38
|
- **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
|
|
39
39
|
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
|
|
40
|
+
- **dnsmasq-dhcp** — a DHCP-only dnsmasq in **`internal`**, providing the zone-scoped `dhcp_server` capability for a network with no upstream router to serve it. **provides:** `dhcp_server` (zone `internal`). The other two `dhcp_server` providers, **axon** and **greenwave**, do not run a DHCP server at all — they drive an ISP router's built-in one over its API, which works only while the home network shares a broadcast domain with that router. Put `internal` behind the celilo firewall and DHCP discovery, being a broadcast, no longer reaches it: celilo could describe that topology and not make it work. Importing this module at all depends on well-known-provider uniqueness being scoped PER ZONE (celilo#1028), since axon/greenwave already claim `dhcp_server` fleet-wide. **`port=0` is load-bearing**: dnsmasq ships a resolver as well as a DHCP server, and `dns_internal` is a different capability with its own providers — two resolvers answering on one network is a split-brain that is very hard to see from outside. celilo's transactional artifacts go to `/var/lib/celilo/config-state/` via `applyRenderedConfig`'s `statePath`, NOT beside the managed fragment, because dnsmasq parses every file in `/etc/dnsmasq.d` and a `.celilo-bak` there is configuration the daemon reads. It declares a `requires.system` and is placed by celilo like any other module; an earlier shape declared none, which meant no host in the inventory, an `apt-get` shelled over SSH from a hook, and an operator-typed address — and which also evaded `validateMachineRoleForModule`, the check that refuses to place a non-firewall module on a multi-interface router.
|
|
40
41
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
41
42
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
42
43
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -32,7 +32,11 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
32
32
|
- **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/specs/network-declaration/spec.md`.
|
|
33
33
|
- **The network write path (closed) + celilo's own discovery** — `celilo system apply-config` (`apps/celilo/src/cli/commands/system-apply-config.ts`) REFUSES the whole `network.` namespace, `network.bridge` excepted (a Proxmox bridge name is not addressing, and it is the one network key with a schema default). That is the automation surface a module hook shells out to, so closing it there is what makes "networks are celilo's" an authority rather than a convention every module has to remember. The refusal names the alternative — declare the network, read it with `$system:` — because a bare rejection sends a module author hunting for a typo. The one write that legitimately needed the surface moved INTO celilo: `celilo system discover-network` (`apps/celilo/src/services/network-discovery.ts`, `cli/commands/system-discover-network.ts`) parses the box's own `ip route` and records `network.internal.*` — or `network.secure-mgmt.*` when the box is off the internal LAN (#300). `celilo-mgmt` calls it and decides nothing; it used to parse and write this itself. Idempotent and never overwrites addressing already set. Recurrence gate: `test-integration/module/no-module-writes-networks.test.ts`.
|
|
34
34
|
- **Firewall interface audit** — `apps/celilo/src/services/audit/interface-classification.ts` — reports per-firewall classification in `celilo audit`: alien interfaces by name and address (drift), and the blocking findings a converge refuses on — a carrier-grade NAT leg, an ambiguous external edge, a default route on the wrong leg.
|
|
35
|
-
- **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone.
|
|
35
|
+
- **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone. Two copies are unavoidable and BOTH are guarded: `modules/iptables`' `zones` picker (`test-integration/module/zone-options-cover-network-zones.test.ts` fails on any drift, and did when `isp-transit` was added to core alone) and the generated `schemas/module-manifest.schema.json` (`bun run check:schema`, run from `apps/celilo` — a FOURTH gate CI runs that the "Validation gates" list in CLAUDE.md does not name).
|
|
36
|
+
- **`isp-transit`** — the private segment between a celilo firewall and the router upstream of it. Non-placement and non-allocatable (it joins `external` and `control-plane-vpn` in the excluded set) because that router owns the addressing. It exists because on a DOWNSTREAM firewall the default route and the workload network share one interface, so `internal` meant both at once and every egress rule also reasoned about the workload network. A firewall with no separate transit leg declares `default_route_zone: internal` and behaves as it always did.
|
|
37
|
+
- **Declared egress zone** — `modules/iptables` `default_route_zone`, consumed by `defaultRouteFinding` in `packages/capabilities/src/interface-classification.ts` — celilo already classifies whichever interface carries the default route; what it could not do is tell an INTENDED egress leg from a drifted one, so the comparison was hardcoded to "internal or external". Declaring the intent makes a wandered default route a finding. Defaults to `internal`.
|
|
38
|
+
- **Capability-secret access is reference-driven** — `apps/celilo/src/capabilities/validation.ts` — `validateCapabilityAccess` fires on the `$capability:<name>.<secret>` paths the CONSUMER references, collected from its manifest AND from the templates `validateModuleTemplates` already parses at import. It used to iterate the secrets the PROVIDER declares, so requiring a capability that merely carries a secret put a module on the hook for that secret's `readable_by` list (celilo#854, celilo#1038). Reading a secret still goes through `checkCapabilitySecretAccess` at resolution time.
|
|
39
|
+
- **Zone-first capability provider selection** — `apps/celilo/src/capabilities/lookup.ts` — one shared selection function used by both the direct lookup and the hook capability loader, which had drifted apart. Well-known-provider uniqueness is scoped per zone, which is what lets a second `dhcp_server` provider exist at all while `axon`/`greenwave` claim it fleet-wide.
|
|
36
40
|
- **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
|
|
37
41
|
- **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
|
|
38
42
|
- **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- `dns_internal_records` cascades on its PROVIDER as well as its consumer, so
|
|
2
|
+
-- swapping the internal DNS provider deletes the fleet's entire internal DNS
|
|
3
|
+
-- ledger — including the `zone_routable_ip` view overrides the resolver's
|
|
4
|
+
-- split-horizon config is reconciled from (celilo#1010).
|
|
5
|
+
--
|
|
6
|
+
-- `web_routes` does not do this. Its rows cascade on the consumer only, which is
|
|
7
|
+
-- why a `public_web` provider swap keeps every route. The two tables' docblocks
|
|
8
|
+
-- claim to be siblings and nothing marks the difference as a decision, because it
|
|
9
|
+
-- was not one.
|
|
10
|
+
--
|
|
11
|
+
-- The claim on a capability-owned table is the CONSUMER
|
|
12
|
+
-- (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
|
|
13
|
+
-- declaration cannot express anything else. This makes the table agree with it.
|
|
14
|
+
--
|
|
15
|
+
-- `provider_module_id` STAYS. It is how a record is attributed to the resolver
|
|
16
|
+
-- serving it and it is half the uniqueness key; only its ON DELETE action is
|
|
17
|
+
-- wrong. It becomes a plain column with no foreign key rather than a cascade,
|
|
18
|
+
-- so removing a provider leaves the ledger intact for the next one to reconcile
|
|
19
|
+
-- from — which is the behaviour stage 1's provider-arrival backfill assumes.
|
|
20
|
+
--
|
|
21
|
+
-- SQLite cannot alter a foreign key, so the table is rebuilt. Order matters:
|
|
22
|
+
-- `foreign_keys` is ON during migration, so the copy is made BEFORE the drop and
|
|
23
|
+
-- the index is recreated after the rename. Same shape as
|
|
24
|
+
-- `0021_dns_registration_consumers`.
|
|
25
|
+
|
|
26
|
+
CREATE TABLE `__new_dns_internal_records` (
|
|
27
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
28
|
+
`provider_module_id` text NOT NULL,
|
|
29
|
+
`consumer_module_id` text NOT NULL,
|
|
30
|
+
`host` text NOT NULL,
|
|
31
|
+
`ip` text NOT NULL,
|
|
32
|
+
`zone_routable_ip` text,
|
|
33
|
+
`registered_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
34
|
+
FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
|
|
35
|
+
);
|
|
36
|
+
--> statement-breakpoint
|
|
37
|
+
INSERT INTO `__new_dns_internal_records` SELECT `id`, `provider_module_id`, `consumer_module_id`, `host`, `ip`, `zone_routable_ip`, `registered_at` FROM `dns_internal_records`;
|
|
38
|
+
--> statement-breakpoint
|
|
39
|
+
DROP TABLE `dns_internal_records`;
|
|
40
|
+
--> statement-breakpoint
|
|
41
|
+
ALTER TABLE `__new_dns_internal_records` RENAME TO `dns_internal_records`;
|
|
42
|
+
--> statement-breakpoint
|
|
43
|
+
CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
|
|
@@ -190,6 +190,13 @@
|
|
|
190
190
|
"when": 1784200000000,
|
|
191
191
|
"tag": "0026_module_integrity_version",
|
|
192
192
|
"breakpoints": true
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"idx": 27,
|
|
196
|
+
"version": "6",
|
|
197
|
+
"when": 1784300000000,
|
|
198
|
+
"tag": "0027_dns_internal_records_consumer_cascade",
|
|
199
|
+
"breakpoints": true
|
|
193
200
|
}
|
|
194
201
|
]
|
|
195
|
-
}
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^2.
|
|
61
|
+
"@celilo/capabilities": "^2.6.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.9.1",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -824,5 +824,56 @@ describe('Capability Access Validation', () => {
|
|
|
824
824
|
expect(refused.success).toBe(false);
|
|
825
825
|
expect(refused.error).toContain('tsig_key');
|
|
826
826
|
});
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* A reference in a TEMPLATE counts too. `resolver.ts` does refuse one at
|
|
830
|
+
* the point of use, so nothing is unsafe without this, but the refusal
|
|
831
|
+
* lands at generation rather than at import.
|
|
832
|
+
*
|
|
833
|
+
* That gap sits exactly where the repo sends people. CLAUDE.md's module
|
|
834
|
+
* Definition of Done says "Capability variable usage — Templates use
|
|
835
|
+
* `$capability:` syntax" and gives a `.tf.tpl` example, so the first author
|
|
836
|
+
* who follows that instruction with a RESTRICTED secret is the one who
|
|
837
|
+
* finds out late. No module in the tree references a capability from a
|
|
838
|
+
* template today, which is why the gap is currently invisible rather than
|
|
839
|
+
* absent.
|
|
840
|
+
*/
|
|
841
|
+
test('a secret named only in a template is refused at import', async () => {
|
|
842
|
+
const manifest: ModuleManifest = {
|
|
843
|
+
celilo_contract: '1.0',
|
|
844
|
+
id: 'nosy-app',
|
|
845
|
+
name: 'Nosy App',
|
|
846
|
+
version: '1.0.0',
|
|
847
|
+
description: 'Test',
|
|
848
|
+
requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
|
|
849
|
+
provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
|
|
850
|
+
variables: { owns: [], imports: [] },
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// The manifest names nothing. Only the .tf.tpl does.
|
|
854
|
+
const result = await validateCapabilityAccess(manifest, knotDb, ['dns_internal.tsig_key']);
|
|
855
|
+
|
|
856
|
+
expect(result.success).toBe(false);
|
|
857
|
+
expect(result.error).toContain("cannot access secret 'tsig_key'");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('a non-secret template reference is still not a secret reference', async () => {
|
|
861
|
+
const manifest: ModuleManifest = {
|
|
862
|
+
celilo_contract: '1.0',
|
|
863
|
+
id: 'caddy-internal',
|
|
864
|
+
name: 'Caddy (fleet-only ingress)',
|
|
865
|
+
version: '1.0.0',
|
|
866
|
+
description: 'Test',
|
|
867
|
+
requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
|
|
868
|
+
provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
|
|
869
|
+
variables: { owns: [], imports: [] },
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const result = await validateCapabilityAccess(manifest, knotDb, [
|
|
873
|
+
'dns_internal.server.ip.primary',
|
|
874
|
+
]);
|
|
875
|
+
|
|
876
|
+
expect(result.success).toBe(true);
|
|
877
|
+
});
|
|
827
878
|
});
|
|
828
879
|
});
|
|
@@ -21,11 +21,16 @@ export interface ValidationResult {
|
|
|
21
21
|
*
|
|
22
22
|
* @param manifest - Consumer module manifest
|
|
23
23
|
* @param db - Database connection
|
|
24
|
+
* @param templateReferences - `<capability>.<path>` references found outside
|
|
25
|
+
* the manifest, i.e. in the module's templates. Empty is the honest default
|
|
26
|
+
* for a caller holding no template context; `module import` passes what the
|
|
27
|
+
* template validator already parsed.
|
|
24
28
|
* @returns Validation result
|
|
25
29
|
*/
|
|
26
30
|
export async function validateCapabilityAccess(
|
|
27
31
|
manifest: ModuleManifest,
|
|
28
32
|
db: Database,
|
|
33
|
+
templateReferences: readonly string[] = [],
|
|
29
34
|
): Promise<ValidationResult> {
|
|
30
35
|
// If module doesn't require capabilities, validation passes
|
|
31
36
|
if (!manifest.requires?.capabilities || manifest.requires.capabilities.length === 0) {
|
|
@@ -35,7 +40,7 @@ export async function validateCapabilityAccess(
|
|
|
35
40
|
// Get list of capabilities this module provides
|
|
36
41
|
const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name);
|
|
37
42
|
|
|
38
|
-
const references = collectCapabilityReferences(manifest);
|
|
43
|
+
const references = collectCapabilityReferences(manifest, templateReferences);
|
|
39
44
|
|
|
40
45
|
// Check each required capability
|
|
41
46
|
for (const requiredCapability of manifest.requires.capabilities) {
|
|
@@ -105,16 +110,25 @@ export async function validateCapabilityAccess(
|
|
|
105
110
|
* lives — a `variables.owns[].derive_from`, a default, a capability data block —
|
|
106
111
|
* without this having to track which fields may hold one.
|
|
107
112
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
113
|
+
* Template references are included too, supplied by the caller. `resolver.ts`
|
|
114
|
+
* does refuse a template reference at the point of use, so nothing is unsafe
|
|
115
|
+
* without them, but the refusal lands at generation rather than at import. That
|
|
116
|
+
* matters because a module's TEMPLATES are where this repo tells authors to put
|
|
117
|
+
* these: the Definition of Done in CLAUDE.md says "Capability variable usage —
|
|
118
|
+
* Templates use `$capability:` syntax" and gives a `.tf.tpl` example. A gate
|
|
119
|
+
* that does not fire where the documentation sends people is a gate with a hole
|
|
120
|
+
* in the shape of the instructions.
|
|
111
121
|
*/
|
|
112
|
-
function collectCapabilityReferences(
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
function collectCapabilityReferences(
|
|
123
|
+
manifest: ModuleManifest,
|
|
124
|
+
templateReferences: readonly string[] = [],
|
|
125
|
+
): Set<string> {
|
|
126
|
+
return new Set([
|
|
127
|
+
...parseVariables(JSON.stringify(manifest))
|
|
115
128
|
.filter((variable) => variable.type === 'capability')
|
|
116
129
|
.map((variable) => variable.path),
|
|
117
|
-
|
|
130
|
+
...templateReferences,
|
|
131
|
+
]);
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
/**
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration gate for celilo#1010, over a database that already holds records.
|
|
3
|
+
*
|
|
4
|
+
* `0027` rebuilds `dns_internal_records` to drop the foreign key on
|
|
5
|
+
* `provider_module_id`. A rebuild is copy, drop, rename — the most destructive
|
|
6
|
+
* shape a migration takes — and it will run on celilo-mgr, which holds the live
|
|
7
|
+
* internal DNS ledger including the `zone_routable_ip` view overrides the
|
|
8
|
+
* resolver's split-horizon config is reconciled from.
|
|
9
|
+
*
|
|
10
|
+
* Every other test in the suite starts from an empty database and can only prove
|
|
11
|
+
* the forward invariant. This one builds a database at the schema BEFORE the
|
|
12
|
+
* change, puts real rows in it, and runs the real migrator over it — the same
|
|
13
|
+
* shape as `dns-registrations-migration.test.ts`, and for the same reason: the
|
|
14
|
+
* installed base is where the risk is, and a clean-start suite cannot see it.
|
|
15
|
+
*
|
|
16
|
+
* Task 2.6 asks for backup and restore proven before this runs on celilo-mgr.
|
|
17
|
+
* This is the stronger half of that: it proves the migration PRESERVES the rows,
|
|
18
|
+
* so a restore is the fallback rather than the plan.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Database } from 'bun:sqlite';
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
23
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
27
|
+
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
28
|
+
import { findMigrationsFolder } from './client';
|
|
29
|
+
|
|
30
|
+
/** The last migration before this change — the schema celilo-mgr is on today. */
|
|
31
|
+
const LAST_LEGACY_TAG = '0026_module_integrity_version';
|
|
32
|
+
|
|
33
|
+
interface JournalEntry {
|
|
34
|
+
idx: number;
|
|
35
|
+
version: string;
|
|
36
|
+
when: number;
|
|
37
|
+
tag: string;
|
|
38
|
+
breakpoints: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A migrations folder truncated at `LAST_LEGACY_TAG`, built from the real files. */
|
|
42
|
+
function legacyMigrationsFolder(into: string): string {
|
|
43
|
+
const source = findMigrationsFolder();
|
|
44
|
+
const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as {
|
|
45
|
+
version: string;
|
|
46
|
+
dialect: string;
|
|
47
|
+
entries: JournalEntry[];
|
|
48
|
+
};
|
|
49
|
+
const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG);
|
|
50
|
+
if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`);
|
|
51
|
+
const kept = journal.entries.slice(0, cutoff + 1);
|
|
52
|
+
|
|
53
|
+
mkdirSync(join(into, 'meta'), { recursive: true });
|
|
54
|
+
for (const entry of kept) {
|
|
55
|
+
cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`));
|
|
56
|
+
}
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(into, 'meta', '_journal.json'),
|
|
59
|
+
JSON.stringify({ ...journal, entries: kept }, null, 2),
|
|
60
|
+
);
|
|
61
|
+
return into;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('0027 over a database that already holds an internal DNS ledger', () => {
|
|
65
|
+
let tempDir: string;
|
|
66
|
+
let dbPath: string;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsint-'));
|
|
70
|
+
dbPath = join(tempDir, 'legacy.db');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function openLegacyDatabase(): Database {
|
|
78
|
+
const sqlite = new Database(dbPath, { create: true });
|
|
79
|
+
sqlite.run('PRAGMA foreign_keys = ON');
|
|
80
|
+
migrate(drizzle(sqlite), {
|
|
81
|
+
migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')),
|
|
82
|
+
});
|
|
83
|
+
return sqlite;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function seedModule(sqlite: Database, id: string): void {
|
|
87
|
+
sqlite.run(
|
|
88
|
+
'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)',
|
|
89
|
+
[id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })],
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function seedLedger(sqlite: Database): void {
|
|
94
|
+
seedModule(sqlite, 'technitium');
|
|
95
|
+
seedModule(sqlite, 'knot-unbound-internal');
|
|
96
|
+
seedModule(sqlite, 'caddy');
|
|
97
|
+
seedModule(sqlite, 'forgejo');
|
|
98
|
+
sqlite.run(
|
|
99
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, ?)',
|
|
100
|
+
['technitium', 'caddy', 'auth.example.org', '192.168.0.253', '10.0.10.14'],
|
|
101
|
+
);
|
|
102
|
+
sqlite.run(
|
|
103
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, NULL)',
|
|
104
|
+
['technitium', 'forgejo', 'git.example.org', '192.168.0.253'],
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runCurrentMigrations(sqlite: Database): void {
|
|
109
|
+
migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('the rebuild carries every row across, values intact', () => {
|
|
113
|
+
const sqlite = openLegacyDatabase();
|
|
114
|
+
seedLedger(sqlite);
|
|
115
|
+
|
|
116
|
+
runCurrentMigrations(sqlite);
|
|
117
|
+
|
|
118
|
+
const rows = sqlite
|
|
119
|
+
.query<
|
|
120
|
+
{ host: string; ip: string; zone_routable_ip: string | null; provider_module_id: string },
|
|
121
|
+
[]
|
|
122
|
+
>(
|
|
123
|
+
'SELECT host, ip, zone_routable_ip, provider_module_id FROM dns_internal_records ORDER BY host',
|
|
124
|
+
)
|
|
125
|
+
.all();
|
|
126
|
+
expect(rows).toHaveLength(2);
|
|
127
|
+
expect(rows[0]?.host).toBe('auth.example.org');
|
|
128
|
+
// The override is the value whose loss is silent: the resolver keeps
|
|
129
|
+
// answering, just with an address in-zone clients cannot route to.
|
|
130
|
+
expect(rows[0]?.zone_routable_ip).toBe('10.0.10.14');
|
|
131
|
+
expect(rows[1]?.zone_routable_ip).toBeNull();
|
|
132
|
+
// Attribution survives; only its ON DELETE action changed.
|
|
133
|
+
expect(rows[0]?.provider_module_id).toBe('technitium');
|
|
134
|
+
sqlite.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('after migrating, removing the PROVIDER no longer empties the ledger', () => {
|
|
138
|
+
const sqlite = openLegacyDatabase();
|
|
139
|
+
seedLedger(sqlite);
|
|
140
|
+
runCurrentMigrations(sqlite);
|
|
141
|
+
|
|
142
|
+
sqlite.run('DELETE FROM modules WHERE id = ?', ['technitium']);
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
sqlite.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM dns_internal_records').get()?.c,
|
|
146
|
+
).toBe(2);
|
|
147
|
+
sqlite.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The behaviour that did NOT change. Dropping a foreign key is an easy way to
|
|
152
|
+
* lose the one you meant to keep, and nothing else would notice.
|
|
153
|
+
*/
|
|
154
|
+
test('the consumer cascade still fires after the rebuild', () => {
|
|
155
|
+
const sqlite = openLegacyDatabase();
|
|
156
|
+
seedLedger(sqlite);
|
|
157
|
+
runCurrentMigrations(sqlite);
|
|
158
|
+
|
|
159
|
+
sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']);
|
|
160
|
+
|
|
161
|
+
const left = sqlite
|
|
162
|
+
.query<{ consumer_module_id: string }, []>(
|
|
163
|
+
'SELECT consumer_module_id FROM dns_internal_records',
|
|
164
|
+
)
|
|
165
|
+
.all();
|
|
166
|
+
expect(left.map((r) => r.consumer_module_id)).toEqual(['forgejo']);
|
|
167
|
+
sqlite.close();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** The unique index is recreated by the rebuild, not left behind with the old table. */
|
|
171
|
+
test('the provider+host uniqueness survives the rebuild', () => {
|
|
172
|
+
const sqlite = openLegacyDatabase();
|
|
173
|
+
seedLedger(sqlite);
|
|
174
|
+
runCurrentMigrations(sqlite);
|
|
175
|
+
|
|
176
|
+
expect(() =>
|
|
177
|
+
sqlite.run(
|
|
178
|
+
'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip) VALUES (?, ?, ?, ?)',
|
|
179
|
+
['technitium', 'forgejo', 'auth.example.org', '192.168.0.253'],
|
|
180
|
+
),
|
|
181
|
+
).toThrow();
|
|
182
|
+
sqlite.close();
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/db/schema.ts
CHANGED
|
@@ -801,16 +801,33 @@ export const publicDnsEvidence = sqliteTable('public_dns_evidence', {
|
|
|
801
801
|
*
|
|
802
802
|
* `celilo system doctor` reads this to assert service hostnames resolve to
|
|
803
803
|
* the firewall natIp (LAN-reachable) and not a zone-side container IP that
|
|
804
|
-
* a LAN device can't route to. Rows die with
|
|
804
|
+
* a LAN device can't route to. Rows die with their CONSUMER via FK cascade, and
|
|
805
|
+
* NOT with their provider (celilo#1010 — see `providerModuleId`).
|
|
805
806
|
* @owner capability:dns_internal — resolver configuration; migrates to the provider (T6)
|
|
806
807
|
*/
|
|
807
808
|
export const dnsInternalRecords = sqliteTable(
|
|
808
809
|
'dns_internal_records',
|
|
809
810
|
{
|
|
810
811
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
812
|
+
/**
|
|
813
|
+
* The resolver serving this record. A PLAIN column with no foreign key, and
|
|
814
|
+
* that is the celilo#1010 correction rather than an oversight.
|
|
815
|
+
*
|
|
816
|
+
* It used to cascade, so swapping `technitium` for `knot-unbound-internal`
|
|
817
|
+
* deleted the fleet's entire internal DNS ledger, `zone_routable_ip` view
|
|
818
|
+
* overrides included. `web_routes` cascades on its consumer only and the two
|
|
819
|
+
* docblocks claimed to be siblings, so the divergence read as intent and was
|
|
820
|
+
* not. The claim on a capability-owned table is the CONSUMER
|
|
821
|
+
* (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
|
|
822
|
+
* declaration cannot express anything else.
|
|
823
|
+
*
|
|
824
|
+
* Attribution is still real and still enforced — it is half
|
|
825
|
+
* `dns_internal_records_provider_host_idx` — it just no longer decides when a
|
|
826
|
+
* LIVE record is forgotten. A provider leaving now leaves the ledger for the
|
|
827
|
+
* next one to reconcile from, which is what stage 1's provider-arrival
|
|
828
|
+
* backfill assumes. Migration `0027`.
|
|
829
|
+
*/
|
|
830
|
+
providerModuleId: text('provider_module_id').notNull(),
|
|
814
831
|
consumerModuleId: text('consumer_module_id')
|
|
815
832
|
.notNull()
|
|
816
833
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
@@ -258,4 +258,51 @@ resource "local_file" "test" {
|
|
|
258
258
|
expect(errors[0]?.variable).toBe('$self:invalid_var');
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The capability-secret gate at import consumes these, so what happens when
|
|
264
|
+
* templates cannot be read is a security property rather than a detail. It
|
|
265
|
+
* has to FAIL CLOSED: an unreadable tree must abort the import, never hand
|
|
266
|
+
* the gate an empty reference set that reads as "this module references no
|
|
267
|
+
* secrets". It was already true here — but true by inspection, which is the
|
|
268
|
+
* weakest way for a security property to be true.
|
|
269
|
+
*/
|
|
270
|
+
describe('capability references, and failing closed', () => {
|
|
271
|
+
test('collects $capability: references from templates', async () => {
|
|
272
|
+
const dir = await mkdtemp(join(tmpdir(), 'celilo-tplrefs-'));
|
|
273
|
+
try {
|
|
274
|
+
await mkdir(join(dir, 'terraform'), { recursive: true });
|
|
275
|
+
await writeFile(
|
|
276
|
+
join(dir, 'terraform', 'main.tf.tpl'),
|
|
277
|
+
'acme_dns = "$capability:dns_internal.tsig_key"\nzone = "$capability:dns_internal.dns.domain"\n',
|
|
278
|
+
);
|
|
279
|
+
const manifest = createTestManifest({
|
|
280
|
+
requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await validateModuleTemplates(dir, manifest);
|
|
284
|
+
|
|
285
|
+
expect(result.capabilityReferences.sort()).toEqual([
|
|
286
|
+
'dns_internal.dns.domain',
|
|
287
|
+
'dns_internal.tsig_key',
|
|
288
|
+
]);
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(dir, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('an unreadable module tree fails, and yields no references', async () => {
|
|
295
|
+
// A path that does not exist stands in for any read failure. The PAIRING
|
|
296
|
+
// is the property: success:false travels WITH the empty array, and
|
|
297
|
+
// `import.ts` returns on !success before the access gate runs, so the
|
|
298
|
+
// empty set can never be mistaken for "nothing referenced".
|
|
299
|
+
const result = await validateModuleTemplates(
|
|
300
|
+
join(tmpdir(), 'celilo-no-such-module-tree-9f3a2b'),
|
|
301
|
+
createTestManifest(),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(result.success).toBe(false);
|
|
305
|
+
expect(result.capabilityReferences).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
261
308
|
});
|
|
@@ -19,6 +19,12 @@ export interface TemplateValidationError {
|
|
|
19
19
|
export interface TemplateValidationResult {
|
|
20
20
|
success: boolean;
|
|
21
21
|
errors: TemplateValidationError[];
|
|
22
|
+
/**
|
|
23
|
+
* `<capability>.<path>` paths the templates reference. The capability-access
|
|
24
|
+
* check at import consumes these, so a secret referenced only from a template
|
|
25
|
+
* is refused at import rather than later at generation.
|
|
26
|
+
*/
|
|
27
|
+
capabilityReferences: string[];
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -277,10 +283,16 @@ export async function validateModuleTemplates(
|
|
|
277
283
|
// Find all .tpl files
|
|
278
284
|
const templateFiles = await findTemplateFiles(modulePath, modulePath);
|
|
279
285
|
|
|
280
|
-
// Validate each template file
|
|
286
|
+
// Validate each template file. Every one is parsed here anyway, so the
|
|
287
|
+
// capability references fall out of work already being done — no second
|
|
288
|
+
// walk of the module tree.
|
|
289
|
+
const capabilityReferences = new Set<string>();
|
|
281
290
|
for (const relativePath of templateFiles) {
|
|
282
291
|
const fullPath = join(modulePath, relativePath);
|
|
283
292
|
const content = await readFile(fullPath, 'utf-8');
|
|
293
|
+
for (const variable of parseVariables(content)) {
|
|
294
|
+
if (variable.type === 'capability') capabilityReferences.add(variable.path);
|
|
295
|
+
}
|
|
284
296
|
const errors = validateTemplateContent(content, manifest, relativePath);
|
|
285
297
|
allErrors.push(...errors);
|
|
286
298
|
}
|
|
@@ -288,6 +300,7 @@ export async function validateModuleTemplates(
|
|
|
288
300
|
return {
|
|
289
301
|
success: allErrors.length === 0,
|
|
290
302
|
errors: allErrors,
|
|
303
|
+
capabilityReferences: [...capabilityReferences],
|
|
291
304
|
};
|
|
292
305
|
} catch (error) {
|
|
293
306
|
return {
|
|
@@ -299,6 +312,10 @@ export async function validateModuleTemplates(
|
|
|
299
312
|
error: `Failed to validate templates: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
300
313
|
},
|
|
301
314
|
],
|
|
315
|
+
// Unreadable templates cannot yield references, and import returns on
|
|
316
|
+
// !success before the access check runs, so this can never reach it as a
|
|
317
|
+
// silent pass.
|
|
318
|
+
capabilityReferences: [],
|
|
302
319
|
};
|
|
303
320
|
}
|
|
304
321
|
}
|
package/src/module/import.ts
CHANGED
|
@@ -607,7 +607,25 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
|
|
|
607
607
|
// Execution: Validate capability access if module requires capabilities
|
|
608
608
|
if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) {
|
|
609
609
|
const { validateCapabilityAccess } = await import('../capabilities/validation');
|
|
610
|
-
|
|
610
|
+
// Templates are where CLAUDE.md's Definition of Done tells module authors
|
|
611
|
+
// to put `$capability:` references, so the import-time gate has to see
|
|
612
|
+
// them (celilo#854, celilo#1027).
|
|
613
|
+
//
|
|
614
|
+
// This adds no parser. `validateModuleTemplates` above already reads and
|
|
615
|
+
// parses every `.tpl` on every import, roughly 25 lines before the gate
|
|
616
|
+
// runs — it was discarding the references it saw. So there is no new file
|
|
617
|
+
// walk, no new failure mode and no new ordering question: the data is
|
|
618
|
+
// already in scope and the gate simply was not looking at it.
|
|
619
|
+
//
|
|
620
|
+
// Fail-closed by that same ordering. An unreadable template makes
|
|
621
|
+
// `validateModuleTemplates` return success:false with no references, and
|
|
622
|
+
// the early return above fires BEFORE this check, so an empty reference
|
|
623
|
+
// set can never reach the gate as a silent pass.
|
|
624
|
+
const accessResult = await validateCapabilityAccess(
|
|
625
|
+
manifest,
|
|
626
|
+
db.$client,
|
|
627
|
+
templateValidation.capabilityReferences,
|
|
628
|
+
);
|
|
611
629
|
|
|
612
630
|
if (!accessResult.success) {
|
|
613
631
|
if (tempDir) await cleanupTempDir(tempDir);
|