@takodotid/circuit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -174
- package/package.json +2 -1
- package/src/cli/index.ts +73 -12
- package/src/cli/new.ts +153 -0
- package/src/core/define.ts +2 -4
- package/src/core/merge.ts +19 -0
- package/src/core/secrets.ts +62 -15
- package/src/index.ts +1 -0
- package/src/presets/index.ts +208 -3
- package/src/schema/common.ts +3 -3
- package/templates/colocation/checks.ts +38 -0
- package/templates/colocation/circuit.config.ts +9 -0
- package/templates/colocation/edge-01.ts +131 -0
- package/templates/colocation/routing.ts +13 -0
- package/templates/colocation/tenancy.ts +97 -0
- package/templates/colocation/tenants/acme.ts +19 -0
- package/templates/colocation/tenants/index.ts +5 -0
- package/templates/colocation/tor-01.ts +33 -0
- package/templates/edge-router/checks.ts +20 -0
- package/templates/edge-router/circuit.config.ts +8 -0
- package/templates/edge-router/edge-01.ts +108 -0
- package/templates/edge-router/routing.ts +13 -0
- package/templates/project/AGENTS.md +44 -0
- package/templates/project/CLAUDE.md +1 -0
- package/templates/project/README.md +15 -0
- package/templates/project/gitignore +5 -0
- package/templates/project/tsconfig.json +13 -0
- package/templates/single-site/checks.ts +26 -0
- package/templates/single-site/circuit.config.ts +8 -0
- package/templates/single-site/router.ts +89 -0
- package/templates/single-site/site.ts +18 -0
- package/templates/single-site/switch.ts +34 -0
package/src/core/secrets.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
// Secrets are references in the config and values only at the moment a command is sent. A reference names a variable in the environment or `.env
|
|
1
|
+
// Secrets are references in the config and values only at the moment a command is sent. A reference names a variable, set in the environment or in `.env` beside the config, or a file kept out of the repository. A variable's value may itself be a 1Password reference, `op://vault/item/field`, read with the 1Password CLI.
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { Secret } from "../schema";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** The directory of the config file. `.env` and secret files are found from here. */
|
|
8
|
+
let root = process.cwd();
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (localFile) return localFile;
|
|
10
|
+
let envFile: Record<string, string> | undefined;
|
|
11
|
+
const fromOnePassword = new Map<string, string>();
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/** Find `.env` and secret files from this directory, the config file's, instead of the working directory. */
|
|
14
|
+
export function useRoot(directory: string): void {
|
|
15
|
+
root = directory;
|
|
16
|
+
envFile = undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `.env` beside the config, read once. */
|
|
20
|
+
function dotenv(): Record<string, string> {
|
|
21
|
+
if (envFile) return envFile;
|
|
22
|
+
|
|
23
|
+
envFile = {};
|
|
24
|
+
const path = join(root, ".env");
|
|
25
|
+
if (!existsSync(path)) return envFile;
|
|
16
26
|
|
|
17
27
|
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
18
28
|
const trimmed = line.trim();
|
|
@@ -21,23 +31,60 @@ function local(): Record<string, string> {
|
|
|
21
31
|
const separator = trimmed.indexOf("=");
|
|
22
32
|
if (separator <= 0) continue;
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
35
|
+
const unquoted = /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value;
|
|
36
|
+
envFile[trimmed.slice(0, separator).trim()] = unquoted;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
|
-
return
|
|
39
|
+
return envFile;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read a 1Password reference with the 1Password CLI, `op`, once per run. */
|
|
43
|
+
function readOnePassword(name: string, reference: string): string {
|
|
44
|
+
const cached = fromOnePassword.get(reference);
|
|
45
|
+
if (cached !== undefined) return cached;
|
|
46
|
+
|
|
47
|
+
let result: ReturnType<typeof Bun.spawnSync>;
|
|
48
|
+
try {
|
|
49
|
+
result = Bun.spawnSync(["op", "read", "--no-newline", reference], { stdout: "pipe", stderr: "pipe" });
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`secret ${name} is a 1Password reference, and the 1Password CLI, op, is not installed`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (result.exitCode !== 0) {
|
|
55
|
+
const reason = result.stderr?.toString().trim() || `op exited with ${result.exitCode}`;
|
|
56
|
+
throw new Error(`secret ${name}: 1Password could not read ${reference}: ${reason}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const value = result.stdout?.toString() ?? "";
|
|
60
|
+
fromOnePassword.set(reference, value);
|
|
61
|
+
return value;
|
|
28
62
|
}
|
|
29
63
|
|
|
30
64
|
export const isSecret = (value: unknown): value is Secret =>
|
|
31
65
|
typeof value === "object" && value !== null && ("secret" in value || "secret_file" in value);
|
|
32
66
|
|
|
33
67
|
/** How a reference is written inside a marker: a variable's name, or `file:` and a path. */
|
|
34
|
-
const referenceOf = (secret: Secret) => ("secret" in secret ? secret.secret : `file:${secret.secret_file}`);
|
|
68
|
+
export const referenceOf = (secret: Secret) => ("secret" in secret ? secret.secret : `file:${secret.secret_file}`);
|
|
69
|
+
|
|
70
|
+
/** Where a reference's value comes from, such as `.env` or `1Password, through .env`, without reading it. Undefined when it is set nowhere. */
|
|
71
|
+
export function sourceOf(reference: string): string | undefined {
|
|
72
|
+
if (reference.startsWith("file:")) return existsSync(join(root, reference.slice("file:".length))) ? "a file" : undefined;
|
|
73
|
+
|
|
74
|
+
const value = process.env[reference] || dotenv()[reference];
|
|
75
|
+
if (!value) return undefined;
|
|
76
|
+
|
|
77
|
+
// Bun loads `.env` into the environment when it starts in the same directory, so the same value in both came from `.env`.
|
|
78
|
+
const where = process.env[reference] && process.env[reference] !== dotenv()[reference] ? "the environment" : ".env";
|
|
79
|
+
|
|
80
|
+
return value.startsWith("op://") ? `1Password, through ${where}` : where;
|
|
81
|
+
}
|
|
35
82
|
|
|
36
83
|
/** The value behind a reference, as `referenceOf` writes it. */
|
|
37
84
|
function valueOf(reference: string): string {
|
|
38
85
|
if (reference.startsWith("file:")) {
|
|
39
86
|
const path = reference.slice("file:".length);
|
|
40
|
-
const fullPath = join(
|
|
87
|
+
const fullPath = join(root, path);
|
|
41
88
|
if (!existsSync(fullPath)) throw new Error(`secret file ${path} does not exist`);
|
|
42
89
|
|
|
43
90
|
const value = readFileSync(fullPath, "utf8").trimEnd();
|
|
@@ -46,10 +93,10 @@ function valueOf(reference: string): string {
|
|
|
46
93
|
return value;
|
|
47
94
|
}
|
|
48
95
|
|
|
49
|
-
const value = process.env[reference]
|
|
50
|
-
if (!value) throw new Error(`secret ${reference} is not set in the environment or .env
|
|
96
|
+
const value = process.env[reference] || dotenv()[reference];
|
|
97
|
+
if (!value) throw new Error(`secret ${reference} is not set in the environment or in .env`);
|
|
51
98
|
|
|
52
|
-
return value;
|
|
99
|
+
return value.startsWith("op://") ? readOnePassword(reference, value) : value;
|
|
53
100
|
}
|
|
54
101
|
|
|
55
102
|
/** The value of a secret, or of a variable named directly. */
|
package/src/index.ts
CHANGED
package/src/presets/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, and checks. Each returns plain config to place where it belongs, so nothing here applies itself.
|
|
1
|
+
// Building blocks most networks write the same way: address lists, edge filters, a BGP import sanity policy, checks, and helpers for checks of your own. Each returns plain config to place where it belongs, so nothing here applies itself.
|
|
2
2
|
|
|
3
3
|
import { contains } from "../core/addr";
|
|
4
|
-
import type { Check, Finding } from "../core/define";
|
|
4
|
+
import type { Check, Community, Finding } from "../core/define";
|
|
5
5
|
import type { AclRule, Device, FilterRule, PolicyRule, Prefix, TcpFlag } from "../schema";
|
|
6
6
|
|
|
7
7
|
/** IPv4 sources that never arrive from the internet: loopback, link-local and "this network", after RFC 6890. */
|
|
@@ -67,7 +67,7 @@ export function bgpSanity(lengths: SanityLengths = {}): PolicyRule<never, never>
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** Each BGP neighbor's own policies, or its group's. */
|
|
70
|
-
function neighborPolicies(device: Device) {
|
|
70
|
+
export function neighborPolicies(device: Device) {
|
|
71
71
|
const bgp = device.routing?.bgp;
|
|
72
72
|
|
|
73
73
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
@@ -103,6 +103,81 @@ export const exportsEndInReject: Check = (devices) => {
|
|
|
103
103
|
return findings;
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
/** The VLANs a port carries, untagged or tagged. */
|
|
107
|
+
export const vlansOf = (port: { access_vlan?: string; trunk_vlans?: readonly string[] }): string[] =>
|
|
108
|
+
[port.access_vlan, ...(port.trunk_vlans ?? [])].filter((vlan): vlan is string => vlan !== undefined);
|
|
109
|
+
|
|
110
|
+
/** Where traffic from outside, before anything filters it, may go. Every VLAN not listed as untrusted is trusted, so a VLAN added later is protected without being listed. */
|
|
111
|
+
export type TrustBoundary = {
|
|
112
|
+
/** VLANs carrying traffic straight from outside: transits, exchanges, an ISP handoff. */
|
|
113
|
+
untrusted: readonly string[];
|
|
114
|
+
/** The devices that filter: the only ones that may give an untrusted VLAN an address, and the only ones a port carrying both kinds may face. */
|
|
115
|
+
routers: readonly string[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A check: traffic from outside reaches your own VLANs only through a router. A port or LAG carrying an untrusted VLAN beside any other is on a router or faces one, and only a router gives an untrusted VLAN an address. */
|
|
119
|
+
export const trustBoundary =
|
|
120
|
+
(boundary: TrustBoundary): Check =>
|
|
121
|
+
(devices) => {
|
|
122
|
+
const untrusted = new Set(boundary.untrusted);
|
|
123
|
+
const routers = new Set(boundary.routers);
|
|
124
|
+
const findings: Finding[] = [];
|
|
125
|
+
|
|
126
|
+
for (const device of devices) {
|
|
127
|
+
const isRouter = routers.has(device.name);
|
|
128
|
+
const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => (port ? [{ name, port }] : []));
|
|
129
|
+
|
|
130
|
+
// What carries VLANs: a port on its own, or a LAG, which faces whatever its members link to.
|
|
131
|
+
const carriers = [
|
|
132
|
+
...ports
|
|
133
|
+
.filter(({ port }) => !port.lag)
|
|
134
|
+
.map(({ name, port }) => ({
|
|
135
|
+
what: `port ${name}`,
|
|
136
|
+
carried: vlansOf(port),
|
|
137
|
+
faces: port.link ? [port.link.device] : [],
|
|
138
|
+
})),
|
|
139
|
+
...Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
140
|
+
iface.type === "lag"
|
|
141
|
+
? [
|
|
142
|
+
{
|
|
143
|
+
what: `LAG ${name}`,
|
|
144
|
+
carried: vlansOf(iface),
|
|
145
|
+
faces: ports.filter(({ port }) => port.lag === name && port.link).map(({ port }) => port.link!.device),
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
: []
|
|
149
|
+
),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
for (const { what, carried, faces } of carriers) {
|
|
153
|
+
const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => !untrusted.has(vlan));
|
|
154
|
+
const facesRouter = isRouter || faces.some((name) => routers.has(name));
|
|
155
|
+
|
|
156
|
+
if (mixes && !facesRouter) {
|
|
157
|
+
findings.push({
|
|
158
|
+
level: "error",
|
|
159
|
+
device: device.name,
|
|
160
|
+
message: `${what} carries an untrusted VLAN beside a trusted one and does not face a router`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isRouter) continue;
|
|
166
|
+
|
|
167
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
168
|
+
if (iface.type === "vlan" && untrusted.has(iface.vlan)) {
|
|
169
|
+
findings.push({
|
|
170
|
+
level: "error",
|
|
171
|
+
device: device.name,
|
|
172
|
+
message: `interface ${name} puts an address on untrusted VLAN ${iface.vlan}; only a router may`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return findings;
|
|
179
|
+
};
|
|
180
|
+
|
|
106
181
|
/** A GRE or VXLAN tunnel's local address is outside what the neighbors over it are offered, or their replies route back into the tunnel. */
|
|
107
182
|
export const tunnelsOutsideOffered: Check = (devices) => {
|
|
108
183
|
const findings: Finding[] = [];
|
|
@@ -131,3 +206,133 @@ export const tunnelsOutsideOffered: Check = (devices) => {
|
|
|
131
206
|
|
|
132
207
|
return findings;
|
|
133
208
|
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A BGP community scheme of large communities, `asn:function:parameter`, in the style of RFC 8195. Each field names the function number your network uses for it; leave out what your network does not offer.
|
|
212
|
+
*/
|
|
213
|
+
export type CommunityScheme<Class extends string = string> = {
|
|
214
|
+
/** Your AS. It is the first part of every community. */
|
|
215
|
+
asn: number;
|
|
216
|
+
/** Set on import: the kind of neighbor a route came from, `asn:function:class`. */
|
|
217
|
+
learned_from?: { function: number; classes: Readonly<Record<Class, number>> };
|
|
218
|
+
/** Set on import: the site a route was learned at, `asn:function:site`. `sites` names the sites, for the catalogue. */
|
|
219
|
+
learned_at?: { function: number; sites?: Readonly<Record<number, string>> };
|
|
220
|
+
/** Set on import: the AS a route was learned from, `asn:function:asn`. */
|
|
221
|
+
learned_from_as?: number;
|
|
222
|
+
/** Set by a customer: do not announce the route to an AS, `asn:function:asn`, or to anyone, `asn:function:0`. */
|
|
223
|
+
do_not_announce?: number;
|
|
224
|
+
/** Set by a customer: prepend toward an AS once, twice or three times, `asn:function:asn`. */
|
|
225
|
+
prepend?: { once?: number; twice?: number; three_times?: number };
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const PREPENDS = [
|
|
229
|
+
["once", 1],
|
|
230
|
+
["twice", 2],
|
|
231
|
+
["three_times", 3],
|
|
232
|
+
] as const;
|
|
233
|
+
|
|
234
|
+
const article = (word: string) => (/^[aeiou]/i.test(word) ? `an ${word}` : `a ${word}`);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Policy rules and a catalogue from one community scheme, so the communities your routers set, the ones they act on, and the ones you publish never disagree.
|
|
238
|
+
*
|
|
239
|
+
* - `tag(neighborClass, neighborAs, { site, keepActions })`: a rule for an import policy. It removes your communities a neighbor set, then adds where the route was learned. `site` is needed when the scheme has `learned_at`. With `keepActions`, for a customer, its action communities stay.
|
|
240
|
+
* - `actions(neighborAs)`: rules for an export policy toward one neighbor. They act on a customer's action communities, then remove all of yours. Place them before the rules that accept.
|
|
241
|
+
* - `catalogue`: every community, for `communities` in `defineNetwork`.
|
|
242
|
+
*/
|
|
243
|
+
export function communityScheme<const Class extends string>(scheme: CommunityScheme<Class>) {
|
|
244
|
+
const { asn } = scheme;
|
|
245
|
+
const community = (fn: number, parameter: number | string) => `${asn}:${fn}:${parameter}`;
|
|
246
|
+
const ours = { standard: [`${asn}:*`], large: [`${asn}:*:*`] };
|
|
247
|
+
|
|
248
|
+
const information = [scheme.learned_from?.function, scheme.learned_at?.function, scheme.learned_from_as].filter(
|
|
249
|
+
(fn): fn is number => fn !== undefined
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const tag = (
|
|
253
|
+
neighborClass: Class,
|
|
254
|
+
neighborAs: number,
|
|
255
|
+
options: { site?: number; keepActions?: boolean } = {}
|
|
256
|
+
): PolicyRule<never, never> => {
|
|
257
|
+
const { site, keepActions = false } = options;
|
|
258
|
+
if (scheme.learned_at && site === undefined)
|
|
259
|
+
throw new Error(`communityScheme: tag for AS${neighborAs} needs a site, for learned_at`);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
description: `Learned from AS${neighborAs}`,
|
|
263
|
+
set: {
|
|
264
|
+
remove_communities: ours.standard,
|
|
265
|
+
remove_large_communities: keepActions ? information.map((fn) => community(fn, "*")) : ours.large,
|
|
266
|
+
add_large_communities: [
|
|
267
|
+
...(scheme.learned_from ? [community(scheme.learned_from.function, scheme.learned_from.classes[neighborClass])] : []),
|
|
268
|
+
...(scheme.learned_at ? [community(scheme.learned_at.function, site!)] : []),
|
|
269
|
+
...(scheme.learned_from_as !== undefined ? [community(scheme.learned_from_as, neighborAs)] : []),
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const actions = (neighborAs: number): PolicyRule<never, never>[] => [
|
|
276
|
+
...(scheme.do_not_announce !== undefined
|
|
277
|
+
? [
|
|
278
|
+
{
|
|
279
|
+
description: "Asked not to be announced anywhere",
|
|
280
|
+
match: { large_community: community(scheme.do_not_announce, 0) },
|
|
281
|
+
action: "reject" as const,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
description: `Asked not to be announced to AS${neighborAs}`,
|
|
285
|
+
match: { large_community: community(scheme.do_not_announce, neighborAs) },
|
|
286
|
+
action: "reject" as const,
|
|
287
|
+
},
|
|
288
|
+
]
|
|
289
|
+
: []),
|
|
290
|
+
...PREPENDS.flatMap(([name, times]) => {
|
|
291
|
+
const fn = scheme.prepend?.[name];
|
|
292
|
+
if (fn === undefined) return [];
|
|
293
|
+
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
description: `Asked to prepend ${name.replace("_", " ")} to AS${neighborAs}`,
|
|
297
|
+
match: { large_community: community(fn, neighborAs) },
|
|
298
|
+
set: { prepend: times },
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
}),
|
|
302
|
+
{
|
|
303
|
+
description: "Our communities stay inside our network",
|
|
304
|
+
set: { remove_communities: ours.standard, remove_large_communities: ours.large },
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const catalogue: Community[] = [
|
|
309
|
+
...Object.entries<number>(scheme.learned_from?.classes ?? {}).map(([name, value]) => ({
|
|
310
|
+
community: community(scheme.learned_from!.function, value),
|
|
311
|
+
description: `Learned from ${article(name)}`,
|
|
312
|
+
})),
|
|
313
|
+
...(scheme.learned_at
|
|
314
|
+
? [
|
|
315
|
+
...Object.entries(scheme.learned_at.sites ?? {}).map(([site, name]) => ({
|
|
316
|
+
community: community(scheme.learned_at!.function, site),
|
|
317
|
+
description: `Learned in ${name}`,
|
|
318
|
+
})),
|
|
319
|
+
{ community: community(scheme.learned_at.function, "nnn"), description: "Learned at site $0" },
|
|
320
|
+
]
|
|
321
|
+
: []),
|
|
322
|
+
...(scheme.learned_from_as !== undefined
|
|
323
|
+
? [{ community: community(scheme.learned_from_as, "nnn"), description: "Learned from AS$0" }]
|
|
324
|
+
: []),
|
|
325
|
+
...(scheme.do_not_announce !== undefined
|
|
326
|
+
? [
|
|
327
|
+
{ community: community(scheme.do_not_announce, 0), description: "Do not announce to anyone" },
|
|
328
|
+
{ community: community(scheme.do_not_announce, "nnn"), description: "Do not announce to AS$0" },
|
|
329
|
+
]
|
|
330
|
+
: []),
|
|
331
|
+
...PREPENDS.flatMap(([name]) => {
|
|
332
|
+
const fn = scheme.prepend?.[name];
|
|
333
|
+
return fn === undefined ? [] : [{ community: community(fn, "nnn"), description: `Prepend ${name.replace("_", " ")} to AS$0` }];
|
|
334
|
+
}),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
return { community, tag, actions, catalogue };
|
|
338
|
+
}
|
package/src/schema/common.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A value kept out of the repository and resolved only when a device is applied.
|
|
3
3
|
*
|
|
4
|
-
* `secret("NAME")` reads a variable from the environment or `.env.
|
|
4
|
+
* `secret("NAME")` reads a variable from the environment or `.env` beside the config; a value written as `op://vault/item/field` is read from 1Password. `secretFile("path")` reads a file, relative to the config file, for a value that spans lines such as a private key in PEM. Keep that file out of git.
|
|
5
5
|
*/
|
|
6
6
|
export type Secret =
|
|
7
7
|
| {
|
|
8
|
-
/** Name of the value in the environment or in `.env
|
|
8
|
+
/** Name of the value in the environment or in `.env`. */
|
|
9
9
|
readonly secret: string;
|
|
10
10
|
}
|
|
11
11
|
| {
|
|
12
|
-
/** Path of a file holding the value, relative to the
|
|
12
|
+
/** Path of a file holding the value, relative to the config file. */
|
|
13
13
|
readonly secret_file: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Rules of your own. `circuit validate` runs them beside Circuit's own, and an error stops `diff` and `apply`.
|
|
2
|
+
|
|
3
|
+
import type { Check, Finding } from "@takodotid/circuit";
|
|
4
|
+
import { contains } from "@takodotid/circuit";
|
|
5
|
+
import { exportsEndInReject, trustBoundary } from "@takodotid/circuit/presets";
|
|
6
|
+
import { TENANTS } from "./tenants";
|
|
7
|
+
|
|
8
|
+
export default [
|
|
9
|
+
// Nothing leaves by accident: every export ends by rejecting what it did not accept.
|
|
10
|
+
exportsEndInReject,
|
|
11
|
+
|
|
12
|
+
// Traffic from the transit and the exchange reaches the tenants only through the router, which filters it.
|
|
13
|
+
trustBoundary({ untrusted: ["transit", "ix"], routers: ["edge-01"] }),
|
|
14
|
+
|
|
15
|
+
// Tenants never share a name, a number or an address.
|
|
16
|
+
() => {
|
|
17
|
+
const findings: Finding[] = [];
|
|
18
|
+
const error = (message: string) => findings.push({ level: "error", message });
|
|
19
|
+
|
|
20
|
+
TENANTS.forEach((t, index) => {
|
|
21
|
+
for (const other of TENANTS.slice(index + 1)) {
|
|
22
|
+
if (other.name === t.name) error(`tenant ${t.name} is declared twice`);
|
|
23
|
+
if (other.number === t.number) error(`tenants ${t.name} and ${other.name} share number ${t.number}`);
|
|
24
|
+
|
|
25
|
+
for (const block of t.blocks) {
|
|
26
|
+
const clash = other.blocks.find(({ prefix }) => contains(prefix, block.prefix) || contains(block.prefix, prefix));
|
|
27
|
+
if (clash) error(`${block.prefix} of ${t.name} overlaps ${clash.prefix} of ${other.name}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const { prefix, gateway } of t.blocks) {
|
|
32
|
+
if (!contains(prefix, gateway)) error(`the gateway ${gateway} of ${t.name} is outside ${prefix}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return findings;
|
|
37
|
+
},
|
|
38
|
+
] satisfies Check[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The network: every device Circuit manages, your own checks, and what is published about the network.
|
|
2
|
+
|
|
3
|
+
import { defineNetwork } from "@takodotid/circuit";
|
|
4
|
+
import checks from "./checks";
|
|
5
|
+
import edge from "./edge-01";
|
|
6
|
+
import { ASN, communities } from "./routing";
|
|
7
|
+
import tor from "./tor-01";
|
|
8
|
+
|
|
9
|
+
export default defineNetwork({ devices: [edge, tor], checks, asn: ASN, communities: communities.catalogue });
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The edge router: BGP with an IP transit and an internet exchange, the gateway of every tenant, and the filter between them.
|
|
2
|
+
|
|
3
|
+
import type { Prefix } from "@takodotid/circuit";
|
|
4
|
+
import { defineDevice, merge, secret } from "@takodotid/circuit";
|
|
5
|
+
import { antiSpoofing, badTcpFlags, bgpSanity, MARTIANS, PRIVATE_RANGES } from "@takodotid/circuit/presets";
|
|
6
|
+
import { ASN, communities } from "./routing";
|
|
7
|
+
import { edgeRules, gateways, internetVlanNames, internetVlans } from "./tenancy";
|
|
8
|
+
import { TENANTS } from "./tenants";
|
|
9
|
+
|
|
10
|
+
/** The address space this router announces. */
|
|
11
|
+
const OURS = ["198.51.100.0/24", "2001:db8:100::/48"] as const satisfies readonly Prefix[];
|
|
12
|
+
|
|
13
|
+
/** Where the router is managed from. */
|
|
14
|
+
const MANAGEMENT = "10.0.0.0/24" satisfies Prefix;
|
|
15
|
+
|
|
16
|
+
/** Sources that never arrive from the internet: our own space, and addresses that are not on the internet at all. */
|
|
17
|
+
const SPOOFED = [OURS[0], ...MARTIANS, ...PRIVATE_RANGES];
|
|
18
|
+
|
|
19
|
+
export default defineDevice({
|
|
20
|
+
name: "edge-01",
|
|
21
|
+
platform: "routeros",
|
|
22
|
+
model: "CCR2216-1G-12XS-2XQ",
|
|
23
|
+
connection: { host: "10.0.0.1", user: "operator" },
|
|
24
|
+
users: { operator: { role: "admin", password: secret("EDGE_01_PASSWORD") } },
|
|
25
|
+
|
|
26
|
+
system: { timezone: "Asia/Jakarta", ntp: { servers: ["pool.ntp.org"] } },
|
|
27
|
+
management: { allow: [MANAGEMENT], ssh: {} },
|
|
28
|
+
|
|
29
|
+
vlans: merge(
|
|
30
|
+
{
|
|
31
|
+
mgmt: { id: 99, description: "Management of the switches" },
|
|
32
|
+
transit: { id: 300, description: "IP transit from Example Transit, AS64501" },
|
|
33
|
+
ix: { id: 200, description: "Peering LAN of Example IX" },
|
|
34
|
+
},
|
|
35
|
+
internetVlans(TENANTS)
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
ports: {
|
|
39
|
+
"1g-1": { description: "Management", addresses: ["10.0.0.1/24"] },
|
|
40
|
+
"25g-1": { description: "Example Transit", access_vlan: "transit", acl: "edge" },
|
|
41
|
+
"25g-2": { description: "Example IX", access_vlan: "ix", acl: "edge" },
|
|
42
|
+
"100g-1": {
|
|
43
|
+
description: "tor-01",
|
|
44
|
+
speed: "40g",
|
|
45
|
+
trunk_vlans: ["mgmt", ...internetVlanNames(TENANTS)],
|
|
46
|
+
link: { device: "tor-01", port: "40g-1" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
interfaces: merge(
|
|
51
|
+
{
|
|
52
|
+
mgmt: { type: "vlan", vlan: "mgmt", addresses: ["10.0.99.1/24"] },
|
|
53
|
+
transit: { type: "vlan", vlan: "transit", addresses: ["203.0.113.2/30"] },
|
|
54
|
+
ix: { type: "vlan", vlan: "ix", addresses: ["192.0.2.10/24", "2001:db8:ffff::10/64"] },
|
|
55
|
+
},
|
|
56
|
+
gateways(TENANTS)
|
|
57
|
+
),
|
|
58
|
+
|
|
59
|
+
routing: {
|
|
60
|
+
router_id: "203.0.113.2",
|
|
61
|
+
// A route for each block we announce, so it is in the table even when nothing inside it is up.
|
|
62
|
+
static: OURS.map((prefix) => ({ prefix, blackhole: true, description: "Originate" })),
|
|
63
|
+
bgp: {
|
|
64
|
+
asn: ASN,
|
|
65
|
+
networks: [...OURS],
|
|
66
|
+
neighbors: {
|
|
67
|
+
"example-transit": {
|
|
68
|
+
address: "203.0.113.1",
|
|
69
|
+
remote_as: 64501,
|
|
70
|
+
local_role: "customer",
|
|
71
|
+
import: "TRANSIT-IN",
|
|
72
|
+
export: "OUT",
|
|
73
|
+
max_prefixes: 1_200_000,
|
|
74
|
+
},
|
|
75
|
+
"example-ix-rs-v4": {
|
|
76
|
+
address: "192.0.2.1",
|
|
77
|
+
remote_as: 64502,
|
|
78
|
+
local_role: "rs-client",
|
|
79
|
+
import: "IX-IN",
|
|
80
|
+
export: "OUT",
|
|
81
|
+
max_prefixes: 100_000,
|
|
82
|
+
},
|
|
83
|
+
"example-ix-rs-v6": {
|
|
84
|
+
address: "2001:db8:ffff::1",
|
|
85
|
+
remote_as: 64502,
|
|
86
|
+
local_role: "rs-client",
|
|
87
|
+
import: "IX-IN",
|
|
88
|
+
export: "OUT",
|
|
89
|
+
max_prefixes: 50_000,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
policies: {
|
|
96
|
+
SANITY: bgpSanity(),
|
|
97
|
+
// A route from the exchange is preferred over the same route from the transit: higher local preference wins.
|
|
98
|
+
"TRANSIT-IN": [{ call: "SANITY" }, communities.tag("transit", 64501), { set: { local_pref: 100 } }, { action: "accept" }],
|
|
99
|
+
"IX-IN": [{ call: "SANITY" }, communities.tag("exchange", 64502), { set: { local_pref: 200 } }, { action: "accept" }],
|
|
100
|
+
OUT: [
|
|
101
|
+
...OURS.map((prefix) => ({ description: `Announce ${prefix}`, match: { prefix }, action: "accept" as const })),
|
|
102
|
+
{ description: "Nothing else leaves", action: "reject" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
firewall: {
|
|
107
|
+
address_sets: { "bgp-neighbors": ["203.0.113.1", "192.0.2.1", "2001:db8:ffff::1"] },
|
|
108
|
+
filter: {
|
|
109
|
+
input: {
|
|
110
|
+
default: "drop",
|
|
111
|
+
rules: [
|
|
112
|
+
{ description: "Replies", match: { state: ["established", "related"] }, action: "accept" },
|
|
113
|
+
{ description: "Ping", match: { protocol: "icmp" }, action: "accept" },
|
|
114
|
+
{ description: "Management", match: { src: MANAGEMENT }, action: "accept" },
|
|
115
|
+
{ description: "BGP", match: { protocol: "tcp", dst_port: 179, src_set: "bgp-neighbors" }, action: "accept" },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
forward: { rules: [...badTcpFlags()] },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// In the switch chip, before a packet reaches the router's CPU: spoofed sources first, then each tenant's own rules.
|
|
123
|
+
acls: {
|
|
124
|
+
edge: [
|
|
125
|
+
...antiSpoofing("transit", SPOOFED),
|
|
126
|
+
...antiSpoofing("ix", SPOOFED),
|
|
127
|
+
...edgeRules(TENANTS, "transit"),
|
|
128
|
+
...edgeRules(TENANTS, "ix"),
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// What your network is on the internet: its AS number and its BGP communities.
|
|
2
|
+
|
|
3
|
+
import { communityScheme } from "@takodotid/circuit/presets";
|
|
4
|
+
|
|
5
|
+
/** Your AS number. */
|
|
6
|
+
export const ASN = 64500;
|
|
7
|
+
|
|
8
|
+
/** What your BGP communities mean. The router tags each route it learns with them, and `circuit communities` publishes them. */
|
|
9
|
+
export const communities = communityScheme({
|
|
10
|
+
asn: ASN,
|
|
11
|
+
learned_from: { function: 1, classes: { transit: 1, exchange: 2 } },
|
|
12
|
+
learned_from_as: 3,
|
|
13
|
+
});
|