@takodotid/circuit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.local`, or a file kept out of the repository.
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
- let localFile: Record<string, string> | undefined;
7
+ /** The directory of the config file. `.env` and secret files are found from here. */
8
+ let root = process.cwd();
8
9
 
9
- /** `.env.local` in the working directory, read once. */
10
- function local(): Record<string, string> {
11
- if (localFile) return localFile;
10
+ let envFile: Record<string, string> | undefined;
11
+ const fromOnePassword = new Map<string, string>();
12
12
 
13
- localFile = {};
14
- const path = join(process.cwd(), ".env.local");
15
- if (!existsSync(path)) return localFile;
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
- localFile[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
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 localFile;
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(process.cwd(), path);
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] ?? local()[reference];
50
- if (!value) throw new Error(`secret ${reference} is not set in the environment or .env.local`);
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. */
@@ -1,7 +1,7 @@
1
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. */
@@ -107,22 +107,19 @@ export const exportsEndInReject: Check = (devices) => {
107
107
  export const vlansOf = (port: { access_vlan?: string; trunk_vlans?: readonly string[] }): string[] =>
108
108
  [port.access_vlan, ...(port.trunk_vlans ?? [])].filter((vlan): vlan is string => vlan !== undefined);
109
109
 
110
- /** Where traffic from outside, before anything filters it, may go. */
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
111
  export type TrustBoundary = {
112
112
  /** VLANs carrying traffic straight from outside: transits, exchanges, an ISP handoff. */
113
113
  untrusted: readonly string[];
114
- /** VLANs that must not meet them on a port: customers, servers, management. */
115
- trusted: readonly string[];
116
- /** The devices that filter: the only ones that may give an untrusted VLAN an address, and the only ones a port carrying both may face. */
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. */
117
115
  routers: readonly string[];
118
116
  };
119
117
 
120
- /** A check: untrusted traffic reaches trusted VLANs only through a router. A port carrying both faces a router or is on one, and only a router gives an untrusted VLAN an address. */
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. */
121
119
  export const trustBoundary =
122
120
  (boundary: TrustBoundary): Check =>
123
121
  (devices) => {
124
122
  const untrusted = new Set(boundary.untrusted);
125
- const trusted = new Set(boundary.trusted);
126
123
  const routers = new Set(boundary.routers);
127
124
  const findings: Finding[] = [];
128
125
 
@@ -153,7 +150,7 @@ export const trustBoundary =
153
150
  ];
154
151
 
155
152
  for (const { what, carried, faces } of carriers) {
156
- const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => trusted.has(vlan));
153
+ const mixes = carried.some((vlan) => untrusted.has(vlan)) && carried.some((vlan) => !untrusted.has(vlan));
157
154
  const facesRouter = isRouter || faces.some((name) => routers.has(name));
158
155
 
159
156
  if (mixes && !facesRouter) {
@@ -209,3 +206,133 @@ export const tunnelsOutsideOffered: Check = (devices) => {
209
206
 
210
207
  return findings;
211
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
+ }
@@ -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.local`. `secretFile("path")` reads a file, relative to the project root, for a value that spans lines such as a private key in PEM. Keep that file out of git.
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.local`. */
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 project root. */
12
+ /** Path of a file holding the value, relative to the config file. */
13
13
  readonly secret_file: string;
14
14
  };
15
15
 
@@ -18,7 +18,9 @@ export type StaticRoute<I extends string, F extends string> = {
18
18
  description?: string;
19
19
  };
20
20
 
21
- /** Our side of the relationship, as RFC 9234 defines it. */
21
+ /**
22
+ * What a BGP neighbor is to us, with the roles RFC 9234 defines: `provider`, a transit we buy from; `customer`, one we sell transit to; `peer`, one we exchange our own routes with; `rs`, an exchange's route server; `rs-client`, a network using our route server.
23
+ */
22
24
  export type BgpRole = "provider" | "customer" | "peer" | "rs" | "rs-client";
23
25
 
24
26
  /** Settings a BGP neighbor can state itself or take from its group. */
@@ -33,8 +35,8 @@ export type NeighborSettings<P extends string> = {
33
35
  import?: P;
34
36
  /** Policy for routes announced. Nothing is announced when absent. */
35
37
  export?: P;
36
- /** Our role toward this neighbor, which lets both sides reject a leak. */
37
- local_role?: BgpRole;
38
+ /** What the neighbor is to us, such as `provider` for a transit. Both sides then reject a route leak, after RFC 9234. */
39
+ role?: BgpRole;
38
40
  /** The neighbor is more than one hop away. */
39
41
  multihop?: boolean;
40
42
  /** TCP MD5 password shared with the neighbor. */
@@ -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
+ role: "provider",
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
+ role: "rs",
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
+ role: "rs",
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
+ });
@@ -0,0 +1,97 @@
1
+ // What a tenant is, and what each device carries for it. A tenant's file says what is the tenant's own; its VLANs and names follow from its name and number.
2
+
3
+ import type { AclRule, Interface, IP, Port, Prefix, Vlan } from "@takodotid/circuit";
4
+ import { familyOf } from "@takodotid/circuit";
5
+
6
+ /** A tenant's networks. `internet` carries its servers' public addresses and reaches the router. `private` connects its own servers to each other and stays on the switches. */
7
+ type TenantNetwork = "internet" | "private";
8
+
9
+ export type Tenant<N extends string = string> = {
10
+ /** Lowercase, and never reused. Names everything the tenant has. */
11
+ name: N;
12
+ /** A number no other tenant has. Its internet VLAN is 1000 + number, its private VLAN 2000 + number. */
13
+ number: number;
14
+ /** Public blocks routed to the tenant. `gateway` is the router's address inside the block, which the tenant's servers use as their gateway. */
15
+ blocks: readonly { prefix: Prefix; gateway: IP }[];
16
+ /** Rules for traffic toward the tenant's IPv4 blocks from the transit and the exchange, such as a cap on UDP. Everything reaches the tenant when absent. */
17
+ acl?: readonly (Omit<AclRule<never>, "match"> & { match?: Omit<NonNullable<AclRule<never>["match"]>, "vlan" | "dst"> })[];
18
+ /** The tenant's ports, by switch, then by port. A port in `private` gives the tenant a private network on that switch. */
19
+ ports: Readonly<
20
+ Record<string, Readonly<Record<string, { description: string; access?: TenantNetwork; trunk?: readonly TenantNetwork[] }>>>
21
+ >;
22
+ };
23
+
24
+ /** Declare a tenant. */
25
+ export const tenant = <const N extends string>(spec: Tenant<N>): Tenant<N> => spec;
26
+
27
+ const internetVlan = <N extends string>(t: Tenant<N>) => `${t.name}-internet` as const;
28
+ const privateVlan = <N extends string>(t: Tenant<N>) => `${t.name}-private` as const;
29
+
30
+ /** Every tenant's internet VLAN. The router has them all, and so does each switch, to carry them up to the router. */
31
+ export const internetVlans = <N extends string>(tenants: readonly Tenant<N>[]) =>
32
+ Object.fromEntries(tenants.map((t) => [internetVlan(t), { id: 1000 + t.number, description: `Tenant ${t.name} internet` }])) as Record<
33
+ `${N}-internet`,
34
+ Vlan
35
+ >;
36
+
37
+ /** The names of every tenant's internet VLAN, for a trunk toward the router. */
38
+ export const internetVlanNames = <N extends string>(tenants: readonly Tenant<N>[]) => tenants.map(internetVlan);
39
+
40
+ /** The private VLANs one switch has: those of tenants with a port on it in their private network. */
41
+ export const privateVlans = <N extends string>(tenants: readonly Tenant<N>[], device: string) =>
42
+ Object.fromEntries(
43
+ tenants
44
+ .filter((t) =>
45
+ Object.values(t.ports[device] ?? {}).some((port) => port.access === "private" || port.trunk?.includes("private"))
46
+ )
47
+ .map((t) => [privateVlan(t), { id: 2000 + t.number, description: `Tenant ${t.name} private` }])
48
+ ) as Record<`${N}-private`, Vlan>;
49
+
50
+ /** The tenants' ports on one switch. */
51
+ export function portsOn<N extends string>(tenants: readonly Tenant<N>[], device: string) {
52
+ const ports: Record<string, Port<`${N}-internet` | `${N}-private`, string, string, string>> = {};
53
+
54
+ for (const t of tenants) {
55
+ const vlanOf = (network: TenantNetwork) => (network === "internet" ? internetVlan(t) : privateVlan(t));
56
+
57
+ for (const [name, { access, trunk, description }] of Object.entries(t.ports[device] ?? {})) {
58
+ ports[name] = {
59
+ description,
60
+ ...(access ? { access_vlan: vlanOf(access) } : {}),
61
+ ...(trunk ? { trunk_vlans: trunk.map(vlanOf) } : {}),
62
+ };
63
+ }
64
+ }
65
+
66
+ return ports;
67
+ }
68
+
69
+ /** The router's interface on each tenant's internet VLAN, holding the tenant's gateways. */
70
+ export function gateways<N extends string>(tenants: readonly Tenant<N>[]) {
71
+ const interfaces = {} as Record<`${N}-internet`, Interface<`${N}-internet`, never>>;
72
+
73
+ for (const t of tenants) {
74
+ interfaces[internetVlan(t)] = {
75
+ type: "vlan",
76
+ vlan: internetVlan(t),
77
+ description: `Tenant ${t.name}`,
78
+ addresses: t.blocks.map(({ prefix, gateway }) => `${gateway}/${prefix.split("/")[1]}`),
79
+ };
80
+ }
81
+
82
+ return interfaces;
83
+ }
84
+
85
+ /** Each tenant's `acl` rules on one VLAN from outside, one per rule and IPv4 block, each limited to traffic toward that block. */
86
+ export const edgeRules = <V extends string>(tenants: readonly Tenant[], vlan: V): AclRule<V>[] =>
87
+ tenants.flatMap((t) =>
88
+ (t.acl ?? []).flatMap((rule) =>
89
+ t.blocks
90
+ .filter(({ prefix }) => familyOf(prefix) === "ipv4")
91
+ .map(({ prefix }) => ({
92
+ ...rule,
93
+ ...(rule.description ? { description: `${vlan}: ${rule.description}` } : {}),
94
+ match: { ...rule.match, vlan, dst: prefix },
95
+ }))
96
+ )
97
+ );
@@ -0,0 +1,19 @@
1
+ // An example tenant. Copy this file for each new tenant, and add it to index.ts.
2
+
3
+ import { tenant } from "../tenancy";
4
+
5
+ export default tenant({
6
+ name: "acme",
7
+ number: 1,
8
+ blocks: [
9
+ { prefix: "198.51.100.8/29", gateway: "198.51.100.9" },
10
+ { prefix: "2001:db8:100:1::/64", gateway: "2001:db8:100:1::1" },
11
+ ],
12
+ acl: [{ description: "UDP to acme above 1G", match: { protocol: "udp" }, action: "accept", rate: "1G" }],
13
+ ports: {
14
+ "tor-01": {
15
+ "10g-1": { description: "acme server 1", trunk: ["internet", "private"] },
16
+ "10g-2": { description: "acme server 2", trunk: ["internet", "private"] },
17
+ },
18
+ },
19
+ });
@@ -0,0 +1,5 @@
1
+ // Every tenant. A tenant joins by being added here, and leaves by being removed: the next apply takes away everything it had.
2
+
3
+ import acme from "./acme";
4
+
5
+ export const TENANTS = [acme] as const;