@takodotid/circuit 0.28.1 → 0.30.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/dist/chunks/{index-22sbskdx.js → index-47debt16.js} +5 -1
- package/dist/chunks/{server-1ftkqkx5.js → server-tjbb3rqc.js} +1 -1
- package/dist/cli/index.js +151 -20
- package/dist/types/adapters/devices/fortios/firewall.d.ts +2 -0
- package/dist/types/adapters/devices/fortios/index.d.ts +1 -1
- package/dist/types/adapters/devices/fortios/names.d.ts +9 -0
- package/dist/types/adapters/devices/fortios/render.d.ts +2 -0
- package/dist/types/adapters/devices/routeros/render.d.ts +2 -0
- package/dist/types/schema/device.d.ts +2 -2
- package/dist/types/schema/interfaces.d.ts +13 -1
- package/dist/types/transport/ssh.d.ts +3 -0
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +3 -0
- package/src/adapters/devices/fortios/firewall.ts +11 -1
- package/src/adapters/devices/fortios/index.ts +52 -4
- package/src/adapters/devices/fortios/names.ts +16 -0
- package/src/adapters/devices/fortios/plan.ts +10 -1
- package/src/adapters/devices/fortios/render.ts +19 -1
- package/src/adapters/devices/junos/index.ts +7 -2
- package/src/adapters/devices/junos/render.ts +1 -1
- package/src/adapters/devices/raisecom-ros/index.ts +7 -2
- package/src/adapters/devices/routeros/TRAPS.md +2 -0
- package/src/adapters/devices/routeros/export.ts +3 -0
- package/src/adapters/devices/routeros/index.ts +6 -2
- package/src/adapters/devices/routeros/plan.ts +1 -0
- package/src/adapters/devices/routeros/render.ts +24 -2
- package/src/adapters/devices/vrp/index.ts +8 -3
- package/src/adapters/registries/librenms.ts +4 -2
- package/src/adapters/registries/prometheus.ts +2 -2
- package/src/cli/index.ts +11 -6
- package/src/core/graph.ts +5 -1
- package/src/core/validate.ts +17 -0
- package/src/schema/device.ts +2 -2
- package/src/schema/interfaces.ts +14 -1
- package/src/transport/ssh.ts +7 -0
|
@@ -117,7 +117,11 @@ function describeDevice(device) {
|
|
|
117
117
|
const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
|
|
118
118
|
return `${name}: ${list.join(", ")}${vrf}`;
|
|
119
119
|
});
|
|
120
|
-
return [
|
|
120
|
+
return [
|
|
121
|
+
`${device.platform} ${device.model}`,
|
|
122
|
+
device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
|
|
123
|
+
...addresses
|
|
124
|
+
];
|
|
121
125
|
}
|
|
122
126
|
function carriedBy(device, settings) {
|
|
123
127
|
const vlan = (name, untagged) => {
|
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
addressesOf,
|
|
12
12
|
freeOfLength,
|
|
13
13
|
studioData
|
|
14
|
-
} from "../chunks/index-
|
|
14
|
+
} from "../chunks/index-47debt16.js";
|
|
15
15
|
import {
|
|
16
16
|
familyOf2,
|
|
17
17
|
split,
|
|
@@ -118,6 +118,11 @@ import { Client as Client2 } from "ssh2";
|
|
|
118
118
|
|
|
119
119
|
// src/transport/ssh.ts
|
|
120
120
|
import { Client } from "ssh2";
|
|
121
|
+
function managementHost(device) {
|
|
122
|
+
if (!device.connection.host)
|
|
123
|
+
throw new Error(`${device.name} has no management address; it is reached through its console`);
|
|
124
|
+
return device.connection.host;
|
|
125
|
+
}
|
|
121
126
|
function connect(login) {
|
|
122
127
|
return new Promise((resolve, reject) => {
|
|
123
128
|
const client = new Client;
|
|
@@ -530,6 +535,14 @@ class ConsoleShell {
|
|
|
530
535
|
}
|
|
531
536
|
|
|
532
537
|
// src/adapters/devices/fortios/names.ts
|
|
538
|
+
function bridgesOf(device) {
|
|
539
|
+
return Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "bridge" ? [{ name, members: iface.members }] : []);
|
|
540
|
+
}
|
|
541
|
+
var isTransparent = (device) => bridgesOf(device).length > 0;
|
|
542
|
+
function forwardDomainOf(device, name) {
|
|
543
|
+
const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
|
|
544
|
+
return index < 0 ? undefined : index + 1;
|
|
545
|
+
}
|
|
533
546
|
function nameOf(device, name) {
|
|
534
547
|
if (device.interfaces && name in device.interfaces)
|
|
535
548
|
return name;
|
|
@@ -710,6 +723,7 @@ function portList(ports) {
|
|
|
710
723
|
const list = Array.isArray(ports) ? ports : [ports];
|
|
711
724
|
return list.map((port) => typeof port === "number" ? String(port) : `${port.min}-${port.max}`);
|
|
712
725
|
}
|
|
726
|
+
var ALL_IN_TRANSPARENT = "circuit-all";
|
|
713
727
|
function serviceOf(match, family) {
|
|
714
728
|
const protocol = match?.protocol;
|
|
715
729
|
const destinations = portList(match?.dst_port);
|
|
@@ -770,6 +784,10 @@ class Objects {
|
|
|
770
784
|
}
|
|
771
785
|
service(match, family) {
|
|
772
786
|
const { name, block } = serviceOf(match, family);
|
|
787
|
+
if (name === "ALL" && isTransparent(this.device)) {
|
|
788
|
+
this.root.block("firewall service custom").entry(ALL_IN_TRANSPARENT).set("protocol", "IP");
|
|
789
|
+
return ALL_IN_TRANSPARENT;
|
|
790
|
+
}
|
|
773
791
|
if (!block)
|
|
774
792
|
return name;
|
|
775
793
|
const entry = this.root.block("firewall service custom").entry(name);
|
|
@@ -1922,7 +1940,11 @@ function plan(desired, current, options) {
|
|
|
1922
1940
|
removals.unshift({ title: `${section}, removed`, show: diff.removed, send: [`config ${header}`, ...diff.removals, "end"] });
|
|
1923
1941
|
}
|
|
1924
1942
|
}
|
|
1925
|
-
|
|
1943
|
+
const opmode = (root) => root.blocks.get("system settings")?.settings.get("opmode");
|
|
1944
|
+
const problems = opmode(have) === "transparent" && opmode(want) !== "transparent" ? [
|
|
1945
|
+
"the device runs in transparent mode and the config has no bridge; leave transparent mode by hand, `set opmode nat` with an address and an interface under `config system settings`, then plan again"
|
|
1946
|
+
] : [];
|
|
1947
|
+
return { steps: [...first, ...steps, ...removals, ...late], problems };
|
|
1926
1948
|
}
|
|
1927
1949
|
var TUNNELS = "system interface, tunnels";
|
|
1928
1950
|
function separateTunnels(root) {
|
|
@@ -2194,6 +2216,7 @@ function allowAccess(device) {
|
|
|
2194
2216
|
services.push("fgfm");
|
|
2195
2217
|
return services.join(" ");
|
|
2196
2218
|
}
|
|
2219
|
+
var TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
2197
2220
|
function render(device) {
|
|
2198
2221
|
const root = new Block;
|
|
2199
2222
|
for (const section of SECTIONS)
|
|
@@ -2203,6 +2226,8 @@ function render(device) {
|
|
|
2203
2226
|
global(section("system global"), device);
|
|
2204
2227
|
if (device.system?.banner)
|
|
2205
2228
|
section(BANNER).set("buffer", quote(device.system.banner));
|
|
2229
|
+
if (isTransparent(device))
|
|
2230
|
+
section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
|
|
2206
2231
|
if (natRules(device).length)
|
|
2207
2232
|
section("system settings").set("central-nat", "enable");
|
|
2208
2233
|
section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
|
|
@@ -2329,6 +2354,9 @@ function interfaces(root, device) {
|
|
|
2329
2354
|
if (parent)
|
|
2330
2355
|
entry.set("interface", quote(nameOf(device, parent)));
|
|
2331
2356
|
entry.set("vlanid", device.vlans?.[iface.vlan]?.id ?? 0);
|
|
2357
|
+
const domain = forwardDomainOf(device, name);
|
|
2358
|
+
if (domain)
|
|
2359
|
+
entry.set("forward-domain", domain);
|
|
2332
2360
|
if (iface.description)
|
|
2333
2361
|
entry.set("description", quote(iface.description));
|
|
2334
2362
|
if (iface.mtu)
|
|
@@ -2703,7 +2731,7 @@ async function login(device) {
|
|
|
2703
2731
|
if (!user)
|
|
2704
2732
|
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
2705
2733
|
return connect({
|
|
2706
|
-
host: device
|
|
2734
|
+
host: managementHost(device),
|
|
2707
2735
|
port: device.connection.port,
|
|
2708
2736
|
user: device.connection.user,
|
|
2709
2737
|
password: resolve(user.password)
|
|
@@ -2893,7 +2921,18 @@ async function settle(device, options) {
|
|
|
2893
2921
|
}
|
|
2894
2922
|
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
2895
2923
|
}
|
|
2896
|
-
async function apply(device,
|
|
2924
|
+
async function apply(device, network, changes, options) {
|
|
2925
|
+
await applyOnce(device, changes, options);
|
|
2926
|
+
const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
|
|
2927
|
+
if (!switched)
|
|
2928
|
+
return;
|
|
2929
|
+
const again = plan(render(device), await read(device, network, options), options);
|
|
2930
|
+
if (!again.steps.length)
|
|
2931
|
+
return;
|
|
2932
|
+
console.log(" the operation mode changed; applying what it added on its own");
|
|
2933
|
+
await applyOnce(device, again, options);
|
|
2934
|
+
}
|
|
2935
|
+
async function applyOnce(device, plan, options) {
|
|
2897
2936
|
const seconds = options.rollback * 60;
|
|
2898
2937
|
try {
|
|
2899
2938
|
if (options.console) {
|
|
@@ -2964,6 +3003,11 @@ function unsupported(device) {
|
|
|
2964
3003
|
found.push(...serviceGaps(device));
|
|
2965
3004
|
if (device.stp)
|
|
2966
3005
|
found.push("stp, ports are routed");
|
|
3006
|
+
found.push(...transparentGaps(device));
|
|
3007
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
3008
|
+
if (iface.type === "vlan" && iface.mac)
|
|
3009
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
3010
|
+
}
|
|
2967
3011
|
if (Object.keys(device.vrfs ?? {}).length > VRFS)
|
|
2968
3012
|
found.push(`vrfs, at most ${VRFS}`);
|
|
2969
3013
|
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
@@ -2972,6 +3016,35 @@ function unsupported(device) {
|
|
|
2972
3016
|
}
|
|
2973
3017
|
return found;
|
|
2974
3018
|
}
|
|
3019
|
+
function transparentGaps(device) {
|
|
3020
|
+
if (!isTransparent(device))
|
|
3021
|
+
return [];
|
|
3022
|
+
const found = [];
|
|
3023
|
+
const where = "in transparent mode, which a bridge needs";
|
|
3024
|
+
if (device.connection.host)
|
|
3025
|
+
found.push(`connection.host, ${where}: the device is reached through its console`);
|
|
3026
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
3027
|
+
if (port?.addresses?.length)
|
|
3028
|
+
found.push(`ports.${name}.addresses, ${where}`);
|
|
3029
|
+
}
|
|
3030
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
3031
|
+
if (iface.addresses?.length)
|
|
3032
|
+
found.push(`interfaces.${name}.addresses, ${where}`);
|
|
3033
|
+
}
|
|
3034
|
+
const routing = device.routing;
|
|
3035
|
+
if (routing?.static?.length || routing?.bgp || routing?.ospf)
|
|
3036
|
+
found.push(`routing, ${where}`);
|
|
3037
|
+
if (device.firewall?.nat)
|
|
3038
|
+
found.push(`firewall.nat, ${where}`);
|
|
3039
|
+
if (device.dhcp && Object.keys(device.dhcp).length)
|
|
3040
|
+
found.push(`dhcp, ${where}`);
|
|
3041
|
+
const management = device.management ?? {};
|
|
3042
|
+
for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"]) {
|
|
3043
|
+
if (management[service])
|
|
3044
|
+
found.push(`management.${service}, ${where}: the device has no address to answer on`);
|
|
3045
|
+
}
|
|
3046
|
+
return found;
|
|
3047
|
+
}
|
|
2975
3048
|
function systemGaps(device) {
|
|
2976
3049
|
const found = [];
|
|
2977
3050
|
const system = device.system ?? {};
|
|
@@ -3947,7 +4020,8 @@ function sflow(out, device, names) {
|
|
|
3947
4020
|
const flow = device.flow_export;
|
|
3948
4021
|
if (flow?.protocol !== "sflow")
|
|
3949
4022
|
return;
|
|
3950
|
-
|
|
4023
|
+
if (device.connection.host)
|
|
4024
|
+
out.set("protocols sflow agent-id", device.connection.host);
|
|
3951
4025
|
if (flow.sampling !== undefined)
|
|
3952
4026
|
out.set("protocols sflow sample-rate ingress", flow.sampling);
|
|
3953
4027
|
for (const collector of flow.collectors) {
|
|
@@ -4521,7 +4595,7 @@ async function login2(device) {
|
|
|
4521
4595
|
if (!user)
|
|
4522
4596
|
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
4523
4597
|
return connect({
|
|
4524
|
-
host: device
|
|
4598
|
+
host: managementHost(device),
|
|
4525
4599
|
port: device.connection.port,
|
|
4526
4600
|
user: device.connection.user,
|
|
4527
4601
|
password: resolve(user.password)
|
|
@@ -4739,6 +4813,12 @@ async function apply2(device, _network, plan, options) {
|
|
|
4739
4813
|
}
|
|
4740
4814
|
function unsupported2(device) {
|
|
4741
4815
|
const found = [];
|
|
4816
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
4817
|
+
if (iface.type === "bridge")
|
|
4818
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
4819
|
+
if (iface.type === "vlan" && iface.mac)
|
|
4820
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
4821
|
+
}
|
|
4742
4822
|
const names = new Names(device);
|
|
4743
4823
|
const users = device.users ?? {};
|
|
4744
4824
|
const root = users.root;
|
|
@@ -5420,7 +5500,7 @@ async function sshShell(device) {
|
|
|
5420
5500
|
if (!user)
|
|
5421
5501
|
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
5422
5502
|
const client = await connect({
|
|
5423
|
-
host: device
|
|
5503
|
+
host: managementHost(device),
|
|
5424
5504
|
port: device.connection.port,
|
|
5425
5505
|
user: device.connection.user,
|
|
5426
5506
|
password: resolve(user.password),
|
|
@@ -5489,6 +5569,12 @@ async function read3(device, _network = [], options = {}) {
|
|
|
5489
5569
|
}
|
|
5490
5570
|
function unsupported3(device) {
|
|
5491
5571
|
const found = [];
|
|
5572
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
5573
|
+
if (iface.type === "bridge")
|
|
5574
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
5575
|
+
if (iface.type === "vlan" && iface.mac)
|
|
5576
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
5577
|
+
}
|
|
5492
5578
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
5493
5579
|
if (!port)
|
|
5494
5580
|
continue;
|
|
@@ -5813,6 +5899,7 @@ var DEFAULTS2 = {
|
|
|
5813
5899
|
"limit-unknown-unicasts": "no"
|
|
5814
5900
|
},
|
|
5815
5901
|
"/interface vlan": { "l3-hw-offloading": "yes", mtu: "1500" },
|
|
5902
|
+
"/interface macvlan": { mode: "bridge", mtu: "1500" },
|
|
5816
5903
|
"/interface vrrp": { priority: "100", "preemption-mode": "yes", interval: "1s", version: "3", "v3-protocol": "ipv4" },
|
|
5817
5904
|
"/interface vxlan": { port: "4789" },
|
|
5818
5905
|
"/interface wireguard peers": {
|
|
@@ -5913,6 +6000,7 @@ var IDENTITY = {
|
|
|
5913
6000
|
"/ipv6 firewall address-list": ["list", "address"],
|
|
5914
6001
|
"/interface bridge port": ["interface"],
|
|
5915
6002
|
"/interface bridge vlan": ["vlan-ids"],
|
|
6003
|
+
"/interface vlan": ["interface", "vlan-id"],
|
|
5916
6004
|
"/interface vxlan vteps": ["interface", "remote-ip"],
|
|
5917
6005
|
"/routing bgp connection": ["remote.address"],
|
|
5918
6006
|
"/routing rpki": ["address"],
|
|
@@ -5968,6 +6056,7 @@ var ORDER = [
|
|
|
5968
6056
|
"/interface ethernet",
|
|
5969
6057
|
"/interface bonding",
|
|
5970
6058
|
"/interface vlan",
|
|
6059
|
+
"/interface macvlan",
|
|
5971
6060
|
"/interface gre",
|
|
5972
6061
|
"/interface vxlan",
|
|
5973
6062
|
"/interface wireguard",
|
|
@@ -6486,17 +6575,28 @@ function lags2(out, ctx) {
|
|
|
6486
6575
|
}));
|
|
6487
6576
|
}
|
|
6488
6577
|
}
|
|
6578
|
+
var underMacvlan = (name) => `${name}-vlan`;
|
|
6489
6579
|
function vlanInterfaces(out, ctx) {
|
|
6490
6580
|
for (const [name, iface] of ctx.vlanInterfaces()) {
|
|
6491
6581
|
const softwareOnly = ctx.device.hardware_offload && iface.hardware_offload === false;
|
|
6582
|
+
const vlanName = iface.mac ? underMacvlan(name) : name;
|
|
6492
6583
|
out.add("/interface vlan", command("add", {
|
|
6493
6584
|
comment: iface.description,
|
|
6494
6585
|
interface: BRIDGE,
|
|
6495
6586
|
"l3-hw-offloading": softwareOnly ? "no" : undefined,
|
|
6496
6587
|
mtu: iface.mtu,
|
|
6497
|
-
name,
|
|
6588
|
+
name: vlanName,
|
|
6498
6589
|
"vlan-id": ctx.vlanId(iface.vlan)
|
|
6499
6590
|
}));
|
|
6591
|
+
if (iface.mac) {
|
|
6592
|
+
out.add("/interface macvlan", command("add", {
|
|
6593
|
+
comment: iface.description,
|
|
6594
|
+
interface: vlanName,
|
|
6595
|
+
"mac-address": iface.mac.toUpperCase(),
|
|
6596
|
+
mtu: iface.mtu,
|
|
6597
|
+
name
|
|
6598
|
+
}));
|
|
6599
|
+
}
|
|
6500
6600
|
}
|
|
6501
6601
|
}
|
|
6502
6602
|
function tunnels(out, ctx) {
|
|
@@ -7346,7 +7446,7 @@ async function login3(device) {
|
|
|
7346
7446
|
if (!user)
|
|
7347
7447
|
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
7348
7448
|
return connect({
|
|
7349
|
-
host: device
|
|
7449
|
+
host: managementHost(device),
|
|
7350
7450
|
port: device.connection.port,
|
|
7351
7451
|
user: device.connection.user,
|
|
7352
7452
|
password: resolve(user.password)
|
|
@@ -7442,6 +7542,10 @@ ${certificates}` : "";
|
|
|
7442
7542
|
}
|
|
7443
7543
|
function unsupported4(device) {
|
|
7444
7544
|
const found = [];
|
|
7545
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
7546
|
+
if (iface.type === "bridge")
|
|
7547
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
7548
|
+
}
|
|
7445
7549
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
7446
7550
|
if (!port)
|
|
7447
7551
|
continue;
|
|
@@ -7985,7 +8089,8 @@ function flowExport3(config, device) {
|
|
|
7985
8089
|
const flow = device.flow_export;
|
|
7986
8090
|
if (flow?.protocol !== "sflow")
|
|
7987
8091
|
return;
|
|
7988
|
-
|
|
8092
|
+
if (device.connection.host)
|
|
8093
|
+
config.line(`sflow agent ip ${device.connection.host}`);
|
|
7989
8094
|
flow.collectors.forEach((collector, index) => {
|
|
7990
8095
|
const number = FIRST_COLLECTOR + index;
|
|
7991
8096
|
config.line(`sflow collector ${number} ip ${collector.address} udp-port ${collector.port ?? 6343}`);
|
|
@@ -8051,7 +8156,7 @@ async function login4(device) {
|
|
|
8051
8156
|
if (!user)
|
|
8052
8157
|
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
8053
8158
|
return connect({
|
|
8054
|
-
host: device
|
|
8159
|
+
host: managementHost(device),
|
|
8055
8160
|
port: device.connection.port,
|
|
8056
8161
|
user: device.connection.user,
|
|
8057
8162
|
password: resolve(user.password),
|
|
@@ -8122,6 +8227,12 @@ function configurationOf(device, dump) {
|
|
|
8122
8227
|
}
|
|
8123
8228
|
function unsupported5(device) {
|
|
8124
8229
|
const found = [];
|
|
8230
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
8231
|
+
if (iface.type === "bridge")
|
|
8232
|
+
found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
8233
|
+
if (iface.type === "vlan" && iface.mac)
|
|
8234
|
+
found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
8235
|
+
}
|
|
8125
8236
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
8126
8237
|
if (!port)
|
|
8127
8238
|
continue;
|
|
@@ -8357,8 +8468,9 @@ async function planLibreNms(network) {
|
|
|
8357
8468
|
if (!response.ok)
|
|
8358
8469
|
throw new Error(`LibreNMS answered ${response.status} to the device list`);
|
|
8359
8470
|
const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
|
|
8360
|
-
const
|
|
8361
|
-
const
|
|
8471
|
+
const polled = network.devices.filter((device) => device.connection.host);
|
|
8472
|
+
const ours = new Set(polled.map((device) => device.connection.host));
|
|
8473
|
+
const missing = polled.filter((device) => !monitored.has(device.connection.host));
|
|
8362
8474
|
return {
|
|
8363
8475
|
additions: missing.filter((device) => device.management?.snmp),
|
|
8364
8476
|
withoutSnmp: missing.filter((device) => !device.management?.snmp),
|
|
@@ -8481,7 +8593,7 @@ var AUTH2 = { sha1: "SHA", sha256: "SHA256" };
|
|
|
8481
8593
|
var PRIVACY2 = { aes128: "AES", des: "DES" };
|
|
8482
8594
|
function prometheusTargets(network) {
|
|
8483
8595
|
return {
|
|
8484
|
-
targets: network.devices.filter((device) => device.management?.snmp).map((device) => ({
|
|
8596
|
+
targets: network.devices.filter((device) => device.management?.snmp && device.connection.host).map((device) => ({
|
|
8485
8597
|
targets: [device.connection.host],
|
|
8486
8598
|
labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name }
|
|
8487
8599
|
})),
|
|
@@ -8838,6 +8950,8 @@ function checkDevice(device, report) {
|
|
|
8838
8950
|
};
|
|
8839
8951
|
if (!device.users?.[device.connection.user])
|
|
8840
8952
|
report(`connection user '${device.connection.user}' is not one of its users`);
|
|
8953
|
+
if (!device.connection.host && !device.connection.console)
|
|
8954
|
+
report("connection has neither a host nor a console to reach the device through");
|
|
8841
8955
|
checkVlans(device, report);
|
|
8842
8956
|
checkAddresses2(device, report, cidr, ip);
|
|
8843
8957
|
checkPorts(device, names, report);
|
|
@@ -8925,6 +9039,20 @@ function checkInterfaces(device, names, report, ip) {
|
|
|
8925
9039
|
if (!members.length)
|
|
8926
9040
|
report(`LAG ${name} has no member ports`);
|
|
8927
9041
|
}
|
|
9042
|
+
if (iface.type === "bridge") {
|
|
9043
|
+
if (iface.members.length < 2)
|
|
9044
|
+
report(`bridge ${name} joins fewer than two interfaces`);
|
|
9045
|
+
for (const member of iface.members) {
|
|
9046
|
+
const joined = device.interfaces?.[member];
|
|
9047
|
+
if (joined?.type !== "vlan")
|
|
9048
|
+
report(`bridge ${name}: member ${member} is not a VLAN interface`);
|
|
9049
|
+
else if (joined.addresses?.length)
|
|
9050
|
+
report(`bridge ${name}: member ${member} has an address of its own`);
|
|
9051
|
+
const others = Object.entries(device.interfaces ?? {}).filter(([other, settings]) => other !== name && settings.type === "bridge" && settings.members.includes(member));
|
|
9052
|
+
if (others.length)
|
|
9053
|
+
report(`bridge ${name}: member ${member} is also in bridge ${others[0][0]}`);
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
8928
9056
|
if (iface.type === "wireguard") {
|
|
8929
9057
|
for (const peer of iface.peers ?? []) {
|
|
8930
9058
|
for (const address of peer.allowed_addresses) {
|
|
@@ -9343,9 +9471,12 @@ function one() {
|
|
|
9343
9471
|
const [device] = selected(false);
|
|
9344
9472
|
return device ?? fail("name a device");
|
|
9345
9473
|
}
|
|
9474
|
+
function optionsFor(device) {
|
|
9475
|
+
return options.console || !device.connection.host ? { ...options, console: true } : options;
|
|
9476
|
+
}
|
|
9346
9477
|
function adapterOf(device) {
|
|
9347
9478
|
const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
9348
|
-
if (!
|
|
9479
|
+
if (!optionsFor(device).console)
|
|
9349
9480
|
return adapter;
|
|
9350
9481
|
if (!adapter.console)
|
|
9351
9482
|
fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
@@ -9419,7 +9550,7 @@ function diffCommand() {
|
|
|
9419
9550
|
}
|
|
9420
9551
|
async function snapshotCommand() {
|
|
9421
9552
|
for (const device of selected(true)) {
|
|
9422
|
-
const text = await adapterOf(device).read(device, network3.devices,
|
|
9553
|
+
const text = await adapterOf(device).read(device, network3.devices, optionsFor(device));
|
|
9423
9554
|
writeState(device, text);
|
|
9424
9555
|
console.log(`${device.name}: ${text.split(`
|
|
9425
9556
|
`).length} lines`);
|
|
@@ -9438,7 +9569,7 @@ async function applyCommand() {
|
|
|
9438
9569
|
const device = one();
|
|
9439
9570
|
guard([device]);
|
|
9440
9571
|
const adapter = adapterOf(device);
|
|
9441
|
-
const live = await adapter.read(device, network3.devices,
|
|
9572
|
+
const live = await adapter.read(device, network3.devices, optionsFor(device));
|
|
9442
9573
|
writeState(device, live);
|
|
9443
9574
|
const plan = adapter.plan(adapter.render(device), live, options);
|
|
9444
9575
|
if (!show(device, plan))
|
|
@@ -9451,8 +9582,8 @@ Not sent: resolve the problems first.`);
|
|
|
9451
9582
|
Nothing sent. Re-run with --confirm.`);
|
|
9452
9583
|
return;
|
|
9453
9584
|
}
|
|
9454
|
-
await adapter.apply(device, network3.devices, plan,
|
|
9455
|
-
writeState(device, await adapter.read(device, network3.devices,
|
|
9585
|
+
await adapter.apply(device, network3.devices, plan, optionsFor(device));
|
|
9586
|
+
writeState(device, await adapter.read(device, network3.devices, optionsFor(device)));
|
|
9456
9587
|
console.log("snapshot updated");
|
|
9457
9588
|
}
|
|
9458
9589
|
async function refreshCommand() {
|
|
@@ -9741,7 +9872,7 @@ async function studioCommand() {
|
|
|
9741
9872
|
const port = Number(flagValue("port") ?? 4600);
|
|
9742
9873
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
9743
9874
|
fail("--port takes a number from 1 to 65535");
|
|
9744
|
-
const { isLoopback, studio } = await import("../chunks/server-
|
|
9875
|
+
const { isLoopback, studio } = await import("../chunks/server-tjbb3rqc.js");
|
|
9745
9876
|
const host = flagValue("host") ?? "127.0.0.1";
|
|
9746
9877
|
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
9747
9878
|
if (!isLoopback(host) && !allowHosts.length) {
|
|
@@ -8,6 +8,8 @@ export declare function natRules(device: Device): NatRule<string, string>[];
|
|
|
8
8
|
export declare function familiesOf(device: Device, match: FilterMatch<string, string> | undefined): Family[];
|
|
9
9
|
/** A port or ports as the platform writes them in a service: `22`, `1000-2000`. */
|
|
10
10
|
export declare function portList(ports: Ports | undefined): string[];
|
|
11
|
+
/** Every protocol, as a service of Circuit's own, for a device in transparent mode. */
|
|
12
|
+
export declare const ALL_IN_TRANSPARENT = "circuit-all";
|
|
11
13
|
/** The service a rule matches, by name, with what it holds. `ALL` when the rule names no protocol and no port; ports without a protocol are TCP's and UDP's. */
|
|
12
14
|
export declare function serviceOf(match: FilterMatch<string, string> | undefined, family: Family): {
|
|
13
15
|
name: string;
|
|
@@ -49,7 +49,7 @@ export type Guard = {
|
|
|
49
49
|
* Send a plan line by line, each applied as it arrives. The device is first set to revert what is not saved, so a refused line or a fresh login that fails reloads its saved configuration. Once access is proved the change is saved, and changes save themselves again.
|
|
50
50
|
*/
|
|
51
51
|
export declare function configure(cli: Cli, plan: Plan, guard: Guard): Promise<void>;
|
|
52
|
-
export declare function apply(device: Device,
|
|
52
|
+
export declare function apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void>;
|
|
53
53
|
/** The lines that replace a prefix list's entries with what a registry returned: every entry is purged, then each prefix added, matching it and anything more specific. */
|
|
54
54
|
export declare function fillLines(set: string, family: Family, prefixes: string[]): string[];
|
|
55
55
|
/** Replace a prefix list `refresh` fetched. It cannot cut access, so it is sent without a guard, and saved as it goes. `purge` asks to confirm, and is answered yes. */
|
|
@@ -7,6 +7,15 @@ type Addressed = {
|
|
|
7
7
|
vrf?: string;
|
|
8
8
|
lag?: string;
|
|
9
9
|
};
|
|
10
|
+
/** The bridges a device has, in the config's order. A device with any runs in transparent mode, each bridge a forward domain numbered by its place. */
|
|
11
|
+
export declare function bridgesOf(device: Device): {
|
|
12
|
+
name: string;
|
|
13
|
+
members: readonly string[];
|
|
14
|
+
}[];
|
|
15
|
+
/** Whether the device runs in transparent mode: it has a bridge. */
|
|
16
|
+
export declare const isTransparent: (device: Device) => boolean;
|
|
17
|
+
/** The forward domain of a VLAN interface in a bridge, or undefined. */
|
|
18
|
+
export declare function forwardDomainOf(device: Device, name: string): number | undefined;
|
|
10
19
|
/** The name the platform gives a port or an interface: a port by its spelling, any other interface by its key. */
|
|
11
20
|
export declare function nameOf(device: Device, name: string): string;
|
|
12
21
|
/** A port's or an interface's settings, by its key. */
|
|
@@ -31,6 +31,8 @@ export declare const LEVEL: Record<Severity, string>;
|
|
|
31
31
|
export declare const HELPERS: Partial<Record<Helper, readonly [string, number, number][]>>;
|
|
32
32
|
/** Speed by the platform's spelling, for each class a port's cage takes. */
|
|
33
33
|
export declare const SPEEDS: Record<string, Partial<Record<string, string>>>;
|
|
34
|
+
/** The management address of a device in transparent mode, which is reached only through its console: link-local, on no network it can reach. */
|
|
35
|
+
export declare const TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
34
36
|
export declare function render(device: Device): string;
|
|
35
37
|
/** The one port or LAG that carries a VLAN tagged, which its VLAN interface sits on. `unsupported()` refuses any other case. */
|
|
36
38
|
export declare function vlanParent(device: Device, vlan: string): string | undefined;
|
|
@@ -64,6 +64,8 @@ export declare function renderInterfaces(out: Output, ctx: Context): void;
|
|
|
64
64
|
type Speed = NonNullable<AnyPort["speed"]>;
|
|
65
65
|
/** The platform's name for a fixed speed on a port, or undefined when it has none. */
|
|
66
66
|
export declare function mediumOf(platformPortName: string, speed: Speed): string | undefined;
|
|
67
|
+
/** The name of the VLAN interface under one with a MAC of its own, which takes the configured name itself. */
|
|
68
|
+
export declare const underMacvlan: (name: string) => string;
|
|
67
69
|
/** The interface holding one VRRP group. */
|
|
68
70
|
export declare const vrrpName: (iface: string, id: number) => string;
|
|
69
71
|
/**
|
|
@@ -6,8 +6,8 @@ import type { Routing } from "./routing";
|
|
|
6
6
|
import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System, User } from "./system";
|
|
7
7
|
/** How the framework reaches the device. */
|
|
8
8
|
export type Connection<U extends string> = {
|
|
9
|
-
/** Management address. */
|
|
10
|
-
host
|
|
9
|
+
/** Management address. Absent for a device reached only through its console, `console`. */
|
|
10
|
+
host?: IP;
|
|
11
11
|
/** One of the device's `users`. Its password is the login. */
|
|
12
12
|
user: U;
|
|
13
13
|
/** SSH port. 22 when absent. */
|
|
@@ -114,6 +114,10 @@ export type VlanInterface<V extends string, F extends string> = Base<F> & {
|
|
|
114
114
|
type: "vlan";
|
|
115
115
|
/** The VLAN it is on. */
|
|
116
116
|
vlan: V;
|
|
117
|
+
/**
|
|
118
|
+
* A MAC address of its own, instead of the one it shares with the device's other VLAN interfaces. A gateway in front of a transparent firewall needs one: frames to it come back on another VLAN, where the bridge would otherwise take a shared MAC for its own and keep them.
|
|
119
|
+
*/
|
|
120
|
+
mac?: string;
|
|
117
121
|
};
|
|
118
122
|
/** An interface that is always up and is on no link, for addresses that must not depend on one. */
|
|
119
123
|
export type Loopback<F extends string> = Base<F> & {
|
|
@@ -176,6 +180,14 @@ export type WireGuard<F extends string> = Base<F> & {
|
|
|
176
180
|
/** Who may connect. */
|
|
177
181
|
peers?: readonly WireGuardPeer[];
|
|
178
182
|
};
|
|
183
|
+
/**
|
|
184
|
+
* A layer 2 bridge between VLAN interfaces, each keeping its own VLAN: a frame that comes in on one member leaves another with that member's tag. A firewall filters what crosses it, by the rules of its forward chain between the members, without routing it.
|
|
185
|
+
*/
|
|
186
|
+
export type Bridge<F extends string> = Base<F> & {
|
|
187
|
+
type: "bridge";
|
|
188
|
+
/** The VLAN interfaces it joins, by name. Each has no address of its own. */
|
|
189
|
+
members: readonly string[];
|
|
190
|
+
};
|
|
179
191
|
/** Any interface that is not a physical port. */
|
|
180
|
-
export type Interface<V extends string, F extends string> = VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F>;
|
|
192
|
+
export type Interface<V extends string, F extends string> = VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F> | Bridge<F>;
|
|
181
193
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Client, type ClientChannel } from "ssh2";
|
|
2
|
+
import type { Device } from "../schema";
|
|
2
3
|
export type Login = {
|
|
3
4
|
host: string;
|
|
4
5
|
port?: number;
|
|
@@ -7,6 +8,8 @@ export type Login = {
|
|
|
7
8
|
/** Host key algorithms to accept, for a device that negotiates one it then signs wrongly. */
|
|
8
9
|
hostKeys?: string[];
|
|
9
10
|
};
|
|
11
|
+
/** The address SSH reaches a device at. A device without one is reached only through its console. */
|
|
12
|
+
export declare function managementHost(device: Device): string;
|
|
10
13
|
/** Open a session. */
|
|
11
14
|
export declare function connect(login: Login): Promise<Client>;
|
|
12
15
|
/** Run one command and return what it printed, stderr included. */
|
package/package.json
CHANGED
|
@@ -40,3 +40,6 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
|
|
|
40
40
|
- The ports are `port1` to `port4` at 40G and `port5` to `port32` at 10G, then `mgmt1` and `mgmt2`. The factory dedicates both management ports to management and gives them addresses `192.168.1.99` and `192.168.2.99`.
|
|
41
41
|
- On the serial console a line ends at a carriage return. A session left open stays logged in, and one left inside `config` stays there, so the next login must first leave and log out. The console takes 9600 bits per second: reading the sections Circuit manages takes about two minutes.
|
|
42
42
|
- A data port can stay `netdev status: down`, with `rx_carrier: 0` in `diagnose hardware deviceinfo nic`, while the far end reads link-ok and frames reach the port's switch chip. Fixing the speed does not change it, in a LAG or out of one; a reboot does. `link_status: Up` there says nothing: a port with no cable shows it too.
|
|
43
|
+
- Transparent mode, `set opmode transparent` under `config system settings`, refuses to start without a `manageip`: `Attribute 'manageip' MUST be set`. Switching to it takes every interface's address away, replaces the static routes with one default gateway, puts every port in one bridge, and deletes the factory's custom services, `ALL` among them, so a policy that names `ALL` is refused, `entry not found in datasource`. A link-local `manageip`, such as `169.254.0.1/255.255.255.255`, is accepted. It also adds an empty static route in each family and turns memory logging on, so an apply that switches the mode plans once more after it.
|
|
44
|
+
- `forward-domain` exists only in transparent mode: in NAT mode it is `command parse error`. VLAN interfaces in the same forward domain are bridged, each frame leaving with the tag of the interface it leaves by.
|
|
45
|
+
- Leaving transparent mode, `set opmode nat`, refuses `IP address must be a class A, B, or C ip` until an address is given with it.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { host } from "../../../core/addr";
|
|
4
4
|
import type { AclRule, Device, Family, FilterMatch, FilterRule, NatRule, Ports, Protocol } from "../../../schema";
|
|
5
|
-
import { familyOfPrefix, nameOf, network, subnet, withLength } from "./names";
|
|
5
|
+
import { familyOfPrefix, isTransparent, nameOf, network, subnet, withLength } from "./names";
|
|
6
6
|
import { Block, quote, quoteAll, UNSET } from "./tree";
|
|
7
7
|
|
|
8
8
|
/** IP protocol numbers, for a protocol the platform names only by number. */
|
|
@@ -51,6 +51,9 @@ export function portList(ports: Ports | undefined): string[] {
|
|
|
51
51
|
return list.map((port) => (typeof port === "number" ? String(port) : `${port.min}-${port.max}`));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Every protocol, as a service of Circuit's own, for a device in transparent mode. */
|
|
55
|
+
export const ALL_IN_TRANSPARENT = "circuit-all";
|
|
56
|
+
|
|
54
57
|
/** The service a rule matches, by name, with what it holds. `ALL` when the rule names no protocol and no port; ports without a protocol are TCP's and UDP's. */
|
|
55
58
|
export function serviceOf(match: FilterMatch<string, string> | undefined, family: Family): { name: string; block?: Block } {
|
|
56
59
|
const protocol = match?.protocol;
|
|
@@ -134,6 +137,13 @@ class Objects {
|
|
|
134
137
|
|
|
135
138
|
service(match: FilterMatch<string, string> | undefined, family: Family): string {
|
|
136
139
|
const { name, block } = serviceOf(match, family);
|
|
140
|
+
|
|
141
|
+
// Transparent mode deletes the factory's services, `ALL` with them, so every protocol is a service of Circuit's own there.
|
|
142
|
+
if (name === "ALL" && isTransparent(this.device)) {
|
|
143
|
+
this.root.block("firewall service custom").entry(ALL_IN_TRANSPARENT).set("protocol", "IP");
|
|
144
|
+
return ALL_IN_TRANSPARENT;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
if (!block) return name;
|
|
138
148
|
|
|
139
149
|
const entry = this.root.block("firewall service custom").entry(name);
|
|
@@ -7,11 +7,11 @@ import { familyOf, host, split } from "../../../core/addr";
|
|
|
7
7
|
import { MARKER, resolve, valueOfReference } from "../../../core/secrets";
|
|
8
8
|
import type { AclRule, Device, Family, FilterRule } from "../../../schema";
|
|
9
9
|
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
10
|
-
import { connect, shell } from "../../../transport/ssh";
|
|
10
|
+
import { connect, managementHost, shell } from "../../../transport/ssh";
|
|
11
11
|
import { portsOf, spell } from "../catalog";
|
|
12
12
|
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
13
13
|
import { acceptsBeforeDrop, familiesOf, isImplied, serviceOf } from "./firewall";
|
|
14
|
-
import { holderOf, interfaceFor, network, settingsOf, subnet } from "./names";
|
|
14
|
+
import { holderOf, interfaceFor, isTransparent, network, settingsOf, subnet } from "./names";
|
|
15
15
|
import { clean, plan } from "./plan";
|
|
16
16
|
import { COLLECTORS, FULL, HELPERS, RESOLVERS, render, SECTIONS, SPEEDS, SSH_KEYS, vlanParent, VRFS, zoneOf } from "./render";
|
|
17
17
|
import { BITS, PREFIX_LIST, routingGaps } from "./routing";
|
|
@@ -141,7 +141,7 @@ export async function login(device: Device): Promise<Client> {
|
|
|
141
141
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
142
142
|
|
|
143
143
|
return connect({
|
|
144
|
-
host: device
|
|
144
|
+
host: managementHost(device),
|
|
145
145
|
port: device.connection.port,
|
|
146
146
|
user: device.connection.user,
|
|
147
147
|
password: resolve(user.password),
|
|
@@ -376,7 +376,21 @@ async function settle(device: Device, options: ReadOptions): Promise<string> {
|
|
|
376
376
|
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
export async function apply(device: Device,
|
|
379
|
+
export async function apply(device: Device, network: readonly Device[], changes: Plan, options: ApplyOptions): Promise<void> {
|
|
380
|
+
await applyOnce(device, changes, options);
|
|
381
|
+
|
|
382
|
+
// Switching the operation mode makes changes of its own, such as a static route in each family and memory logging on, which a plan made before it could not see. One more pass, planned against what the device now runs, takes them back.
|
|
383
|
+
const switched = changes.steps.some((step) => step.send.some((line) => line.startsWith("set opmode ")));
|
|
384
|
+
if (!switched) return;
|
|
385
|
+
|
|
386
|
+
const again = plan(render(device), await read(device, network, options), options);
|
|
387
|
+
if (!again.steps.length) return;
|
|
388
|
+
|
|
389
|
+
console.log(" the operation mode changed; applying what it added on its own");
|
|
390
|
+
await applyOnce(device, again, options);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function applyOnce(device: Device, plan: Plan, options: ApplyOptions): Promise<void> {
|
|
380
394
|
const seconds = options.rollback * 60;
|
|
381
395
|
|
|
382
396
|
try {
|
|
@@ -454,6 +468,11 @@ export function unsupported(device: Device): string[] {
|
|
|
454
468
|
found.push(...serviceGaps(device));
|
|
455
469
|
|
|
456
470
|
if (device.stp) found.push("stp, ports are routed");
|
|
471
|
+
found.push(...transparentGaps(device));
|
|
472
|
+
|
|
473
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
474
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
475
|
+
}
|
|
457
476
|
if (Object.keys(device.vrfs ?? {}).length > VRFS) found.push(`vrfs, at most ${VRFS}`);
|
|
458
477
|
|
|
459
478
|
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
@@ -463,6 +482,35 @@ export function unsupported(device: Device): string[] {
|
|
|
463
482
|
return found;
|
|
464
483
|
}
|
|
465
484
|
|
|
485
|
+
/** What transparent mode, which a bridge needs, leaves out: every address, routing, NAT and the services that answer on an address. The device is reached through its console. */
|
|
486
|
+
function transparentGaps(device: Device): string[] {
|
|
487
|
+
if (!isTransparent(device)) return [];
|
|
488
|
+
|
|
489
|
+
const found: string[] = [];
|
|
490
|
+
const where = "in transparent mode, which a bridge needs";
|
|
491
|
+
|
|
492
|
+
if (device.connection.host) found.push(`connection.host, ${where}: the device is reached through its console`);
|
|
493
|
+
|
|
494
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
495
|
+
if (port?.addresses?.length) found.push(`ports.${name}.addresses, ${where}`);
|
|
496
|
+
}
|
|
497
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
498
|
+
if (iface.addresses?.length) found.push(`interfaces.${name}.addresses, ${where}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const routing = device.routing;
|
|
502
|
+
if (routing?.static?.length || routing?.bgp || routing?.ospf) found.push(`routing, ${where}`);
|
|
503
|
+
if (device.firewall?.nat) found.push(`firewall.nat, ${where}`);
|
|
504
|
+
if (device.dhcp && Object.keys(device.dhcp).length) found.push(`dhcp, ${where}`);
|
|
505
|
+
|
|
506
|
+
const management = device.management ?? {};
|
|
507
|
+
for (const service of ["ssh", "telnet", "http", "https", "api", "api_tls", "snmp", "native"] as const) {
|
|
508
|
+
if (management[service]) found.push(`management.${service}, ${where}: the device has no address to answer on`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return found;
|
|
512
|
+
}
|
|
513
|
+
|
|
466
514
|
function systemGaps(device: Device): string[] {
|
|
467
515
|
const found: string[] = [];
|
|
468
516
|
const system = device.system ?? {};
|
|
@@ -6,6 +6,22 @@ import { spell } from "../catalog";
|
|
|
6
6
|
|
|
7
7
|
type Addressed = { addresses?: readonly (string | { address: string; peer?: string })[]; vrf?: string; lag?: string };
|
|
8
8
|
|
|
9
|
+
/** The bridges a device has, in the config's order. A device with any runs in transparent mode, each bridge a forward domain numbered by its place. */
|
|
10
|
+
export function bridgesOf(device: Device): { name: string; members: readonly string[] }[] {
|
|
11
|
+
return Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
12
|
+
iface.type === "bridge" ? [{ name, members: iface.members }] : []
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether the device runs in transparent mode: it has a bridge. */
|
|
17
|
+
export const isTransparent = (device: Device) => bridgesOf(device).length > 0;
|
|
18
|
+
|
|
19
|
+
/** The forward domain of a VLAN interface in a bridge, or undefined. */
|
|
20
|
+
export function forwardDomainOf(device: Device, name: string): number | undefined {
|
|
21
|
+
const index = bridgesOf(device).findIndex((bridge) => bridge.members.includes(name));
|
|
22
|
+
return index < 0 ? undefined : index + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/** The name the platform gives a port or an interface: a port by its spelling, any other interface by its key. */
|
|
10
26
|
export function nameOf(device: Device, name: string): string {
|
|
11
27
|
if (device.interfaces && name in device.interfaces) return name;
|
|
@@ -805,7 +805,16 @@ export function plan(desired: string, current: string, options: Pick<ApplyOption
|
|
|
805
805
|
}
|
|
806
806
|
}
|
|
807
807
|
|
|
808
|
-
|
|
808
|
+
// Leaving transparent mode needs an address and an interface for it, which the config of a device without a bridge does not say.
|
|
809
|
+
const opmode = (root: Block) => root.blocks.get("system settings")?.settings.get("opmode");
|
|
810
|
+
const problems =
|
|
811
|
+
opmode(have) === "transparent" && opmode(want) !== "transparent"
|
|
812
|
+
? [
|
|
813
|
+
"the device runs in transparent mode and the config has no bridge; leave transparent mode by hand, `set opmode nat` with an address and an interface under `config system settings`, then plan again",
|
|
814
|
+
]
|
|
815
|
+
: [];
|
|
816
|
+
|
|
817
|
+
return { steps: [...first, ...steps, ...removals, ...late], problems };
|
|
809
818
|
}
|
|
810
819
|
|
|
811
820
|
/** The interfaces of tunnels, which their own tables create, compared after those tables as a section of their own. */
|
|
@@ -5,7 +5,18 @@ import { marker } from "../../../core/secrets";
|
|
|
5
5
|
import type { Device, Helper, Severity } from "../../../schema";
|
|
6
6
|
import { portsOf, spell } from "../catalog";
|
|
7
7
|
import { firewall, natRules } from "./firewall";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
addressed,
|
|
10
|
+
addressesOf,
|
|
11
|
+
forwardDomainOf,
|
|
12
|
+
holderOf,
|
|
13
|
+
interfaceFor,
|
|
14
|
+
isTransparent,
|
|
15
|
+
nameOf,
|
|
16
|
+
network,
|
|
17
|
+
subnet,
|
|
18
|
+
vrfNumber,
|
|
19
|
+
} from "./names";
|
|
9
20
|
import { routing, usesBfd } from "./routing";
|
|
10
21
|
import { Block, print, quote, quoteAll } from "./tree";
|
|
11
22
|
|
|
@@ -246,6 +257,9 @@ function allowAccess(device: Device): string {
|
|
|
246
257
|
return services.join(" ");
|
|
247
258
|
}
|
|
248
259
|
|
|
260
|
+
/** The management address of a device in transparent mode, which is reached only through its console: link-local, on no network it can reach. */
|
|
261
|
+
export const TRANSPARENT_MANAGEIP = "169.254.0.1/255.255.255.255";
|
|
262
|
+
|
|
249
263
|
export function render(device: Device): string {
|
|
250
264
|
const root = new Block();
|
|
251
265
|
for (const section of SECTIONS) root.block(section);
|
|
@@ -255,6 +269,8 @@ export function render(device: Device): string {
|
|
|
255
269
|
certificates(section("vpn certificate local"), section("vpn certificate ca"), device);
|
|
256
270
|
global(section("system global"), device);
|
|
257
271
|
if (device.system?.banner) section(BANNER).set("buffer", quote(device.system.banner));
|
|
272
|
+
// A bridge needs transparent mode, and transparent mode a management address. One on no network the device can reach keeps it reached only through its console.
|
|
273
|
+
if (isTransparent(device)) section("system settings").set("opmode", "transparent").set("manageip", TRANSPARENT_MANAGEIP);
|
|
258
274
|
if (natRules(device).length) section("system settings").set("central-nat", "enable");
|
|
259
275
|
// SIP is otherwise rewritten by its proxy whatever the helpers say; this way only the `sip` helper does.
|
|
260
276
|
section("system settings").set("default-voip-alg-mode", "kernel-helper-based");
|
|
@@ -391,6 +407,8 @@ function interfaces(root: Block, device: Device): void {
|
|
|
391
407
|
entry.set("vdom", quote("root"));
|
|
392
408
|
if (parent) entry.set("interface", quote(nameOf(device, parent)));
|
|
393
409
|
entry.set("vlanid", device.vlans?.[iface.vlan]?.id ?? 0);
|
|
410
|
+
const domain = forwardDomainOf(device, name);
|
|
411
|
+
if (domain) entry.set("forward-domain", domain);
|
|
394
412
|
if (iface.description) entry.set("description", quote(iface.description));
|
|
395
413
|
if (iface.mtu) entry.set("mtu-override", "enable").set("mtu", iface.mtu);
|
|
396
414
|
redirects(entry, device);
|
|
@@ -7,7 +7,7 @@ import { familyOf, host, isAddress, split } from "../../../core/addr";
|
|
|
7
7
|
import { resolve, substitute } from "../../../core/secrets";
|
|
8
8
|
import type { Device, Family } from "../../../schema";
|
|
9
9
|
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
10
|
-
import { connect, exec, readUntil, shell } from "../../../transport/ssh";
|
|
10
|
+
import { connect, exec, managementHost, readUntil, shell } from "../../../transport/ssh";
|
|
11
11
|
import { lagIds } from "../derive";
|
|
12
12
|
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions, Step } from "../types";
|
|
13
13
|
import { GENERATED, settingsOf } from "./bgp";
|
|
@@ -296,7 +296,7 @@ export async function login(device: Device): Promise<Client> {
|
|
|
296
296
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
297
297
|
|
|
298
298
|
return connect({
|
|
299
|
-
host: device
|
|
299
|
+
host: managementHost(device),
|
|
300
300
|
port: device.connection.port,
|
|
301
301
|
user: device.connection.user,
|
|
302
302
|
password: resolve(user.password),
|
|
@@ -565,6 +565,11 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
565
565
|
|
|
566
566
|
export function unsupported(device: Device): string[] {
|
|
567
567
|
const found: string[] = [];
|
|
568
|
+
|
|
569
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
570
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
571
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
572
|
+
}
|
|
568
573
|
const names = new Names(device);
|
|
569
574
|
const users = device.users ?? {};
|
|
570
575
|
|
|
@@ -675,7 +675,7 @@ function sflow(out: Statements, device: Device, names: Names): void {
|
|
|
675
675
|
const flow = device.flow_export;
|
|
676
676
|
if (flow?.protocol !== "sflow") return;
|
|
677
677
|
|
|
678
|
-
out.set("protocols sflow agent-id", device.connection.host);
|
|
678
|
+
if (device.connection.host) out.set("protocols sflow agent-id", device.connection.host);
|
|
679
679
|
if (flow.sampling !== undefined) out.set("protocols sflow sample-rate ingress", flow.sampling);
|
|
680
680
|
|
|
681
681
|
for (const collector of flow.collectors) {
|
|
@@ -8,7 +8,7 @@ import { marker, resolve, substitute } from "../../../core/secrets";
|
|
|
8
8
|
import { offset } from "../../../core/time";
|
|
9
9
|
import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
|
|
10
10
|
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
11
|
-
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
|
+
import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
|
|
12
12
|
import { portsOf, spell } from "../catalog";
|
|
13
13
|
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
14
14
|
import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
|
|
@@ -338,7 +338,7 @@ async function sshShell(device: Device): Promise<Session> {
|
|
|
338
338
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
339
339
|
|
|
340
340
|
const client = await connect({
|
|
341
|
-
host: device
|
|
341
|
+
host: managementHost(device),
|
|
342
342
|
port: device.connection.port,
|
|
343
343
|
user: device.connection.user,
|
|
344
344
|
password: resolve(user.password),
|
|
@@ -420,6 +420,11 @@ export async function read(device: Device, _network: readonly Device[] = [], opt
|
|
|
420
420
|
export function unsupported(device: Device): string[] {
|
|
421
421
|
const found: string[] = [];
|
|
422
422
|
|
|
423
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
424
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
425
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
426
|
+
}
|
|
427
|
+
|
|
423
428
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
424
429
|
if (!port) continue;
|
|
425
430
|
|
|
@@ -42,3 +42,5 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
42
42
|
- `:parse` checks the command, not the text inside `rule=`. A routing filter rule is only checked when it is added.
|
|
43
43
|
- `/routing/filter/filter-wizard` adds the rule it builds; it is not a dry run. `set blackhole yes` was proved this way on 7.23, in an unused chain removed right after.
|
|
44
44
|
- On the serial console, the prompt can come twice after a login. The console echoes a long command wrapped at 80 columns, and a `print` that fills the screen stops at `-- [Q quit|D dump|down]`, where `D` prints the rest.
|
|
45
|
+
- Every VLAN interface on the bridge has the bridge's MAC, and the bridge keeps a frame to that MAC on whatever VLAN it arrives. A gateway in front of a transparent firewall then never gets its replies: they come back on the inside VLAN and stop at the bridge. A MACVLAN on the VLAN interface gives the gateway a MAC of its own, and its replies cross the firewall back; proved with a FortiGate 3700D in transparent mode, the session offloaded to its NP6.
|
|
46
|
+
- A second VLAN interface with the same VLAN ID on the same interface is refused, `already have such vlan`, so a VLAN interface is known by its interface and VLAN ID, and a new name is a rename.
|
|
@@ -131,6 +131,7 @@ export const DEFAULTS: Record<string, Record<string, string>> = {
|
|
|
131
131
|
"limit-unknown-unicasts": "no",
|
|
132
132
|
},
|
|
133
133
|
"/interface vlan": { "l3-hw-offloading": "yes", mtu: "1500" },
|
|
134
|
+
"/interface macvlan": { mode: "bridge", mtu: "1500" },
|
|
134
135
|
"/interface vrrp": { priority: "100", "preemption-mode": "yes", interval: "1s", version: "3", "v3-protocol": "ipv4" },
|
|
135
136
|
"/interface vxlan": { port: "4789" },
|
|
136
137
|
"/interface wireguard peers": {
|
|
@@ -259,6 +260,8 @@ const IDENTITY: Record<string, string[]> = {
|
|
|
259
260
|
"/ipv6 firewall address-list": ["list", "address"],
|
|
260
261
|
"/interface bridge port": ["interface"],
|
|
261
262
|
"/interface bridge vlan": ["vlan-ids"],
|
|
263
|
+
// One VLAN ID per interface: the device refuses a second, so a rename, such as one a MAC of its own brings, is a rename.
|
|
264
|
+
"/interface vlan": ["interface", "vlan-id"],
|
|
262
265
|
"/interface vxlan vteps": ["interface", "remote-ip"],
|
|
263
266
|
"/routing bgp connection": ["remote.address"],
|
|
264
267
|
"/routing rpki": ["address"],
|
|
@@ -7,7 +7,7 @@ import { familyOf } from "../../../core/addr";
|
|
|
7
7
|
import { resolve, substitute } from "../../../core/secrets";
|
|
8
8
|
import type { Device, Family } from "../../../schema";
|
|
9
9
|
import { type ConsoleDialect, consoleLogin, consoleOf, ConsoleShell, openConsole } from "../../../transport/console";
|
|
10
|
-
import { connect, exec, upload } from "../../../transport/ssh";
|
|
10
|
+
import { connect, exec, managementHost, upload } from "../../../transport/ssh";
|
|
11
11
|
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
12
12
|
import { INSTALL_CERTIFICATE, plan } from "./plan";
|
|
13
13
|
import { spell } from "../catalog";
|
|
@@ -21,7 +21,7 @@ export async function login(device: Device): Promise<Client> {
|
|
|
21
21
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
22
22
|
|
|
23
23
|
return connect({
|
|
24
|
-
host: device
|
|
24
|
+
host: managementHost(device),
|
|
25
25
|
port: device.connection.port,
|
|
26
26
|
user: device.connection.user,
|
|
27
27
|
password: resolve(user.password),
|
|
@@ -151,6 +151,10 @@ export async function read(device: Device, _network: readonly Device[] = [], opt
|
|
|
151
151
|
export function unsupported(device: Device): string[] {
|
|
152
152
|
const found: string[] = [];
|
|
153
153
|
|
|
154
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
155
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
155
159
|
if (!port) continue;
|
|
156
160
|
|
|
@@ -344,10 +344,19 @@ function lags(out: Output, ctx: Context): void {
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
/**
|
|
347
|
+
/** The name of the VLAN interface under one with a MAC of its own, which takes the configured name itself. */
|
|
348
|
+
export const underMacvlan = (name: string) => `${name}-vlan`;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* A VLAN interface sits on the bridge. When the device offloads, one that opts out is routed in software so the firewall applies.
|
|
352
|
+
*
|
|
353
|
+
* Every VLAN interface on the bridge has the bridge's MAC. One with a MAC of its own is a MACVLAN on top of the VLAN interface: the MACVLAN takes the configured name, so its addresses and every rule naming it land on it.
|
|
354
|
+
*/
|
|
348
355
|
function vlanInterfaces(out: Output, ctx: Context): void {
|
|
349
356
|
for (const [name, iface] of ctx.vlanInterfaces()) {
|
|
350
357
|
const softwareOnly = ctx.device.hardware_offload && iface.hardware_offload === false;
|
|
358
|
+
const vlanName = iface.mac ? underMacvlan(name) : name;
|
|
359
|
+
|
|
351
360
|
out.add(
|
|
352
361
|
"/interface vlan",
|
|
353
362
|
command("add", {
|
|
@@ -355,10 +364,23 @@ function vlanInterfaces(out: Output, ctx: Context): void {
|
|
|
355
364
|
interface: BRIDGE,
|
|
356
365
|
"l3-hw-offloading": softwareOnly ? "no" : undefined,
|
|
357
366
|
mtu: iface.mtu,
|
|
358
|
-
name,
|
|
367
|
+
name: vlanName,
|
|
359
368
|
"vlan-id": ctx.vlanId(iface.vlan),
|
|
360
369
|
})
|
|
361
370
|
);
|
|
371
|
+
|
|
372
|
+
if (iface.mac) {
|
|
373
|
+
out.add(
|
|
374
|
+
"/interface macvlan",
|
|
375
|
+
command("add", {
|
|
376
|
+
comment: iface.description,
|
|
377
|
+
interface: vlanName,
|
|
378
|
+
"mac-address": iface.mac.toUpperCase(),
|
|
379
|
+
mtu: iface.mtu,
|
|
380
|
+
name,
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
}
|
|
362
384
|
}
|
|
363
385
|
}
|
|
364
386
|
|
|
@@ -8,7 +8,7 @@ import { marker, resolve, substitute, valueOfReference } from "../../../core/sec
|
|
|
8
8
|
import { offset } from "../../../core/time";
|
|
9
9
|
import type { Device, Interface, Port, Vrrp } from "../../../schema";
|
|
10
10
|
import { type ConsoleDialect, consoleLogin, consoleOf, openConsole } from "../../../transport/console";
|
|
11
|
-
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
|
+
import { connect, managementHost, readUntil, send, shell } from "../../../transport/ssh";
|
|
12
12
|
import { portsOf, spell } from "../catalog";
|
|
13
13
|
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
14
14
|
import { compare, type Dialect, scrub, steps, vlanListChange } from "../lines";
|
|
@@ -465,7 +465,7 @@ function flowExport(config: Config, device: Device): void {
|
|
|
465
465
|
const flow = device.flow_export;
|
|
466
466
|
if (flow?.protocol !== "sflow") return;
|
|
467
467
|
|
|
468
|
-
config.line(`sflow agent ip ${device.connection.host}`);
|
|
468
|
+
if (device.connection.host) config.line(`sflow agent ip ${device.connection.host}`);
|
|
469
469
|
|
|
470
470
|
flow.collectors.forEach((collector, index) => {
|
|
471
471
|
const number = FIRST_COLLECTOR + index;
|
|
@@ -550,7 +550,7 @@ export async function login(device: Device): Promise<Client> {
|
|
|
550
550
|
const user = device.users?.[device.connection.user];
|
|
551
551
|
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
552
552
|
return connect({
|
|
553
|
-
host: device
|
|
553
|
+
host: managementHost(device),
|
|
554
554
|
port: device.connection.port,
|
|
555
555
|
user: device.connection.user,
|
|
556
556
|
password: resolve(user.password),
|
|
@@ -637,6 +637,11 @@ function configurationOf(device: Device, dump: string): string {
|
|
|
637
637
|
export function unsupported(device: Device): string[] {
|
|
638
638
|
const found: string[] = [];
|
|
639
639
|
|
|
640
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
641
|
+
if (iface.type === "bridge") found.push(`interfaces.${name}, a bridge, which only FortiOS builds for now`);
|
|
642
|
+
if (iface.type === "vlan" && iface.mac) found.push(`interfaces.${name}.mac, which only RouterOS sets for now`);
|
|
643
|
+
}
|
|
644
|
+
|
|
640
645
|
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
641
646
|
if (!port) continue;
|
|
642
647
|
|
|
@@ -57,8 +57,10 @@ export async function planLibreNms(network: Network): Promise<LibreNmsPlan> {
|
|
|
57
57
|
if (!response.ok) throw new Error(`LibreNMS answered ${response.status} to the device list`);
|
|
58
58
|
|
|
59
59
|
const monitored = new Set(((await response.json()) as { devices: { hostname: string }[] }).devices.map((device) => device.hostname));
|
|
60
|
-
|
|
61
|
-
const
|
|
60
|
+
// A device reached only through its console has no address to poll.
|
|
61
|
+
const polled = network.devices.filter((device) => device.connection.host);
|
|
62
|
+
const ours = new Set(polled.map((device) => device.connection.host));
|
|
63
|
+
const missing = polled.filter((device) => !monitored.has(device.connection.host!));
|
|
62
64
|
|
|
63
65
|
return {
|
|
64
66
|
additions: missing.filter((device) => device.management?.snmp),
|
|
@@ -15,9 +15,9 @@ const PRIVACY = { aes128: "AES", des: "DES" } as const;
|
|
|
15
15
|
export function prometheusTargets(network: Network): { targets: PrometheusTarget[]; withoutSnmp: Device[] } {
|
|
16
16
|
return {
|
|
17
17
|
targets: network.devices
|
|
18
|
-
.filter((device) => device.management?.snmp)
|
|
18
|
+
.filter((device) => device.management?.snmp && device.connection.host)
|
|
19
19
|
.map((device) => ({
|
|
20
|
-
targets: [device.connection.host],
|
|
20
|
+
targets: [device.connection.host!],
|
|
21
21
|
// `__param_auth` picks the device's own auth in snmp_exporter.
|
|
22
22
|
labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name },
|
|
23
23
|
})),
|
package/src/cli/index.ts
CHANGED
|
@@ -133,10 +133,15 @@ function one(): Device {
|
|
|
133
133
|
return device ?? fail("name a device");
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
136
|
+
/** How a device is reached: through its console when `--console` asks, or when it has no management address. */
|
|
137
|
+
function optionsFor(device: Device): ApplyOptions {
|
|
138
|
+
return options.console || !device.connection.host ? { ...options, console: true } : options;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The adapter of a device, which must be able to use its console when the device is reached through it. */
|
|
137
142
|
function adapterOf(device: Device): DeviceAdapter {
|
|
138
143
|
const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
139
|
-
if (!
|
|
144
|
+
if (!optionsFor(device).console) return adapter;
|
|
140
145
|
|
|
141
146
|
if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
142
147
|
if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
|
|
@@ -225,7 +230,7 @@ function diffCommand(): void {
|
|
|
225
230
|
|
|
226
231
|
async function snapshotCommand(): Promise<void> {
|
|
227
232
|
for (const device of selected(true)) {
|
|
228
|
-
const text = await adapterOf(device).read(device, network.devices,
|
|
233
|
+
const text = await adapterOf(device).read(device, network.devices, optionsFor(device));
|
|
229
234
|
writeState(device, text);
|
|
230
235
|
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
231
236
|
}
|
|
@@ -248,7 +253,7 @@ async function applyCommand(): Promise<void> {
|
|
|
248
253
|
const adapter = adapterOf(device);
|
|
249
254
|
|
|
250
255
|
// The plan is made against what the device runs now, never against an old snapshot.
|
|
251
|
-
const live = await adapter.read(device, network.devices,
|
|
256
|
+
const live = await adapter.read(device, network.devices, optionsFor(device));
|
|
252
257
|
writeState(device, live);
|
|
253
258
|
|
|
254
259
|
const plan = adapter.plan(adapter.render(device), live, options);
|
|
@@ -261,9 +266,9 @@ async function applyCommand(): Promise<void> {
|
|
|
261
266
|
return;
|
|
262
267
|
}
|
|
263
268
|
|
|
264
|
-
await adapter.apply(device, network.devices, plan,
|
|
269
|
+
await adapter.apply(device, network.devices, plan, optionsFor(device));
|
|
265
270
|
|
|
266
|
-
writeState(device, await adapter.read(device, network.devices,
|
|
271
|
+
writeState(device, await adapter.read(device, network.devices, optionsFor(device)));
|
|
267
272
|
console.log("snapshot updated");
|
|
268
273
|
}
|
|
269
274
|
|
package/src/core/graph.ts
CHANGED
|
@@ -143,7 +143,11 @@ function describeDevice(device: Device): string[] {
|
|
|
143
143
|
return `${name}: ${list.join(", ")}${vrf}`;
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
return [
|
|
146
|
+
return [
|
|
147
|
+
`${device.platform} ${device.model}`,
|
|
148
|
+
device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
|
|
149
|
+
...addresses,
|
|
150
|
+
];
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
/** The VLANs a port or LAG carries, by id. */
|
package/src/core/validate.ts
CHANGED
|
@@ -79,6 +79,8 @@ function checkDevice(device: Device, report: Report): void {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
if (!device.users?.[device.connection.user]) report(`connection user '${device.connection.user}' is not one of its users`);
|
|
82
|
+
if (!device.connection.host && !device.connection.console)
|
|
83
|
+
report("connection has neither a host nor a console to reach the device through");
|
|
82
84
|
|
|
83
85
|
checkVlans(device, report);
|
|
84
86
|
checkAddresses(device, report, cidr, ip);
|
|
@@ -173,6 +175,21 @@ function checkInterfaces(device: Device, names: Names, report: Report, ip: Check
|
|
|
173
175
|
if (!members.length) report(`LAG ${name} has no member ports`);
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
if (iface.type === "bridge") {
|
|
179
|
+
if (iface.members.length < 2) report(`bridge ${name} joins fewer than two interfaces`);
|
|
180
|
+
|
|
181
|
+
for (const member of iface.members) {
|
|
182
|
+
const joined = device.interfaces?.[member];
|
|
183
|
+
if (joined?.type !== "vlan") report(`bridge ${name}: member ${member} is not a VLAN interface`);
|
|
184
|
+
else if (joined.addresses?.length) report(`bridge ${name}: member ${member} has an address of its own`);
|
|
185
|
+
|
|
186
|
+
const others = Object.entries(device.interfaces ?? {}).filter(
|
|
187
|
+
([other, settings]) => other !== name && settings.type === "bridge" && settings.members.includes(member)
|
|
188
|
+
);
|
|
189
|
+
if (others.length) report(`bridge ${name}: member ${member} is also in bridge ${others[0]![0]}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
176
193
|
if (iface.type === "wireguard") {
|
|
177
194
|
for (const peer of iface.peers ?? []) {
|
|
178
195
|
for (const address of peer.allowed_addresses) {
|
package/src/schema/device.ts
CHANGED
|
@@ -7,8 +7,8 @@ import type { Certificate, DhcpRelay, DhcpServer, FlowExport, Management, System
|
|
|
7
7
|
|
|
8
8
|
/** How the framework reaches the device. */
|
|
9
9
|
export type Connection<U extends string> = {
|
|
10
|
-
/** Management address. */
|
|
11
|
-
host
|
|
10
|
+
/** Management address. Absent for a device reached only through its console, `console`. */
|
|
11
|
+
host?: IP;
|
|
12
12
|
/** One of the device's `users`. Its password is the login. */
|
|
13
13
|
user: U;
|
|
14
14
|
/** SSH port. 22 when absent. */
|
package/src/schema/interfaces.ts
CHANGED
|
@@ -119,6 +119,10 @@ export type VlanInterface<V extends string, F extends string> = Base<F> & {
|
|
|
119
119
|
type: "vlan";
|
|
120
120
|
/** The VLAN it is on. */
|
|
121
121
|
vlan: V;
|
|
122
|
+
/**
|
|
123
|
+
* A MAC address of its own, instead of the one it shares with the device's other VLAN interfaces. A gateway in front of a transparent firewall needs one: frames to it come back on another VLAN, where the bridge would otherwise take a shared MAC for its own and keep them.
|
|
124
|
+
*/
|
|
125
|
+
mac?: string;
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
/** An interface that is always up and is on no link, for addresses that must not depend on one. */
|
|
@@ -187,6 +191,15 @@ export type WireGuard<F extends string> = Base<F> & {
|
|
|
187
191
|
peers?: readonly WireGuardPeer[];
|
|
188
192
|
};
|
|
189
193
|
|
|
194
|
+
/**
|
|
195
|
+
* A layer 2 bridge between VLAN interfaces, each keeping its own VLAN: a frame that comes in on one member leaves another with that member's tag. A firewall filters what crosses it, by the rules of its forward chain between the members, without routing it.
|
|
196
|
+
*/
|
|
197
|
+
export type Bridge<F extends string> = Base<F> & {
|
|
198
|
+
type: "bridge";
|
|
199
|
+
/** The VLAN interfaces it joins, by name. Each has no address of its own. */
|
|
200
|
+
members: readonly string[];
|
|
201
|
+
};
|
|
202
|
+
|
|
190
203
|
/** Any interface that is not a physical port. */
|
|
191
204
|
export type Interface<V extends string, F extends string> =
|
|
192
|
-
VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F>;
|
|
205
|
+
VlanInterface<V, F> | Loopback<F> | Lag<V, F> | Gre<F> | Vxlan<F> | WireGuard<F> | Bridge<F>;
|
package/src/transport/ssh.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SSH sessions to devices. Adapters choose how to talk to their platform; this holds what they share.
|
|
2
2
|
|
|
3
3
|
import { Client, type ClientChannel } from "ssh2";
|
|
4
|
+
import type { Device } from "../schema";
|
|
4
5
|
|
|
5
6
|
export type Login = {
|
|
6
7
|
host: string;
|
|
@@ -11,6 +12,12 @@ export type Login = {
|
|
|
11
12
|
hostKeys?: string[];
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
/** The address SSH reaches a device at. A device without one is reached only through its console. */
|
|
16
|
+
export function managementHost(device: Device): string {
|
|
17
|
+
if (!device.connection.host) throw new Error(`${device.name} has no management address; it is reached through its console`);
|
|
18
|
+
return device.connection.host;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** Open a session. */
|
|
15
22
|
export function connect(login: Login): Promise<Client> {
|
|
16
23
|
return new Promise((resolve, reject) => {
|