@celilo/cli 1.0.0 → 1.1.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
CHANGED
|
@@ -19,6 +19,19 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
19
19
|
> (**knot-unbound-internal** / **technitium**). Identity comes from **authentik** (`idp`). That's
|
|
20
20
|
> the whole edge: DNS + firewall + ingress + identity, each a swappable provider module.
|
|
21
21
|
|
|
22
|
+
> How the graph OPENS again: when a consumer is removed, celilo dispatches
|
|
23
|
+
> `on_consumer_removed` to every provider of every capability that consumer
|
|
24
|
+
> declared, and each withdraws what it minted on its behalf
|
|
25
|
+
> (openspec/changes/consumer-removal-cleanup). Providers implementing it:
|
|
26
|
+
> **caddy** and **caddy-internal** (drop the routes, reclaim `/srv/www/<slug>`),
|
|
27
|
+
> **iptables** / **greenwave** / **axon** (drop the forwards and trusted
|
|
28
|
+
> sources), **authentik** (delete the OIDC application), **forgejo**
|
|
29
|
+
> (deregister the CI runner), **generic-cpanel-hosting-provider** (remove the
|
|
30
|
+
> published docroot). Consumers do NOT withdraw their own state any more — a
|
|
31
|
+
> module gives a port or a subnet back by narrowing its own declaration, and
|
|
32
|
+
> departing entirely is the provider's job. A provider that fails to withdraw
|
|
33
|
+
> is marked ERROR; the removal still completes.
|
|
34
|
+
|
|
22
35
|
## Network fabric (DNS / firewall / DHCP)
|
|
23
36
|
|
|
24
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.
|
|
@@ -28,12 +41,12 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
28
41
|
- **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/`).
|
|
29
42
|
- **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`.
|
|
30
43
|
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
|
|
31
|
-
- **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so the `admin_group` gate on the idp application is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control.
|
|
44
|
+
- **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so the `admin_group` gate on the idp application is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control. Lives in **`app`**, one tier behind `caddy-internal` in `dmz`, and is never reached directly (celilo#879). Enrolment works from INSIDE the fleet only (a device not yet on the VPN cannot reach a manager reachable only over it) — from the LAN through the ingress IP, from a segmented zone at the ingress's dmz address, or over an existing VPN session, and a revocation takes effect at the next reconcile — that interval IS the window in which a revoked device still has reach, which is why the UI shows it pending and why `celilo module run-hook wireguard-manager reconcile_clients` exists. **requires:** `control_plane_vpn` (wireguard), `idp` (authentik), `private_web` (caddy-internal).
|
|
32
45
|
|
|
33
46
|
## Public edge (ingress / identity)
|
|
34
47
|
|
|
35
48
|
- **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
|
|
36
|
-
- **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never
|
|
49
|
+
- **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — declared under `optional.capabilities` ONLY to route around celilo#854 (the import-time secret gate refuses a consumer over a secret it never reads); `on_install` throws without it.
|
|
37
50
|
- **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
|
|
38
51
|
- **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
|
|
39
52
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -24,6 +24,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
24
24
|
## Allocation & infrastructure selection
|
|
25
25
|
|
|
26
26
|
- **IPAM (IP/VMID allocation)** — `apps/celilo/src/ipam/allocator.ts` — `allocateIPFromSubnet`, `allocateVMID`, `reserveIP`/`unreserveIP`, `inferZoneFromIP`, `getAllocation`. Auto-wrapper: `apps/celilo/src/ipam/auto-allocator.ts` — `allocateForModule` / `deallocateForModule`.
|
|
27
|
+
- **Ingress IPs (how the LAN reaches a service in a segmented zone)** — `ensureIngressIps` in `apps/celilo/src/templates/generator.ts`, called from `generateTemplates`. A module opts in by declaring an infrastructure variable whose name ENDS IN `ingress_ip` (`dns_ingress_ip` on the resolver, `ingress_ip` on `caddy-internal`); generate allocates and RESERVES a free `internal`-subnet address once and reuses it forever after, and the module's `on_install` passes it to `firewall.exposeService({ ingressIp })`. That renders ONE DNAT on the firewall's internal side and nothing on the external interface — it is not a public port-forward, and conflating the two is what pinned `caddy-internal` into the `internal` zone until celilo#879. Idempotence is load-bearing: re-allocating on a later generate silently moves the address clients use, with every command still reporting success (`apps/celilo/src/templates/ingress-ip.test.ts`).
|
|
27
28
|
- **Infrastructure selection (container-service vs machine pool)** — `apps/celilo/src/services/machine-pool.ts` (`getMachineByHostname`, `addMachine`, `assignModuleToMachine`) and `apps/celilo/src/services/container-service.ts` (`getContainerServiceByName`, `addContainerService`, `verifyContainerService`). Provider API clients: `apps/celilo/src/api-clients/proxmox.ts`, `apps/celilo/src/api-clients/digitalocean.ts`.
|
|
28
29
|
- **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table and returns `NetworkZone | 'unknown'`. It answers CONTAINMENT ONLY. It used to return `'external'` on no-match, which conflated "no declared subnet contains this" with "the internet can route to this" — on a firewall with five RFC1918 legs that reported four of them as facing the internet. `'unknown'` is the honest answer; the caller resolves it (see `machine add`: publicly routable → `external`, otherwise fail asking for `--zone`). The subnet-backed zone list is derived from `NETWORK_ZONES` minus `external`, which has no subnet and must never be given one.
|
|
29
30
|
- **Interface classification** — `packages/capabilities/src/interface-classification.ts` — THE shared classifier, used by the backend and every firewall provider module so the two cannot drift apart again. `isPubliclyRoutable(ip)` is a property of the address alone (false for RFC 1918, RFC 6598 carrier-grade NAT, loopback, link-local, multicast, reserved). `classifyInterfaces(interfaces, zones)` assigns each interface `zone → external → alien`, first match winning, where `external` is the RESIDUAL — routable and claimed by no declared zone — and is never subnet-matched. `externalEdge()` returns none/single/**ambiguous** rather than silently picking the first public address. `defaultRouteFinding()` enforces the invariant that the default route leaves through `internal` or `external`. **`subnetContains(cidr, ip)` lives here and is the ONLY implementation** — three existed and disagreed (the backend's mishandled `/0`); the other two are deleted, not aliased. Design: `openspec/changes/firewall-interface-classification/design.md`.
|
package/package.json
CHANGED
|
@@ -654,38 +654,58 @@ celilo module import modules/${moduleId}
|
|
|
654
654
|
* @returns Generation result
|
|
655
655
|
*/
|
|
656
656
|
/** Narrower than `GenerateResult`: this step produces no files, only an outcome. */
|
|
657
|
-
export type
|
|
657
|
+
export type IngressIpResult = { success: true } | { success: false; error: string };
|
|
658
658
|
|
|
659
659
|
/**
|
|
660
|
-
* Allocate-and-reserve
|
|
660
|
+
* Allocate-and-reserve a module's dedicated `internal`-subnet ingress IPs, ONCE
|
|
661
|
+
* (ISS-0156, celilo#879).
|
|
661
662
|
*
|
|
662
|
-
* A
|
|
663
|
-
* protected-zone query sources for split-horizon
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
* `
|
|
663
|
+
* A service can need to live in a PROTECTED zone — the `dns_internal` resolver
|
|
664
|
+
* sits in `dmz` so it can see protected-zone query sources for split-horizon,
|
|
665
|
+
* and `caddy-internal` sits there so systems in the segmented zones can reach
|
|
666
|
+
* it. `internal` devices have no route into the 10-net, so they reach such a
|
|
667
|
+
* service through a firewall DNAT on a dedicated `internal`-subnet address.
|
|
668
|
+
* That DNAT is internal-side only: it is NOT a public port-forward, and the two
|
|
669
|
+
* are routinely confused.
|
|
670
|
+
*
|
|
671
|
+
* A module opts in by declaring an infrastructure variable whose name ends in
|
|
672
|
+
* `ingress_ip` — `dns_ingress_ip` for the resolver's `:53`, `ingress_ip` for a
|
|
673
|
+
* private web ingress's `:80/:443`. The hook then passes the stored value to
|
|
674
|
+
* `firewall.exposeService({ ingressIp })`.
|
|
667
675
|
*
|
|
668
676
|
* **Idempotence is the whole point, and it is load-bearing.** `module generate`
|
|
669
677
|
* runs repeatedly over a module's life. Re-allocating here on the second run
|
|
670
|
-
* would move the address internal clients use to reach
|
|
671
|
-
* every command still reports success. That is why the stored value is
|
|
672
|
-
* rather than re-derived, and why this is a named function instead of a
|
|
673
|
-
* buried in `generateTemplates`: an invariant nothing can call is an
|
|
674
|
-
* nothing can test, and this one had no test at all.
|
|
678
|
+
* would move the address internal clients use to reach the service, every time
|
|
679
|
+
* — while every command still reports success. That is why the stored value is
|
|
680
|
+
* reused rather than re-derived, and why this is a named function instead of a
|
|
681
|
+
* branch buried in `generateTemplates`: an invariant nothing can call is an
|
|
682
|
+
* invariant nothing can test, and this one had no test at all.
|
|
675
683
|
*/
|
|
676
|
-
export async function
|
|
684
|
+
export async function ensureIngressIps(
|
|
677
685
|
moduleId: string,
|
|
678
686
|
manifest: ModuleManifest,
|
|
679
687
|
db: DbClient,
|
|
680
|
-
): Promise<
|
|
681
|
-
const
|
|
682
|
-
(v) => v.name
|
|
688
|
+
): Promise<IngressIpResult> {
|
|
689
|
+
const wanted = (manifest.variables?.owns ?? []).filter(
|
|
690
|
+
(v) => v.name.endsWith('ingress_ip') && v.source === 'infrastructure',
|
|
683
691
|
);
|
|
684
|
-
if (
|
|
692
|
+
if (wanted.length === 0) return { success: true };
|
|
693
|
+
|
|
694
|
+
for (const variable of wanted) {
|
|
695
|
+
const result = await allocateIngressIp(moduleId, variable.name, db);
|
|
696
|
+
if (!result.success) return result;
|
|
697
|
+
}
|
|
698
|
+
return { success: true };
|
|
699
|
+
}
|
|
685
700
|
|
|
686
|
-
|
|
701
|
+
async function allocateIngressIp(
|
|
702
|
+
moduleId: string,
|
|
703
|
+
variableName: string,
|
|
704
|
+
db: DbClient,
|
|
705
|
+
): Promise<IngressIpResult> {
|
|
706
|
+
const existing = getModuleConfigValue(moduleId, variableName, db)?.value;
|
|
687
707
|
if (typeof existing === 'string' && existing.length > 0) {
|
|
688
|
-
log.success(`Using existing
|
|
708
|
+
log.success(`Using existing ingress IP ${existing} (${variableName}) for ${moduleId}`);
|
|
689
709
|
return { success: true };
|
|
690
710
|
}
|
|
691
711
|
|
|
@@ -695,9 +715,7 @@ export async function ensureDnsIngressIp(
|
|
|
695
715
|
if (!subnetRow?.value) {
|
|
696
716
|
return {
|
|
697
717
|
success: false,
|
|
698
|
-
error:
|
|
699
|
-
'network.internal.subnet is not configured — required to allocate the ' +
|
|
700
|
-
'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
|
|
718
|
+
error: `network.internal.subnet is not configured — required to allocate the ${variableName} ingress IP (ISS-0156). Ensure the internal network is set up first.`,
|
|
701
719
|
};
|
|
702
720
|
}
|
|
703
721
|
|
|
@@ -705,14 +723,14 @@ export async function ensureDnsIngressIp(
|
|
|
705
723
|
const { stripCIDR } = await import('../ipam/subnet-parser');
|
|
706
724
|
try {
|
|
707
725
|
const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
|
|
708
|
-
await reserveIP(ip, 'internal', `
|
|
709
|
-
upsertModuleConfig(db, moduleId,
|
|
710
|
-
log.success(`Allocated
|
|
726
|
+
await reserveIP(ip, 'internal', `ingress:${moduleId}:${variableName}`, null, db);
|
|
727
|
+
upsertModuleConfig(db, moduleId, variableName, ip);
|
|
728
|
+
log.success(`Allocated ingress IP ${ip} (internal subnet, ${variableName}) for ${moduleId}`);
|
|
711
729
|
return { success: true };
|
|
712
730
|
} catch (error) {
|
|
713
731
|
return {
|
|
714
732
|
success: false,
|
|
715
|
-
error: `
|
|
733
|
+
error: `Ingress IP allocation failed for ${variableName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
716
734
|
};
|
|
717
735
|
}
|
|
718
736
|
}
|
|
@@ -879,8 +897,8 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
879
897
|
}
|
|
880
898
|
}
|
|
881
899
|
|
|
882
|
-
const
|
|
883
|
-
if (!
|
|
900
|
+
const ingress = await ensureIngressIps(moduleId, manifest, db);
|
|
901
|
+
if (!ingress.success) return ingress;
|
|
884
902
|
|
|
885
903
|
// Infrastructure Properties Resolution (Proxmox provider config)
|
|
886
904
|
// For Proxmox services, extract provider config and store as temporary values
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The ingress-IP allocate-and-reserve guard (ISS-0156, celilo#879).
|
|
3
3
|
*
|
|
4
4
|
* This invariant had NO test. Losing it is not a crash: `module generate`
|
|
5
5
|
* re-allocates a different address on every run, silently moving the address
|
|
6
|
-
* internal clients use to reach
|
|
7
|
-
* `module generate` runs repeatedly over a module's life, so "on the
|
|
8
|
-
* run" is the normal case, not an edge one.
|
|
6
|
+
* internal clients use to reach the service, while every command still reports
|
|
7
|
+
* success. `module generate` runs repeatedly over a module's life, so "on the
|
|
8
|
+
* second run" is the normal case, not an edge one.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
@@ -13,7 +13,7 @@ import type { DbClient } from '../db/client';
|
|
|
13
13
|
import type { ModuleManifest } from '../manifest/schema';
|
|
14
14
|
import { getModuleConfigValue } from '../services/module-config';
|
|
15
15
|
import { setupTestDatabase } from '../test-utils/database';
|
|
16
|
-
import {
|
|
16
|
+
import { ensureIngressIps } from './generator';
|
|
17
17
|
|
|
18
18
|
let db: DbClient;
|
|
19
19
|
|
|
@@ -27,12 +27,17 @@ const operatorSupplied = {
|
|
|
27
27
|
variables: { owns: [{ name: 'dns_ingress_ip', source: 'user_input' }] },
|
|
28
28
|
} as unknown as ModuleManifest;
|
|
29
29
|
|
|
30
|
+
/** How `caddy-internal` opts in — a web ingress rather than a DNS one. */
|
|
31
|
+
const wantsWebIngress = {
|
|
32
|
+
variables: { owns: [{ name: 'ingress_ip', source: 'infrastructure' }] },
|
|
33
|
+
} as unknown as ModuleManifest;
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* Typed as `string | undefined` rather than `unknown`: every assertion here is
|
|
32
37
|
* about an address, and an untyped read pushes a cast onto each one.
|
|
33
38
|
*/
|
|
34
|
-
const storedIp = (moduleId: string): string | undefined => {
|
|
35
|
-
const value = getModuleConfigValue(moduleId,
|
|
39
|
+
const storedIp = (moduleId: string, variable = 'dns_ingress_ip'): string | undefined => {
|
|
40
|
+
const value = getModuleConfigValue(moduleId, variable, db)?.value;
|
|
36
41
|
return typeof value === 'string' ? value : undefined;
|
|
37
42
|
};
|
|
38
43
|
|
|
@@ -43,9 +48,9 @@ beforeEach(async () => {
|
|
|
43
48
|
.run('network.internal.subnet', '10.226.1.0/24');
|
|
44
49
|
});
|
|
45
50
|
|
|
46
|
-
describe('
|
|
51
|
+
describe('ensureIngressIps', () => {
|
|
47
52
|
test('allocates an address from the internal subnet on first generate', async () => {
|
|
48
|
-
const result = await
|
|
53
|
+
const result = await ensureIngressIps('technitium', wantsIngress, db);
|
|
49
54
|
|
|
50
55
|
expect(result.success).toBe(true);
|
|
51
56
|
expect(storedIp('technitium')).toMatch(/^10\.226\.1\.\d+$/);
|
|
@@ -54,21 +59,21 @@ describe('ensureDnsIngressIp', () => {
|
|
|
54
59
|
test('REUSES the same address on a second generate', async () => {
|
|
55
60
|
// The guard itself. Re-allocating here moves the resolver's DNAT ingress
|
|
56
61
|
// every time the module is regenerated, and nothing reports a problem.
|
|
57
|
-
await
|
|
62
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
58
63
|
const first = storedIp('technitium');
|
|
59
64
|
|
|
60
|
-
await
|
|
65
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
61
66
|
const second = storedIp('technitium');
|
|
62
67
|
|
|
63
68
|
expect(second).toBe(first);
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
test('stays stable across many generates, not just two', async () => {
|
|
67
|
-
await
|
|
72
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
68
73
|
const first = storedIp('technitium');
|
|
69
74
|
|
|
70
75
|
for (let i = 0; i < 5; i++) {
|
|
71
|
-
await
|
|
76
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
expect(storedIp('technitium')).toBe(first);
|
|
@@ -77,7 +82,7 @@ describe('ensureDnsIngressIp', () => {
|
|
|
77
82
|
test('RESERVES the address, so it is never handed out to something else', async () => {
|
|
78
83
|
// Allocation without reservation is the same bug one step later: a
|
|
79
84
|
// container gets the resolver's ingress address and DNS goes dark.
|
|
80
|
-
await
|
|
85
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
81
86
|
const ip = storedIp('technitium');
|
|
82
87
|
|
|
83
88
|
const reserved = db.$client
|
|
@@ -85,20 +90,20 @@ describe('ensureDnsIngressIp', () => {
|
|
|
85
90
|
.get(ip ?? '') as { ip_start: string; reason: string } | undefined;
|
|
86
91
|
|
|
87
92
|
expect(reserved?.ip_start).toBe(ip);
|
|
88
|
-
// The reason names the owner, so an operator reading the
|
|
89
|
-
// what an otherwise anonymous held address is for.
|
|
90
|
-
expect(reserved?.reason).toBe('
|
|
93
|
+
// The reason names the owner AND the variable, so an operator reading the
|
|
94
|
+
// table can tell what an otherwise anonymous held address is for.
|
|
95
|
+
expect(reserved?.reason).toBe('ingress:technitium:dns_ingress_ip');
|
|
91
96
|
});
|
|
92
97
|
|
|
93
98
|
test('two modules get two different addresses', async () => {
|
|
94
|
-
await
|
|
95
|
-
await
|
|
99
|
+
await ensureIngressIps('technitium', wantsIngress, db);
|
|
100
|
+
await ensureIngressIps('knot-unbound-internal', wantsIngress, db);
|
|
96
101
|
|
|
97
102
|
expect(storedIp('knot-unbound-internal')).not.toBe(storedIp('technitium'));
|
|
98
103
|
});
|
|
99
104
|
|
|
100
105
|
test('does nothing for a module that never asked for one', async () => {
|
|
101
|
-
const result = await
|
|
106
|
+
const result = await ensureIngressIps('caddy', {} as ModuleManifest, db);
|
|
102
107
|
|
|
103
108
|
expect(result.success).toBe(true);
|
|
104
109
|
expect(storedIp('caddy')).toBeUndefined();
|
|
@@ -107,7 +112,7 @@ describe('ensureDnsIngressIp', () => {
|
|
|
107
112
|
test('only `source: infrastructure` opts in', async () => {
|
|
108
113
|
// A same-named variable the operator supplies is theirs to set; allocating
|
|
109
114
|
// over it would overwrite an operator's deliberate choice.
|
|
110
|
-
await
|
|
115
|
+
await ensureIngressIps('technitium', operatorSupplied, db);
|
|
111
116
|
|
|
112
117
|
expect(storedIp('technitium')).toBeUndefined();
|
|
113
118
|
});
|
|
@@ -115,9 +120,20 @@ describe('ensureDnsIngressIp', () => {
|
|
|
115
120
|
test('fails with an actionable message when the internal subnet is unset', async () => {
|
|
116
121
|
db.$client.prepare('DELETE FROM system_config WHERE key = ?').run('network.internal.subnet');
|
|
117
122
|
|
|
118
|
-
const result = await
|
|
123
|
+
const result = await ensureIngressIps('technitium', wantsIngress, db);
|
|
119
124
|
|
|
120
125
|
expect(result.success).toBe(false);
|
|
121
126
|
expect(result.success === false && result.error).toContain('network.internal.subnet');
|
|
122
127
|
});
|
|
128
|
+
|
|
129
|
+
// celilo#879. The opt-in used to be the literal name `dns_ingress_ip`, so a
|
|
130
|
+
// dmz-resident WEB ingress had no way to ask for the same treatment — which
|
|
131
|
+
// is how `caddy-internal` came to be pinned into the `internal` zone with a
|
|
132
|
+
// manifest comment claiming a dmz ingress could not be reached from a LAN.
|
|
133
|
+
test('a `ingress_ip` variable opts in the same way, for a non-DNS ingress', async () => {
|
|
134
|
+
const result = await ensureIngressIps('caddy-internal', wantsWebIngress, db);
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
expect(storedIp('caddy-internal', 'ingress_ip')).toMatch(/^10\.226\.1\.\d+$/);
|
|
138
|
+
});
|
|
123
139
|
});
|