@celilo/cli 0.26.1 → 1.0.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 +3 -0
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0025_port_forward_owner.sql +29 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/cli/commands/module-remove.ts +26 -23
- package/src/cli/commands/system-audit.ts +5 -1
- package/src/cli/commands/system-update.ts +10 -2
- package/src/db/schema.ts +30 -10
- package/src/hooks/capability-loader.ts +65 -13
- package/src/hooks/define-hook.test.ts +4 -6
- package/src/hooks/executor.ts +2 -1
- package/src/hooks/types.ts +9 -17
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/index.ts +20 -0
- package/src/manifest/contracts/v1.ts +33 -1
- package/src/manifest/schema.ts +48 -58
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/undeployed-modules.ts +18 -1
- package/src/services/consumer-cleanup.test.ts +347 -0
- package/src/services/consumer-cleanup.ts +244 -0
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-validator/index.test.ts +9 -0
- package/src/services/port-forwards.test.ts +93 -40
- package/src/services/port-forwards.ts +74 -48
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/services/trusted-sources.test.ts +52 -13
- package/src/services/trusted-sources.ts +25 -15
- package/src/test-utils/cli-context.ts +15 -2
- package/src/types/infrastructure.ts +11 -1
- package/src/services/web-route-cleanup.test.ts +0 -250
- package/src/services/web-route-cleanup.ts +0 -144
|
@@ -59,38 +59,18 @@ export interface DeployResult {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
): Promise<void> {
|
|
75
|
-
// Get current machine record
|
|
76
|
-
const machine = await db.select().from(machines).where(eq(machines.id, machineId)).get();
|
|
77
|
-
|
|
78
|
-
if (!machine) {
|
|
79
|
-
throw new Error(`Machine not found: ${machineId}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Add module ID if not already assigned
|
|
83
|
-
const assignedIds = machine.assignedModuleIds || [];
|
|
84
|
-
if (!assignedIds.includes(moduleId)) {
|
|
85
|
-
const updatedIds = [...assignedIds, moduleId];
|
|
86
|
-
await db
|
|
87
|
-
.update(machines)
|
|
88
|
-
.set({ assignedModuleIds: updatedIds })
|
|
89
|
-
.where(eq(machines.id, machineId))
|
|
90
|
-
.run();
|
|
91
|
-
log.success(`Machine ${machineId} updated with module assignment`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
62
|
+
// `updateMachineAssignment` is deleted (celilo#773).
|
|
63
|
+
//
|
|
64
|
+
// It was the sole writer of `machines.assigned_module_ids`, it only ever
|
|
65
|
+
// APPENDED, and nothing ever removed an entry — `module remove` deletes the
|
|
66
|
+
// module row and never touches `machines`. So a machine accumulated the ids of
|
|
67
|
+
// modules that no longer existed and could never be freed except by editing the
|
|
68
|
+
// database.
|
|
69
|
+
//
|
|
70
|
+
// Nothing replaces it. Occupancy is now derived from `module_infrastructure`
|
|
71
|
+
// and `module_systems` at the point of use (`getModulesOnMachine`), both of
|
|
72
|
+
// which the deploy path already writes and both of which cascade on module
|
|
73
|
+
// removal — so the machine frees itself with no bookkeeping step to forget.
|
|
94
74
|
|
|
95
75
|
export interface DeployOptions {
|
|
96
76
|
debug?: boolean;
|
|
@@ -1203,13 +1183,11 @@ async function deployModuleImpl(
|
|
|
1203
1183
|
};
|
|
1204
1184
|
}
|
|
1205
1185
|
|
|
1206
|
-
if (plan.infrastructure?.type === '
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
plan.infrastructure.serviceId
|
|
1212
|
-
) {
|
|
1186
|
+
if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
|
|
1187
|
+
// Placeholder branch retained below; the machine branch is gone because
|
|
1188
|
+
// occupancy is no longer a stored fact to update (celilo#773).
|
|
1189
|
+
}
|
|
1190
|
+
if (plan.infrastructure?.type === 'container_service' && plan.infrastructure.serviceId) {
|
|
1213
1191
|
// TODO: extract Terraform outputs and persist them on
|
|
1214
1192
|
// module_infrastructure.containerMetadata. Until that lands,
|
|
1215
1193
|
// the deploy still succeeds — we just don't track which
|
|
@@ -15,6 +15,15 @@ const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
|
15
15
|
const CADDY_MODULE_PATH = resolve(REPO_ROOT, 'modules/caddy');
|
|
16
16
|
|
|
17
17
|
describe('runChecks (orchestrator)', () => {
|
|
18
|
+
// celilo#804: this test timed out at 60000ms on a docs-only CI run yet
|
|
19
|
+
// measures ~500ms end-to-end locally (all 3 tests in this file, combined).
|
|
20
|
+
// The work here is a handful of synchronous `git`/`spawnSync` calls
|
|
21
|
+
// (checkGitHygiene) with nothing to cut without dropping the git_hygiene
|
|
22
|
+
// check this test exists to exercise — so this was a saturated CI runner
|
|
23
|
+
// stalling those spawns, not this suite sitting close to its budget.
|
|
24
|
+
// Raising the timeout further wouldn't shrink that margin (a real stall
|
|
25
|
+
// would blow through any budget); left at the file's default (60000ms,
|
|
26
|
+
// ~100x the measured runtime) rather than bumped without evidence it helps.
|
|
18
27
|
test('healthy in-tree module produces all-ok report', async () => {
|
|
19
28
|
const checks = await runChecks(CADDY_MODULE_PATH, {
|
|
20
29
|
noBuild: true,
|
|
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import type { PortForwardStore } from '@celilo/capabilities';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
6
7
|
import type { DbClient } from '../db/client';
|
|
8
|
+
import { portForwards } from '../db/schema';
|
|
7
9
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
8
|
-
import { buildPortForwardStore } from './port-forwards';
|
|
10
|
+
import { buildPortForwardStore, deletePortForwardsForModule } from './port-forwards';
|
|
9
11
|
|
|
10
12
|
const FW = '192.168.0.254';
|
|
13
|
+
const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
|
|
11
14
|
|
|
12
15
|
describe('port-forward store', () => {
|
|
13
16
|
let dir: string;
|
|
@@ -19,7 +22,7 @@ describe('port-forward store', () => {
|
|
|
19
22
|
const dbPath = join(dir, 'celilo.db');
|
|
20
23
|
process.env.CELILO_DB_PATH = dbPath;
|
|
21
24
|
db = await setupTestDatabase(dbPath);
|
|
22
|
-
store = buildPortForwardStore(db);
|
|
25
|
+
store = buildPortForwardStore(db, 'caddy');
|
|
23
26
|
});
|
|
24
27
|
afterEach(() => {
|
|
25
28
|
db.$client.close();
|
|
@@ -31,62 +34,112 @@ describe('port-forward store', () => {
|
|
|
31
34
|
}
|
|
32
35
|
});
|
|
33
36
|
|
|
34
|
-
it('
|
|
35
|
-
store.
|
|
36
|
-
store.add(FW, {
|
|
37
|
-
internalIp: '10.0.20.42',
|
|
38
|
-
port: 2222,
|
|
39
|
-
protocol: 'TCP',
|
|
40
|
-
description: 'forgejo',
|
|
41
|
-
});
|
|
37
|
+
it('declares and lists forwards for a firewall', () => {
|
|
38
|
+
store.replace(FW, CADDY, [80, 443]);
|
|
42
39
|
const forwards = store.list(FW);
|
|
43
40
|
expect(forwards).toHaveLength(2);
|
|
44
|
-
expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([
|
|
41
|
+
expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([80, 443]);
|
|
45
42
|
});
|
|
46
43
|
|
|
47
|
-
it('
|
|
48
|
-
store.
|
|
49
|
-
store.
|
|
44
|
+
it('stamps registeredBy from the bound consumer, and no caller can supply it', () => {
|
|
45
|
+
store.replace(FW, CADDY, [443]);
|
|
46
|
+
expect(store.list(FW)[0].registeredBy).toBe('caddy');
|
|
47
|
+
// The write takes only (firewall, target, ports) — there is no parameter an
|
|
48
|
+
// owner could be passed through, which is the point rather than an omission.
|
|
49
|
+
expect(store.replace.length).toBe(3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('replace is idempotent — same declaration twice → one row per port', () => {
|
|
53
|
+
store.replace(FW, { ...CADDY, description: 'v1' }, [443]);
|
|
54
|
+
store.replace(FW, { ...CADDY, description: 'v2' }, [443]);
|
|
50
55
|
const forwards = store.list(FW);
|
|
51
56
|
expect(forwards).toHaveLength(1);
|
|
52
57
|
expect(forwards[0].description).toBe('v2');
|
|
53
58
|
});
|
|
54
59
|
|
|
55
60
|
it('scopes forwards by firewallIp', () => {
|
|
56
|
-
store.
|
|
57
|
-
store.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
61
|
+
store.replace(FW, CADDY, [443]);
|
|
62
|
+
store.replace(
|
|
63
|
+
'10.0.30.254',
|
|
64
|
+
{ internalIp: '10.0.30.5', protocol: 'UDP', description: 'b' },
|
|
65
|
+
[53],
|
|
66
|
+
);
|
|
63
67
|
expect(store.list(FW)).toHaveLength(1);
|
|
64
68
|
expect(store.list('10.0.30.254')).toHaveLength(1);
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
it('remove deletes exactly the tuple and leaves others', () => {
|
|
68
|
-
store.add(FW, { internalIp: '10.0.20.5', port: 443, protocol: 'TCP', description: 'x' });
|
|
69
|
-
store.add(FW, { internalIp: '10.0.20.5', port: 80, protocol: 'TCP', description: 'y' });
|
|
70
|
-
store.remove(FW, '10.0.20.5', 443, 'TCP');
|
|
71
|
-
const forwards = store.list(FW);
|
|
72
|
-
expect(forwards).toHaveLength(1);
|
|
73
|
-
expect(forwards[0].port).toBe(80);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
71
|
it('ingressIp forwards are distinct from the public (NULL) ones', () => {
|
|
77
|
-
|
|
78
|
-
store.
|
|
79
|
-
|
|
80
|
-
port: 53,
|
|
81
|
-
protocol: 'UDP',
|
|
82
|
-
ingressIp: '10.0.10.53',
|
|
83
|
-
description: 'ingress',
|
|
84
|
-
});
|
|
72
|
+
const target = { internalIp: '10.0.20.5', protocol: 'UDP' as const, description: 'dns' };
|
|
73
|
+
store.replace(FW, target, [53]);
|
|
74
|
+
store.replace(FW, { ...target, ingressIp: '10.0.10.53' }, [53]);
|
|
85
75
|
expect(store.list(FW)).toHaveLength(2);
|
|
86
|
-
//
|
|
87
|
-
store.
|
|
76
|
+
// Re-declaring the public (NULL-ingress) set as empty leaves the ingress one.
|
|
77
|
+
store.replace(FW, target, []);
|
|
88
78
|
const forwards = store.list(FW);
|
|
89
79
|
expect(forwards).toHaveLength(1);
|
|
90
80
|
expect(forwards[0].ingressIp).toBe('10.0.10.53');
|
|
91
81
|
});
|
|
82
|
+
|
|
83
|
+
// D5b / celilo#855. Before this, `exposeService` upserted per port and nothing
|
|
84
|
+
// ever removed a forward a consumer stopped wanting: a module that exposed
|
|
85
|
+
// :8080 and redeployed exposing :9090 kept both, forever.
|
|
86
|
+
it('re-declaring with a shorter port list drops the ports left out', () => {
|
|
87
|
+
store.replace(FW, CADDY, [80, 443, 8080]);
|
|
88
|
+
store.replace(FW, CADDY, [80, 443]);
|
|
89
|
+
expect(
|
|
90
|
+
store
|
|
91
|
+
.list(FW)
|
|
92
|
+
.map((f) => f.port)
|
|
93
|
+
.sort((a, b) => a - b),
|
|
94
|
+
).toEqual([80, 443]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('a consumer narrowing its declaration withdraws no other module rows', () => {
|
|
98
|
+
const forgejo = buildPortForwardStore(db, 'forgejo');
|
|
99
|
+
store.replace(FW, CADDY, [80, 443]);
|
|
100
|
+
forgejo.replace(FW, { internalIp: '10.0.20.42', protocol: 'TCP', description: 'git' }, [2222]);
|
|
101
|
+
|
|
102
|
+
store.replace(FW, CADDY, [443]);
|
|
103
|
+
|
|
104
|
+
expect(store.list(FW).filter((f) => f.registeredBy === 'forgejo')).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// D5a, the refcount case — the bug most likely to ship silently. The owner is
|
|
108
|
+
// IN the unique index, so two consumers of the same forward are two rows and
|
|
109
|
+
// one leaving does not delete a rule the other still needs.
|
|
110
|
+
it('two consumers of the SAME forward are two rows, and one leaving leaves the other', () => {
|
|
111
|
+
const other = buildPortForwardStore(db, 'greenwave-app');
|
|
112
|
+
store.replace(FW, CADDY, [443]);
|
|
113
|
+
other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
|
|
114
|
+
expect(store.list(FW)).toHaveLength(2);
|
|
115
|
+
|
|
116
|
+
deletePortForwardsForModule(db, 'caddy');
|
|
117
|
+
|
|
118
|
+
const left = store.list(FW);
|
|
119
|
+
expect(left).toHaveLength(1);
|
|
120
|
+
expect(left[0].registeredBy).toBe('greenwave-app');
|
|
121
|
+
expect(left[0].port).toBe(443);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Migration 0025 leaves pre-existing rows with an empty owner so the fleet
|
|
125
|
+
// keeps serving. They are adopted the first time their owner re-declares the
|
|
126
|
+
// same target, which is the only moment we can safely say who owns them.
|
|
127
|
+
it('adopts an unattributed legacy row when its owner re-declares the target', () => {
|
|
128
|
+
db.insert(portForwards)
|
|
129
|
+
.values({
|
|
130
|
+
firewallIp: FW,
|
|
131
|
+
internalIp: CADDY.internalIp,
|
|
132
|
+
port: 443,
|
|
133
|
+
protocol: 'TCP',
|
|
134
|
+
description: 'legacy',
|
|
135
|
+
registeredBy: '',
|
|
136
|
+
})
|
|
137
|
+
.run();
|
|
138
|
+
|
|
139
|
+
store.replace(FW, CADDY, [443]);
|
|
140
|
+
|
|
141
|
+
const rows = db.select().from(portForwards).where(eq(portForwards.firewallIp, FW)).all();
|
|
142
|
+
expect(rows).toHaveLength(1);
|
|
143
|
+
expect(rows[0].registeredBy).toBe('caddy');
|
|
144
|
+
});
|
|
92
145
|
});
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* Port-forward registry — the DB-backed `PortForwardStore` (openspec/changes/unified-management-no-ssh/proposal.md).
|
|
3
3
|
*
|
|
4
4
|
* The shared-core desired-state store for the `firewall` capability. The
|
|
5
|
-
* capability-loader constructs one of these
|
|
6
|
-
* provider factory, so `exposeService
|
|
7
|
-
*
|
|
8
|
-
* `list(firewallIp)
|
|
9
|
-
* "read the box back with `iptables -L`" as the
|
|
5
|
+
* capability-loader constructs one of these bound to the CONSUMING module and
|
|
6
|
+
* injects it into the firewall provider factory, so `exposeService` becomes a
|
|
7
|
+
* declaration of that consumer's complete port set and the provider's converge
|
|
8
|
+
* renders the whole ruleset from `list(firewallIp)`, applying it atomically
|
|
9
|
+
* (`iptables-restore`). Replaces "read the box back with `iptables -L`" as the
|
|
10
|
+
* source of truth.
|
|
11
|
+
*
|
|
12
|
+
* `registeredBy` is stamped HERE, never accepted from a caller — the same rule
|
|
13
|
+
* `buildTrustedSourceStore` follows, so a forward cannot be attributed to the
|
|
14
|
+
* wrong module (openspec/changes/consumer-removal-cleanup, D2).
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import type { PortForward, PortForwardStore,
|
|
13
|
-
import { and, eq, isNull } from 'drizzle-orm';
|
|
17
|
+
import type { PortForward, PortForwardStore, PortForwardTarget } from '@celilo/capabilities';
|
|
18
|
+
import { and, eq, isNull, or } from 'drizzle-orm';
|
|
14
19
|
import type { DbClient } from '../db/client';
|
|
15
20
|
import { portForwards } from '../db/schema';
|
|
16
21
|
|
|
@@ -19,7 +24,37 @@ function ingressMatch(ingressIp: string | undefined) {
|
|
|
19
24
|
return ingressIp ? eq(portForwards.ingressIp, ingressIp) : isNull(portForwards.ingressIp);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Every row this consumer owns for one target, plus the UNATTRIBUTED rows for
|
|
29
|
+
* the same target.
|
|
30
|
+
*
|
|
31
|
+
* The unattributed half is the migration's other end: rows written before
|
|
32
|
+
* `registered_by` existed carry `''`, and a consumer re-declaring the target
|
|
33
|
+
* they describe is the one moment we can safely say who owns them — one module
|
|
34
|
+
* owns a backend IP. Without this they would render forever with no owner to
|
|
35
|
+
* withdraw them.
|
|
36
|
+
*/
|
|
37
|
+
function ownedOrUnattributed(firewallIp: string, target: PortForwardTarget, consumer: string) {
|
|
38
|
+
return and(
|
|
39
|
+
eq(portForwards.firewallIp, firewallIp),
|
|
40
|
+
eq(portForwards.internalIp, target.internalIp),
|
|
41
|
+
eq(portForwards.protocol, target.protocol),
|
|
42
|
+
ingressMatch(target.ingressIp),
|
|
43
|
+
or(eq(portForwards.registeredBy, consumer), eq(portForwards.registeredBy, '')),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the store bound to the module that will call through the capability.
|
|
49
|
+
*
|
|
50
|
+
* ponytail: a consumer that stops exposing a target ENTIRELY — its backend IP
|
|
51
|
+
* changes, or it drops a host — leaves rows for the old target, because no call
|
|
52
|
+
* arrives to declare that target's set empty. Bounded: the rows die when the
|
|
53
|
+
* module is removed, and a redeploy onto a new host is the only way to reach
|
|
54
|
+
* it. Closing it needs a sweep against `getModuleSystems`, which is not
|
|
55
|
+
* obviously worth its own failure mode.
|
|
56
|
+
*/
|
|
57
|
+
export function buildPortForwardStore(db: DbClient, registeredBy: string): PortForwardStore {
|
|
23
58
|
return {
|
|
24
59
|
list(firewallIp: string): PortForward[] {
|
|
25
60
|
return db
|
|
@@ -33,54 +68,45 @@ export function buildPortForwardStore(db: DbClient): PortForwardStore {
|
|
|
33
68
|
protocol: r.protocol,
|
|
34
69
|
ingressIp: r.ingressIp ?? undefined,
|
|
35
70
|
description: r.description,
|
|
71
|
+
registeredBy: r.registeredBy,
|
|
36
72
|
}));
|
|
37
73
|
},
|
|
38
74
|
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
75
|
+
replace(firewallIp: string, target: PortForwardTarget, ports: number[]): void {
|
|
76
|
+
// Delete-then-insert over the consumer's WHOLE set for this target, not
|
|
77
|
+
// per port: that is what makes a redeploy exposing a shorter port list
|
|
78
|
+
// withdraw the ports it left out (celilo#855). Scoped to this consumer,
|
|
79
|
+
// so another module's forward for the same target is untouched.
|
|
43
80
|
db.delete(portForwards)
|
|
44
|
-
.where(
|
|
45
|
-
and(
|
|
46
|
-
eq(portForwards.firewallIp, firewallIp),
|
|
47
|
-
eq(portForwards.internalIp, forward.internalIp),
|
|
48
|
-
eq(portForwards.port, forward.port),
|
|
49
|
-
eq(portForwards.protocol, forward.protocol),
|
|
50
|
-
ingressMatch(forward.ingressIp),
|
|
51
|
-
),
|
|
52
|
-
)
|
|
81
|
+
.where(ownedOrUnattributed(firewallIp, target, registeredBy))
|
|
53
82
|
.run();
|
|
54
|
-
db.insert(portForwards)
|
|
55
|
-
.values({
|
|
56
|
-
firewallIp,
|
|
57
|
-
internalIp: forward.internalIp,
|
|
58
|
-
port: forward.port,
|
|
59
|
-
protocol: forward.protocol,
|
|
60
|
-
ingressIp: forward.ingressIp ?? null,
|
|
61
|
-
description: forward.description,
|
|
62
|
-
})
|
|
63
|
-
.run();
|
|
64
|
-
},
|
|
65
83
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
eq(portForwards.protocol, protocol),
|
|
80
|
-
ingressMatch(ingressIp),
|
|
81
|
-
),
|
|
84
|
+
if (ports.length === 0) return;
|
|
85
|
+
|
|
86
|
+
db.insert(portForwards)
|
|
87
|
+
.values(
|
|
88
|
+
ports.map((port) => ({
|
|
89
|
+
firewallIp,
|
|
90
|
+
internalIp: target.internalIp,
|
|
91
|
+
port,
|
|
92
|
+
protocol: target.protocol,
|
|
93
|
+
ingressIp: target.ingressIp ?? null,
|
|
94
|
+
description: target.description,
|
|
95
|
+
registeredBy,
|
|
96
|
+
})),
|
|
82
97
|
)
|
|
83
98
|
.run();
|
|
84
99
|
},
|
|
85
100
|
};
|
|
86
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Drop every forward a departing module owns, across every firewall.
|
|
105
|
+
*
|
|
106
|
+
* `registered_by` is plain text rather than a FK, so unlike `web_routes` these
|
|
107
|
+
* rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
|
|
108
|
+
* the provider has converged without them (D4).
|
|
109
|
+
*/
|
|
110
|
+
export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
|
|
111
|
+
db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
|
|
112
|
+
}
|
|
@@ -55,7 +55,6 @@ describe('a machine with no stored SSH key fails loudly, not silently', () => {
|
|
|
55
55
|
hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 100 },
|
|
56
56
|
role: 'host',
|
|
57
57
|
interfaces: [],
|
|
58
|
-
assignedModuleIds: [],
|
|
59
58
|
});
|
|
60
59
|
|
|
61
60
|
await expect(writeTemporarySshKey(machine.id)).rejects.toThrow(/has no SSH key stored/);
|
|
@@ -114,7 +113,6 @@ describe('ssh-key-manager', () => {
|
|
|
114
113
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
115
114
|
role: 'host',
|
|
116
115
|
interfaces: [],
|
|
117
|
-
assignedModuleIds: [],
|
|
118
116
|
});
|
|
119
117
|
|
|
120
118
|
const keyPath = await writeTemporarySshKey(machine.id);
|
|
@@ -142,7 +140,6 @@ describe('ssh-key-manager', () => {
|
|
|
142
140
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
143
141
|
role: 'host',
|
|
144
142
|
interfaces: [],
|
|
145
|
-
assignedModuleIds: [],
|
|
146
143
|
});
|
|
147
144
|
|
|
148
145
|
const keyPath = await writeTemporarySshKey(machine.id);
|
|
@@ -163,7 +160,6 @@ describe('ssh-key-manager', () => {
|
|
|
163
160
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
164
161
|
role: 'host',
|
|
165
162
|
interfaces: [],
|
|
166
|
-
assignedModuleIds: [],
|
|
167
163
|
});
|
|
168
164
|
|
|
169
165
|
const keyPath = await writeTemporarySshKey(machine.id);
|
|
@@ -189,7 +185,6 @@ describe('ssh-key-manager', () => {
|
|
|
189
185
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
190
186
|
role: 'host',
|
|
191
187
|
interfaces: [],
|
|
192
|
-
assignedModuleIds: [],
|
|
193
188
|
});
|
|
194
189
|
|
|
195
190
|
const machine2 = await addMachine({
|
|
@@ -201,7 +196,6 @@ describe('ssh-key-manager', () => {
|
|
|
201
196
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
202
197
|
role: 'host',
|
|
203
198
|
interfaces: [],
|
|
204
|
-
assignedModuleIds: [],
|
|
205
199
|
});
|
|
206
200
|
|
|
207
201
|
const keyPath1 = await writeTemporarySshKey(machine1.id);
|
|
@@ -232,7 +226,6 @@ describe('ssh-key-manager', () => {
|
|
|
232
226
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
233
227
|
role: 'host',
|
|
234
228
|
interfaces: [],
|
|
235
|
-
assignedModuleIds: [],
|
|
236
229
|
});
|
|
237
230
|
|
|
238
231
|
const managedKey = new ManagedSshKey(machine.id);
|
|
@@ -252,7 +245,6 @@ describe('ssh-key-manager', () => {
|
|
|
252
245
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
253
246
|
role: 'host',
|
|
254
247
|
interfaces: [],
|
|
255
|
-
assignedModuleIds: [],
|
|
256
248
|
});
|
|
257
249
|
|
|
258
250
|
const managedKey = new ManagedSshKey(machine.id);
|
|
@@ -274,7 +266,6 @@ describe('ssh-key-manager', () => {
|
|
|
274
266
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
275
267
|
role: 'host',
|
|
276
268
|
interfaces: [],
|
|
277
|
-
assignedModuleIds: [],
|
|
278
269
|
});
|
|
279
270
|
|
|
280
271
|
const managedKey = new ManagedSshKey(machine.id);
|
|
@@ -301,7 +292,6 @@ describe('ssh-key-manager', () => {
|
|
|
301
292
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
302
293
|
role: 'host',
|
|
303
294
|
interfaces: [],
|
|
304
|
-
assignedModuleIds: [],
|
|
305
295
|
});
|
|
306
296
|
|
|
307
297
|
const managedKey = new ManagedSshKey(machine.id);
|
|
@@ -40,27 +40,63 @@ describe('trusted-source store', () => {
|
|
|
40
40
|
|
|
41
41
|
it('stamps registeredBy from the binding, not the caller', () => {
|
|
42
42
|
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
43
|
-
store.
|
|
43
|
+
store.replace(FW, { subnets: [VPN], description: 'admin VPN clients' });
|
|
44
44
|
|
|
45
45
|
expect(store.list(FW)).toEqual([
|
|
46
46
|
{ subnet: VPN, description: 'admin VPN clients', registeredBy: 'wireguard' },
|
|
47
47
|
]);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('
|
|
50
|
+
it('replace is idempotent — declaring the same set twice yields one row', () => {
|
|
51
51
|
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
52
|
-
store.
|
|
53
|
-
store.
|
|
52
|
+
store.replace(FW, { subnets: [VPN], description: 'first' });
|
|
53
|
+
store.replace(FW, { subnets: [VPN], description: 'second' });
|
|
54
54
|
|
|
55
55
|
expect(store.list(FW)).toHaveLength(1);
|
|
56
56
|
expect(store.list(FW)[0].description).toBe('second');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// D5b: the set is DECLARED, so changing an admin VPN's client subnet revokes
|
|
60
|
+
// the old one's reach. It used to keep reaching every zone forever.
|
|
61
|
+
it('a subnet left out of a later declaration loses its reach', () => {
|
|
62
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
63
|
+
store.replace(FW, { subnets: [VPN, '10.9.9.0/24'], description: 'vpn' });
|
|
64
|
+
store.replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
expect(store.list(FW).map((s) => s.subnet)).toEqual([VPN]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('an empty declaration withdraws the consumer’s whole set', () => {
|
|
70
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
71
|
+
store.replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
72
|
+
store.replace(FW, { subnets: [], description: 'vpn' });
|
|
73
|
+
|
|
74
|
+
expect(store.list(FW)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// D5a, the refcount case: the owner is in the unique index, so two modules
|
|
78
|
+
// trusting the same subnet are two rows and one withdrawing does not revoke
|
|
79
|
+
// the other's reach.
|
|
80
|
+
it('two consumers trusting the same subnet are two rows; one withdrawing leaves the other', () => {
|
|
81
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
82
|
+
buildTrustedSourceStore(db, 'other').replace(FW, { subnets: [VPN], description: 'also vpn' });
|
|
83
|
+
expect(listTrustedSourcesFor(db, FW)).toHaveLength(2);
|
|
84
|
+
|
|
85
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
|
|
86
|
+
|
|
87
|
+
expect(listTrustedSourcesFor(db, FW)).toEqual([
|
|
88
|
+
{ subnet: VPN, description: 'also vpn', registeredBy: 'other' },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('withdrawing on one firewall leaves other firewalls alone', () => {
|
|
93
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
94
|
+
buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
|
|
95
|
+
subnets: [VPN],
|
|
96
|
+
description: 'elsewhere',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [], description: 'vpn' });
|
|
64
100
|
|
|
65
101
|
expect(buildTrustedSourceStore(db, 'wireguard').list(FW)).toEqual([]);
|
|
66
102
|
expect(buildTrustedSourceStore(db, 'other').list('10.0.0.1')).toHaveLength(1);
|
|
@@ -69,9 +105,9 @@ describe('trusted-source store', () => {
|
|
|
69
105
|
it('reads one firewall’s registrations without a module binding', () => {
|
|
70
106
|
// Trust registered against one firewall is not trust granted by another —
|
|
71
107
|
// the read is scoped, and the reader needs no identity to look.
|
|
72
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
73
|
-
buildTrustedSourceStore(db, 'other').
|
|
74
|
-
|
|
108
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
109
|
+
buildTrustedSourceStore(db, 'other').replace('10.0.0.1', {
|
|
110
|
+
subnets: ['172.16.9.0/24'],
|
|
75
111
|
description: 'elsewhere',
|
|
76
112
|
});
|
|
77
113
|
|
|
@@ -80,7 +116,7 @@ describe('trusted-source store', () => {
|
|
|
80
116
|
});
|
|
81
117
|
|
|
82
118
|
it('reports every registration across firewalls, with who registered it', () => {
|
|
83
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
119
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, { subnets: [VPN], description: 'vpn' });
|
|
84
120
|
|
|
85
121
|
expect(listAllTrustedSources(db)).toEqual([
|
|
86
122
|
{ firewallIp: FW, subnet: VPN, description: 'vpn', registeredBy: 'wireguard' },
|
|
@@ -104,7 +140,10 @@ describe('the render input excludes registrations; the reporting view includes t
|
|
|
104
140
|
process.env.CELILO_DB_PATH = dbPath;
|
|
105
141
|
db = await setupTestDatabase(dbPath);
|
|
106
142
|
db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
|
|
107
|
-
buildTrustedSourceStore(db, 'wireguard').
|
|
143
|
+
buildTrustedSourceStore(db, 'wireguard').replace(FW, {
|
|
144
|
+
subnets: [VPN],
|
|
145
|
+
description: 'admin VPN',
|
|
146
|
+
});
|
|
108
147
|
});
|
|
109
148
|
afterEach(() => {
|
|
110
149
|
db.$client.close();
|
|
@@ -38,31 +38,41 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
|
|
|
38
38
|
}));
|
|
39
39
|
},
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
//
|
|
41
|
+
replace(firewallIp: string, source: RegisterTrustedSourceRequest): void {
|
|
42
|
+
// The consumer's COMPLETE set for this firewall (D5b), scoped to its own
|
|
43
|
+
// rows: a subnet it trusted before and omits now loses its reach, and a
|
|
44
|
+
// subnet another module also trusts keeps it. Changing an admin VPN's
|
|
45
|
+
// client subnet used to leave the old one reaching every zone forever.
|
|
43
46
|
db.delete(trustedSources)
|
|
44
47
|
.where(
|
|
45
|
-
and(
|
|
48
|
+
and(
|
|
49
|
+
eq(trustedSources.firewallIp, firewallIp),
|
|
50
|
+
eq(trustedSources.registeredBy, registeredBy),
|
|
51
|
+
),
|
|
46
52
|
)
|
|
47
53
|
.run();
|
|
48
|
-
db.insert(trustedSources)
|
|
49
|
-
.values({
|
|
50
|
-
firewallIp,
|
|
51
|
-
subnet: source.subnet,
|
|
52
|
-
description: source.description,
|
|
53
|
-
registeredBy,
|
|
54
|
-
})
|
|
55
|
-
.run();
|
|
56
|
-
},
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
if (source.subnets.length === 0) return;
|
|
56
|
+
|
|
57
|
+
db.insert(trustedSources)
|
|
58
|
+
.values(
|
|
59
|
+
source.subnets.map((subnet) => ({
|
|
60
|
+
firewallIp,
|
|
61
|
+
subnet,
|
|
62
|
+
description: source.description,
|
|
63
|
+
registeredBy,
|
|
64
|
+
})),
|
|
65
|
+
)
|
|
61
66
|
.run();
|
|
62
67
|
},
|
|
63
68
|
};
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/** Drop every trusted source a departing module owns, across every firewall. */
|
|
72
|
+
export function deleteTrustedSourcesForModule(db: DbClient, moduleId: string): void {
|
|
73
|
+
db.delete(trustedSources).where(eq(trustedSources.registeredBy, moduleId)).run();
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
/** Where a trusted subnet came from — reach into every tier must be attributable. */
|
|
67
77
|
export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
|
|
68
78
|
|