@celilo/cli 0.11.0 → 0.12.1
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/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +52 -6
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +13 -5
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/api/protocol.test.ts +1 -1
- package/src/api/remote-client.test.ts +1 -1
- package/src/api/serve.ts +5 -5
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +21 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +170 -7
- package/src/cli/parser.ts +1 -1
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- package/src/variables/types.ts +1 -1
- package/src/api/protocol.ts +0 -159
- package/src/api/remote-client.ts +0 -218
- package/src/cli/command-registry.ts +0 -1488
package/src/db/schema.ts
CHANGED
|
@@ -318,7 +318,7 @@ export const machines = sqliteTable('machines', {
|
|
|
318
318
|
* Appliance machines (e.g., greenwave = ISP modem) where celilo
|
|
319
319
|
* has no shell-level access — only API calls. Base-module aspects
|
|
320
320
|
* cannot Ansible to these systems, so the aspect runner skips
|
|
321
|
-
* them. See
|
|
321
|
+
* them. See openspec/specs/base-module-aspects/spec.md D8.
|
|
322
322
|
*/
|
|
323
323
|
apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
|
|
324
324
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
@@ -345,7 +345,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
345
345
|
* driver registers an entry that has no SSH surface (rare today,
|
|
346
346
|
* but exists for future API-only providers). Aspect runner reads
|
|
347
347
|
* this to decide whether the system is reachable via Ansible.
|
|
348
|
-
* See
|
|
348
|
+
* See openspec/specs/base-module-aspects/spec.md D8.
|
|
349
349
|
*/
|
|
350
350
|
apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
|
|
351
351
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
@@ -358,7 +358,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
358
358
|
* deployment-STATE counterpart to module_configs (declared inputs): it records,
|
|
359
359
|
* per deployed system, the hostname + IPv4 + zone + where it lives. Replaces the
|
|
360
360
|
* old scalar `target_ip`/`vmid` rows in module_configs, which baked in the
|
|
361
|
-
* one-module-one-host assumption. See
|
|
361
|
+
* one-module-one-host assumption. See openspec/specs/module-systems-addressing/spec.md.
|
|
362
362
|
*
|
|
363
363
|
* - 0 rows: API-only modules (e.g. namecheap — no host).
|
|
364
364
|
* - 1 row: the common case (technitium, homebridge, …).
|
|
@@ -368,7 +368,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
368
368
|
* the manifest's `requires.systems[].name` — what templates reference via
|
|
369
369
|
* `$infra:<name>.…`. `hostname` is the runtime DNS hostname (often == name, but
|
|
370
370
|
* user/well-known-assignable), used by DNS and events. See
|
|
371
|
-
*
|
|
371
|
+
* openspec/specs/module-systems-addressing/spec.md.
|
|
372
372
|
*/
|
|
373
373
|
export const moduleSystems = sqliteTable(
|
|
374
374
|
'module_systems',
|
|
@@ -443,6 +443,49 @@ export const webRoutes = sqliteTable(
|
|
|
443
443
|
}),
|
|
444
444
|
);
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Port-forward registry — the desired-state store for the `firewall` capability
|
|
448
|
+
* (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`
|
|
449
|
+
* upserts, `unexposeService` deletes, and the firewall provider's converge
|
|
450
|
+
* renders the COMPLETE ruleset for a firewall from these rows and applies it
|
|
451
|
+
* atomically via `iptables-restore` — replacing the old per-rule `iptables -A`
|
|
452
|
+
* (non-idempotent) + the "read the box back with iptables -L" source of truth.
|
|
453
|
+
*
|
|
454
|
+
* Keyed by `firewall_ip` (the converge target) so multiple firewalls each render
|
|
455
|
+
* their own set. Shared-core (not a per-module JSON file) so any firewall
|
|
456
|
+
* provider reconciles against the one canonical store.
|
|
457
|
+
*/
|
|
458
|
+
export const portForwards = sqliteTable(
|
|
459
|
+
'port_forwards',
|
|
460
|
+
{
|
|
461
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
462
|
+
/** The firewall host this forward is installed on (config.firewallIp). */
|
|
463
|
+
firewallIp: text('firewall_ip').notNull(),
|
|
464
|
+
/** Backend IP the forward targets. */
|
|
465
|
+
internalIp: text('internal_ip').notNull(),
|
|
466
|
+
/** Port — external == internal (no translation today). */
|
|
467
|
+
port: integer('port').notNull(),
|
|
468
|
+
protocol: text('protocol').$type<'TCP' | 'UDP'>().notNull(),
|
|
469
|
+
/** Dedicated INTERNAL ingress IP (ISS-0156); NULL for the normal public path. */
|
|
470
|
+
ingressIp: text('ingress_ip'),
|
|
471
|
+
description: text('description').notNull().default(''),
|
|
472
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
473
|
+
},
|
|
474
|
+
(table) => ({
|
|
475
|
+
// One row per (firewall, backend, port, protocol, ingress). The store also
|
|
476
|
+
// delete-then-inserts on this tuple so a re-expose is an idempotent upsert
|
|
477
|
+
// (NULL ingress_ip is compared with `is null` there, since SQLite treats
|
|
478
|
+
// NULLs as distinct in a unique index).
|
|
479
|
+
forwardUnique: uniqueIndex('port_forwards_unique_idx').on(
|
|
480
|
+
table.firewallIp,
|
|
481
|
+
table.internalIp,
|
|
482
|
+
table.port,
|
|
483
|
+
table.protocol,
|
|
484
|
+
table.ingressIp,
|
|
485
|
+
),
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
446
489
|
/**
|
|
447
490
|
* DNS registration ledger — one row per (provider, fqdn) the framework
|
|
448
491
|
* has successfully registered via dns_registrar.registerHost. Written
|
|
@@ -509,7 +552,7 @@ export const dnsInternalRecords = sqliteTable(
|
|
|
509
552
|
/**
|
|
510
553
|
* In-zone split-horizon answer (caddy's zone-routable IP), when this is a
|
|
511
554
|
* caddy-fronted hostname that needs source-based views (ISS-0156,
|
|
512
|
-
*
|
|
555
|
+
* openspec/specs/internal-dns-zone-views/spec.md). NULL for records with no zone override
|
|
513
556
|
* (per-system identity, plain A records). This column is the durable
|
|
514
557
|
* desired-state the resolver's view config is reconciled from.
|
|
515
558
|
*/
|
|
@@ -621,7 +664,7 @@ export const moduleOperations = sqliteTable('module_operations', {
|
|
|
621
664
|
* per module version. Cascade delete on module removal so stale
|
|
622
665
|
* approvals don't linger.
|
|
623
666
|
*
|
|
624
|
-
* See
|
|
667
|
+
* See openspec/specs/base-module-aspects/spec.md D2 + D7.
|
|
625
668
|
*/
|
|
626
669
|
export const aspectApprovals = sqliteTable(
|
|
627
670
|
'aspect_approvals',
|
|
@@ -655,7 +698,7 @@ export const aspectApprovals = sqliteTable(
|
|
|
655
698
|
);
|
|
656
699
|
|
|
657
700
|
/**
|
|
658
|
-
* Remote API principals (see
|
|
701
|
+
* Remote API principals (see openspec/changes/replace-ssh-cli-api/proposal.md).
|
|
659
702
|
*
|
|
660
703
|
* Each row is one API identity: a name, one SSH public key, and the set of
|
|
661
704
|
* operations it may run. celilo renders these into the API account's
|
|
@@ -47,12 +47,13 @@ export default defineCapabilityFunction({
|
|
|
47
47
|
`;
|
|
48
48
|
|
|
49
49
|
// Mock iptables firewall factory — kept on the legacy
|
|
50
|
-
// `createFirewall(config, upstreamFirewall)` shape because its
|
|
50
|
+
// `createFirewall(config, store, upstreamFirewall, logger)` shape because its
|
|
51
51
|
// upstream-injection signature doesn't fit the
|
|
52
52
|
// defineCapabilityFunction `{ config, secrets, logger }` context.
|
|
53
|
-
// buildFirewallChain
|
|
53
|
+
// buildFirewallChain injects the port-forward store (2nd arg) and wires the
|
|
54
|
+
// upstream (3rd arg) manually. The mock ignores store (no converge here).
|
|
54
55
|
const MOCK_IPTABLES_MODULE = `
|
|
55
|
-
export function createFirewall(config, upstreamFirewall) {
|
|
56
|
+
export function createFirewall(config, store, upstreamFirewall, logger) {
|
|
56
57
|
return {
|
|
57
58
|
exposeService: async (opts) => {
|
|
58
59
|
if (!upstreamFirewall) {
|
|
@@ -21,18 +21,20 @@ import type {
|
|
|
21
21
|
DnsInternalCapability,
|
|
22
22
|
DnsRegistrarCapability,
|
|
23
23
|
HookLogger,
|
|
24
|
+
PortForwardStore,
|
|
24
25
|
RouteOps,
|
|
25
26
|
RouteReadView,
|
|
26
27
|
} from '@celilo/capabilities';
|
|
27
28
|
import { and, eq } from 'drizzle-orm';
|
|
28
29
|
import type { DbClient } from '../db/client';
|
|
29
|
-
import { capabilities, modules, secrets, webRoutes } from '../db/schema';
|
|
30
|
+
import { capabilities, modules, secrets, systemConfig, webRoutes } from '../db/schema';
|
|
30
31
|
import { decryptSecret } from '../secrets/encryption';
|
|
31
32
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
32
33
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
33
34
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
34
35
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
35
36
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
37
|
+
import { buildPortForwardStore } from '../services/port-forwards';
|
|
36
38
|
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
37
39
|
import { containsComputedMarker } from '../variables/computed/marker';
|
|
38
40
|
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
@@ -288,6 +290,10 @@ export async function loadCapabilityFunctions(
|
|
|
288
290
|
exported,
|
|
289
291
|
providerConfig,
|
|
290
292
|
providerSecrets,
|
|
293
|
+
// Single firewall provider (no upstream chain) still needs the injected
|
|
294
|
+
// port-forward store — the chain path isn't taken when there's one provider.
|
|
295
|
+
capName === 'firewall' ? buildPortForwardStore(db) : undefined,
|
|
296
|
+
capName === 'firewall' ? loadFirewallZones(db) : undefined,
|
|
291
297
|
);
|
|
292
298
|
|
|
293
299
|
if (capabilityInterface) {
|
|
@@ -326,6 +332,17 @@ export async function loadCapabilityFunctions(
|
|
|
326
332
|
debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
|
|
327
333
|
}
|
|
328
334
|
|
|
335
|
+
// Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
|
|
336
|
+
// learns it from its own firewall.exposeService at install and persists it
|
|
337
|
+
// as the `public_ip` hook output (stored as a caddy secret). register_route
|
|
338
|
+
// points a new hostname's PUBLIC record here, so it's reachable from one
|
|
339
|
+
// deploy — no per-hostname DNAT (shared :443 ingress).
|
|
340
|
+
const caddyExternalIp =
|
|
341
|
+
typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
|
|
342
|
+
if (caddyExternalIp) {
|
|
343
|
+
debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
|
|
344
|
+
}
|
|
345
|
+
|
|
329
346
|
// Caddy's configured hostnames — public_web rejects routes for any
|
|
330
347
|
// hostname not in this list, throwing a structured error that runs
|
|
331
348
|
// caddy's `managed_hostname` ensure interview. Empty list means
|
|
@@ -437,6 +454,8 @@ export async function loadCapabilityFunctions(
|
|
|
437
454
|
routeOps,
|
|
438
455
|
dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
|
|
439
456
|
firewallNatIp,
|
|
457
|
+
dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
|
|
458
|
+
externalIp: caddyExternalIp,
|
|
440
459
|
hostnames: caddyHostnames,
|
|
441
460
|
caddyModuleId: provider.moduleId,
|
|
442
461
|
dnsManagedDomains,
|
|
@@ -525,16 +544,28 @@ function buildCapabilityInterface(
|
|
|
525
544
|
factory: (...args: unknown[]) => unknown,
|
|
526
545
|
config: Record<string, unknown>,
|
|
527
546
|
_secrets: Record<string, string>,
|
|
547
|
+
store?: PortForwardStore,
|
|
548
|
+
zones?: FirewallZones,
|
|
528
549
|
): unknown {
|
|
529
550
|
if (capabilityName === 'firewall') {
|
|
530
|
-
// iptables firewall factory:
|
|
531
|
-
//
|
|
532
|
-
// another
|
|
551
|
+
// iptables firewall factory: NAT config + the injected port-forward store
|
|
552
|
+
// (createFirewall(config, store, upstream?, logger?)). Upstream is injected
|
|
553
|
+
// by buildFirewallChain when this module is downstream of another provider.
|
|
554
|
+
// The firewall is only built through buildFirewallChain, which always passes
|
|
555
|
+
// a store — guard so a mis-route surfaces loudly rather than as store=undefined.
|
|
533
556
|
if (config.firewall_ip && config.nat_ip) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
557
|
+
if (!store) {
|
|
558
|
+
throw new Error('firewall capability requires an injected port-forward store');
|
|
559
|
+
}
|
|
560
|
+
return factory(
|
|
561
|
+
{
|
|
562
|
+
firewallIp: config.firewall_ip as string,
|
|
563
|
+
natIp: config.nat_ip as string,
|
|
564
|
+
zoneTiers: zones?.zoneTiers ?? [],
|
|
565
|
+
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
566
|
+
},
|
|
567
|
+
store,
|
|
568
|
+
);
|
|
538
569
|
}
|
|
539
570
|
return null;
|
|
540
571
|
}
|
|
@@ -624,6 +655,40 @@ async function resolveRegistrarDomainList(
|
|
|
624
655
|
* on the legacy `createFirewall(config, upstream)` path because its
|
|
625
656
|
* upstream-injection signature doesn't fit the new shape.
|
|
626
657
|
*/
|
|
658
|
+
/** Most-exposed → most-protected SEGMENTED tiers for the firewall default-DROP matrix. */
|
|
659
|
+
const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
|
|
660
|
+
|
|
661
|
+
interface FirewallZones {
|
|
662
|
+
/** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
|
|
663
|
+
zoneTiers: Array<{ name: string; subnet: string }>;
|
|
664
|
+
/** Trusted LAN subnets (internal) that reach every tier — celilo's control plane. */
|
|
665
|
+
trustedSubnets: string[];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function readZoneSubnet(db: DbClient, zone: string): string | undefined {
|
|
669
|
+
const row = db
|
|
670
|
+
.select()
|
|
671
|
+
.from(systemConfig)
|
|
672
|
+
.where(eq(systemConfig.key, `network.${zone}.subnet`))
|
|
673
|
+
.get();
|
|
674
|
+
return row?.value ?? undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
|
|
679
|
+
* the segmented tiers [dmz, app, secure] and the trusted internal LAN. Zones with
|
|
680
|
+
* no configured subnet are omitted — their traffic stays denied (fail-closed).
|
|
681
|
+
*/
|
|
682
|
+
function loadFirewallZones(db: DbClient): FirewallZones {
|
|
683
|
+
const zoneTiers: Array<{ name: string; subnet: string }> = [];
|
|
684
|
+
for (const zone of ZONE_TIER_ORDER) {
|
|
685
|
+
const subnet = readZoneSubnet(db, zone);
|
|
686
|
+
if (subnet) zoneTiers.push({ name: zone, subnet });
|
|
687
|
+
}
|
|
688
|
+
const internal = readZoneSubnet(db, 'internal');
|
|
689
|
+
return { zoneTiers, trustedSubnets: internal ? [internal] : [] };
|
|
690
|
+
}
|
|
691
|
+
|
|
627
692
|
async function buildFirewallChain(
|
|
628
693
|
allProviders: Array<{
|
|
629
694
|
id: number;
|
|
@@ -638,6 +703,13 @@ async function buildFirewallChain(
|
|
|
638
703
|
logger: HookLogger,
|
|
639
704
|
debugLog: (msg: string) => void,
|
|
640
705
|
): Promise<unknown> {
|
|
706
|
+
// The shared-core port-forward registry, injected into every firewall provider
|
|
707
|
+
// in the chain so exposeService/converge reconcile against the one canonical
|
|
708
|
+
// store (openspec/changes/unified-management-no-ssh/proposal.md).
|
|
709
|
+
const store = buildPortForwardStore(db);
|
|
710
|
+
// Zone matrix for the default-DROP posture — same set for every layer in the chain.
|
|
711
|
+
const zones = loadFirewallZones(db);
|
|
712
|
+
|
|
641
713
|
// Find the provider with has_external (the leaf — has direct internet access)
|
|
642
714
|
const hasExternal = allProviders.find((p) => {
|
|
643
715
|
const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
|
|
@@ -686,7 +758,14 @@ async function buildFirewallChain(
|
|
|
686
758
|
);
|
|
687
759
|
} else {
|
|
688
760
|
// Legacy factory path
|
|
689
|
-
leafFirewall = buildCapabilityInterface(
|
|
761
|
+
leafFirewall = buildCapabilityInterface(
|
|
762
|
+
'firewall',
|
|
763
|
+
leafExported,
|
|
764
|
+
leafConfig,
|
|
765
|
+
leafSecrets,
|
|
766
|
+
store,
|
|
767
|
+
zones,
|
|
768
|
+
);
|
|
690
769
|
if (leafFirewall) {
|
|
691
770
|
leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall');
|
|
692
771
|
}
|
|
@@ -741,7 +820,18 @@ async function buildFirewallChain(
|
|
|
741
820
|
// celilo's ProgressDisplay instead of dumping to stderr. Older
|
|
742
821
|
// iptables modules ignore it — the factory's signature is
|
|
743
822
|
// backward-compatible.
|
|
744
|
-
const downstreamFirewall = provFactory(
|
|
823
|
+
const downstreamFirewall = provFactory(
|
|
824
|
+
{
|
|
825
|
+
firewallIp,
|
|
826
|
+
natIp,
|
|
827
|
+
dryRun,
|
|
828
|
+
zoneTiers: zones.zoneTiers,
|
|
829
|
+
trustedSubnets: zones.trustedSubnets,
|
|
830
|
+
},
|
|
831
|
+
store,
|
|
832
|
+
currentUpstream,
|
|
833
|
+
logger,
|
|
834
|
+
);
|
|
745
835
|
|
|
746
836
|
debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
|
|
747
837
|
// Wrap each downstream layer with auto-logging.
|
|
@@ -95,6 +95,12 @@ const fakeIdp: IdpCapability = {
|
|
|
95
95
|
async create_token() {
|
|
96
96
|
return { token: 'fake-token', created: true };
|
|
97
97
|
},
|
|
98
|
+
async list_tokens() {
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
async revoke_token() {
|
|
102
|
+
return { revoked: true };
|
|
103
|
+
},
|
|
98
104
|
};
|
|
99
105
|
|
|
100
106
|
describe('defineHook', () => {
|
|
@@ -260,6 +266,12 @@ describe('defineCapabilityFunction', () => {
|
|
|
260
266
|
async create_token() {
|
|
261
267
|
return { token: 'fake-token', created: true };
|
|
262
268
|
},
|
|
269
|
+
async list_tokens() {
|
|
270
|
+
return [];
|
|
271
|
+
},
|
|
272
|
+
async revoke_token() {
|
|
273
|
+
return { revoked: true };
|
|
274
|
+
},
|
|
263
275
|
}),
|
|
264
276
|
});
|
|
265
277
|
|
|
@@ -323,6 +335,12 @@ describe('defineCapabilityFunction', () => {
|
|
|
323
335
|
async create_token(): Promise<{ token: string; created: boolean }> {
|
|
324
336
|
return { token: 'tok', created: true };
|
|
325
337
|
},
|
|
338
|
+
async list_tokens() {
|
|
339
|
+
return [];
|
|
340
|
+
},
|
|
341
|
+
async revoke_token(): Promise<{ revoked: boolean }> {
|
|
342
|
+
return { revoked: true };
|
|
343
|
+
},
|
|
326
344
|
};
|
|
327
345
|
},
|
|
328
346
|
});
|
|
@@ -459,6 +477,12 @@ void defineCapabilityFunction({
|
|
|
459
477
|
async create_token() {
|
|
460
478
|
return { token: 'x', created: true };
|
|
461
479
|
},
|
|
480
|
+
async list_tokens() {
|
|
481
|
+
return [];
|
|
482
|
+
},
|
|
483
|
+
async revoke_token() {
|
|
484
|
+
return { revoked: true };
|
|
485
|
+
},
|
|
462
486
|
}),
|
|
463
487
|
});
|
|
464
488
|
|
|
@@ -469,7 +469,7 @@ describe('Hook Executor', () => {
|
|
|
469
469
|
// cross_module_read is a privilege granted by the framework to the
|
|
470
470
|
// hooks that use it (backup/restore), not a provider-loaded
|
|
471
471
|
// capability. It must not block other hooks like on_install
|
|
472
|
-
// (regression: celilo-mgmt deploy,
|
|
472
|
+
// (regression: celilo-mgmt deploy, openspec/specs/progressive-zone-disclosure/spec.md).
|
|
473
473
|
expect(checkRequiredCapabilities('on_install', ['cross_module_read'], {})).toBeNull();
|
|
474
474
|
// Real missing providers still flagged alongside a privilege.
|
|
475
475
|
const mixed = checkRequiredCapabilities('on_install', ['cross_module_read', 'idp'], {});
|
package/src/hooks/executor.ts
CHANGED
|
@@ -202,7 +202,7 @@ async function runScript(
|
|
|
202
202
|
|
|
203
203
|
if (!isCompiledHook(module.default)) {
|
|
204
204
|
throw new Error(
|
|
205
|
-
`Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See
|
|
205
|
+
`Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -263,7 +263,7 @@ export interface InvokeHookOptions {
|
|
|
263
263
|
capabilities?: Record<string, unknown>;
|
|
264
264
|
/**
|
|
265
265
|
* The module's 0..N deployed systems, injected as `ctx.systems`
|
|
266
|
-
* (
|
|
266
|
+
* (openspec/specs/module-systems-addressing/spec.md). Callers load these from the DB via
|
|
267
267
|
* `getModuleSystems` — the executor stays decoupled from the database, the
|
|
268
268
|
* same way `capabilities` is loaded by the caller. Defaults to `[]`.
|
|
269
269
|
*/
|
package/src/hooks/types.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface HookContext {
|
|
|
55
55
|
/** Module secret values (decrypted) */
|
|
56
56
|
secrets: Record<string, string>;
|
|
57
57
|
/**
|
|
58
|
-
* The 0..N systems this module has deployed onto (
|
|
58
|
+
* The 0..N systems this module has deployed onto (openspec/specs/module-systems-addressing/spec.md).
|
|
59
59
|
* Always an array — no singular convenience, so the 0/1/N reality stays visible.
|
|
60
60
|
*/
|
|
61
61
|
systems: DeployedSystem[];
|
|
@@ -145,7 +145,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
147
|
/**
|
|
148
|
-
* Per-system DNS lifecycle hook (
|
|
148
|
+
* Per-system DNS lifecycle hook (openspec/specs/internal-dns-split-horizon/spec.md D5).
|
|
149
149
|
*
|
|
150
150
|
* A dns_internal provider declares this hook; celilo's internal-dns bridge
|
|
151
151
|
* invokes it once per host when a system.created/destroyed event fires,
|
|
@@ -198,7 +198,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
198
198
|
* Build-bus upstream publish hook. The executor passes the
|
|
199
199
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
|
200
200
|
* CELILO_EVENT_PACKAGE_NAME, etc.) rather than as named inputs
|
|
201
|
-
* here — see
|
|
201
|
+
* here — see openspec/changes/build-bus-poll-cd/proposal.md Phase 4 + the hook-dispatch executor.
|
|
202
202
|
* No structured outputs; hook reports success via exit code.
|
|
203
203
|
*/
|
|
204
204
|
on_upstream_publish: {
|
package/src/manifest/schema.ts
CHANGED
|
@@ -214,7 +214,7 @@ export const EnsureSchema = z.object({
|
|
|
214
214
|
export type Ensure = z.infer<typeof EnsureSchema>;
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
* A computed capability field (
|
|
217
|
+
* A computed capability field (openspec/specs/internal-dns-split-horizon/spec.md D1).
|
|
218
218
|
*
|
|
219
219
|
* Declared alongside `data`, but derived ON ACCESS from other values via the
|
|
220
220
|
* `value:` DSL (see src/variables/computed/) and never persisted. The result
|
|
@@ -275,7 +275,7 @@ export const LifecycleHookSchema = z.object({
|
|
|
275
275
|
/**
|
|
276
276
|
* Build-bus upstream-publish hook. Fires when a publish event lands
|
|
277
277
|
* on the local event bus from the receiver daemon
|
|
278
|
-
* ([[
|
|
278
|
+
* ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4). Each module can declare multiple
|
|
279
279
|
* entries, each with its own match rule + action script.
|
|
280
280
|
*
|
|
281
281
|
* The action script runs with `CELILO_EVENT_PAYLOAD` in its env (the
|
|
@@ -326,7 +326,7 @@ export const SystemResourceSchema = z.object({
|
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
|
-
* One system a module deploys (
|
|
329
|
+
* One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module
|
|
330
330
|
* declares 0..N of these under `requires.systems`. `name` is the stable
|
|
331
331
|
* authoring-time handle — referenced in templates via `$infra:<name>.…` and the
|
|
332
332
|
* per-system key in `module_systems`. `resources` carries the per-system machine
|
|
@@ -390,7 +390,7 @@ export const ModuleSubscriptionSchema = z
|
|
|
390
390
|
handler: z.string().min(1).optional(),
|
|
391
391
|
/**
|
|
392
392
|
* Name of one of THIS module's own hooks to invoke when a matching event
|
|
393
|
-
* fires (
|
|
393
|
+
* fires (openspec/specs/event-driven-hook-subscriptions/spec.md). The framework synthesizes
|
|
394
394
|
* a `celilo events run-hook` handler that runs the hook in a
|
|
395
395
|
* fault-isolated subprocess with backend access (DB, capabilities,
|
|
396
396
|
* secrets). Mutually exclusive with `handler`.
|
|
@@ -445,7 +445,7 @@ export const ModuleManifestSchema = z
|
|
|
445
445
|
|
|
446
446
|
/**
|
|
447
447
|
* How `manifest.yml#version` (the PAYLOAD version) is determined — see
|
|
448
|
-
*
|
|
448
|
+
* openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
|
|
449
449
|
* lives in `provides.capabilities[].version` and is unrelated to this.
|
|
450
450
|
*
|
|
451
451
|
* - `changeset` — first-party apps/content (lunacycle): the version is
|
|
@@ -471,7 +471,7 @@ export const ModuleManifestSchema = z
|
|
|
471
471
|
.object({
|
|
472
472
|
capabilities: z.array(CapabilityRequirementSchema).default([]),
|
|
473
473
|
/**
|
|
474
|
-
* The 0..N systems this module deploys (
|
|
474
|
+
* The 0..N systems this module deploys (openspec/specs/module-systems-addressing/spec.md).
|
|
475
475
|
* Preferred over the singular `system`. A module declaring
|
|
476
476
|
* `systems` gets one host per entry, each addressable as `$infra:<name>`.
|
|
477
477
|
*/
|
|
@@ -545,7 +545,7 @@ export const ModuleManifestSchema = z
|
|
|
545
545
|
* to (de)register a single host's A records when celilo's bridge
|
|
546
546
|
* delivers a system.created/destroyed event. Inputs (hostname,
|
|
547
547
|
* target_ip, op) come from the contract — see contracts/v1.ts and
|
|
548
|
-
* [[
|
|
548
|
+
* [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
|
|
549
549
|
*/
|
|
550
550
|
on_system_event: LifecycleHookSchema.optional(),
|
|
551
551
|
/**
|
|
@@ -553,7 +553,7 @@ export const ModuleManifestSchema = z
|
|
|
553
553
|
* change event. The caddy `public_web` provider declares this to
|
|
554
554
|
* re-render its Caddyfile from web_routes when a consumer registers or
|
|
555
555
|
* unregisters a route (ISS-0035). See
|
|
556
|
-
* [[
|
|
556
|
+
* [[openspec/specs/public-web-provider-reconcile/spec.md]].
|
|
557
557
|
*/
|
|
558
558
|
reconcile_routes: LifecycleHookSchema.optional(),
|
|
559
559
|
/**
|
|
@@ -569,7 +569,7 @@ export const ModuleManifestSchema = z
|
|
|
569
569
|
/**
|
|
570
570
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
571
571
|
* to multiple upstream packages with different actions). See
|
|
572
|
-
* [[
|
|
572
|
+
* [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
|
|
573
573
|
*/
|
|
574
574
|
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
575
575
|
})
|
|
@@ -645,7 +645,7 @@ export const ModuleManifestSchema = z
|
|
|
645
645
|
|
|
646
646
|
/**
|
|
647
647
|
* Event-bus subscriptions this module wants registered. See
|
|
648
|
-
* `infra/
|
|
648
|
+
* `infra/openspec/specs/event-bus/spec.md`. Truly optional so existing
|
|
649
649
|
* test fixtures and manifests don't need a churn-pass; readers
|
|
650
650
|
* use `manifest.subscriptions ?? []`.
|
|
651
651
|
*/
|
|
@@ -654,7 +654,7 @@ export const ModuleManifestSchema = z
|
|
|
654
654
|
/**
|
|
655
655
|
* Optional base-module aspect — lightweight fleet-configuration
|
|
656
656
|
* code applied to OTHER systems in declared zones, on top of
|
|
657
|
-
* the module's primary deployment. See
|
|
657
|
+
* the module's primary deployment. See openspec/specs/base-module-aspects/spec.md.
|
|
658
658
|
*
|
|
659
659
|
* Modules without this block work exactly as today. When
|
|
660
660
|
* declared, the operator approves the aspect's scope at
|
|
@@ -723,7 +723,7 @@ export const ModuleManifestSchema = z
|
|
|
723
723
|
ansible_vars: z.record(z.string(), z.string()).optional(),
|
|
724
724
|
|
|
725
725
|
/**
|
|
726
|
-
* Optional Proxmox reconciliation (
|
|
726
|
+
* Optional Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5).
|
|
727
727
|
*
|
|
728
728
|
* When an aspect manages a setting that Proxmox also owns
|
|
729
729
|
* authoritatively (DNS resolver via `proxmox_lxc.nameserver`
|
|
@@ -779,7 +779,7 @@ export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>
|
|
|
779
779
|
export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
|
|
780
780
|
|
|
781
781
|
/**
|
|
782
|
-
* Normalize a manifest's declared systems (
|
|
782
|
+
* Normalize a manifest's declared systems (openspec/specs/module-systems-addressing/spec.md).
|
|
783
783
|
* Returns `requires.systems` if present; else the singular `requires.system`
|
|
784
784
|
* as one entry named `main`; else `[]` (config-only module like namecheap).
|
|
785
785
|
* This is the single place the system→systems sugar lives, so the rest of the
|
|
@@ -72,7 +72,7 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
|
72
72
|
'vlan', // Auto-derived from zone configuration
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
75
|
-
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (
|
|
75
|
+
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (openspec/specs/lxc-dns-at-birth/spec.md)
|
|
76
76
|
// Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
|
|
77
77
|
// disk,storage}, which are injected during resolution from the module_systems
|
|
78
78
|
// table (falling back to requires.system.*) — see variables/context.ts. Like
|
|
@@ -10,6 +10,7 @@ import { validateModuleDirectory } from '../import';
|
|
|
10
10
|
import { computeFileChecksum } from './checksum';
|
|
11
11
|
import { includeNodeModulesPath } from './package-rules';
|
|
12
12
|
import { signChecksums } from './signature';
|
|
13
|
+
import { rewriteWorkspaceDeps } from './workspace-deps';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Checksums data structure
|
|
@@ -222,6 +223,24 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
222
223
|
const manifest = parseYaml(manifestContent);
|
|
223
224
|
if (manifest.build?.command || manifest.build?.script) {
|
|
224
225
|
log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
|
|
226
|
+
|
|
227
|
+
// Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
|
|
228
|
+
// buildDir has no workspace root, so a sibling monorepo package pulled in
|
|
229
|
+
// via `workspace:^` won't resolve during the build's `bun install`.
|
|
230
|
+
// Rewrite those specs to `file:` paths pointing at the live monorepo
|
|
231
|
+
// members — always-fresh, no npm-pin lag, no hand-rolled
|
|
232
|
+
// CELILO_MODULE_SOURCE_DIR dance. Fails loudly naming any unresolvable dep.
|
|
233
|
+
try {
|
|
234
|
+
for (const rewrite of rewriteWorkspaceDeps(buildDir, sourceDir)) {
|
|
235
|
+
log.info(`Resolved workspace dep: ${rewrite}`);
|
|
236
|
+
}
|
|
237
|
+
} catch (rewriteError) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: rewriteError instanceof Error ? rewriteError.message : String(rewriteError),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
// The build runs in a staged copy of the module (buildDir), so
|
|
226
245
|
// relative paths that reach outside the module (e.g. sibling packages
|
|
227
246
|
// in a monorepo) don't resolve. Expose the ORIGINAL unstaged source
|