@takodotid/circuit 0.26.2 → 0.27.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/cli/index.js +252 -14
- package/dist/types/adapters/devices/fortios/index.d.ts +6 -2
- package/dist/types/adapters/devices/fortios/plan.d.ts +2 -0
- package/dist/types/adapters/devices/types.d.ts +7 -1
- package/dist/types/schema/device.d.ts +25 -1
- package/dist/types/transport/console.d.ts +9 -0
- package/package.json +1 -1
- package/src/adapters/devices/fortios/TRAPS.md +2 -1
- package/src/adapters/devices/fortios/index.ts +137 -13
- package/src/adapters/devices/fortios/plan.ts +40 -1
- package/src/adapters/devices/types.ts +8 -1
- package/src/cli/index.ts +14 -4
- package/src/schema/device.ts +27 -1
- package/src/transport/console.ts +127 -0
package/dist/cli/index.js
CHANGED
|
@@ -110,6 +110,11 @@ var MARKER = /<secret:([A-Za-z0-9_./:-]+)>/g;
|
|
|
110
110
|
var substitute = (text) => text.replace(MARKER, (_, reference) => valueOf(reference));
|
|
111
111
|
var valueOfReference = valueOf;
|
|
112
112
|
|
|
113
|
+
// src/transport/console.ts
|
|
114
|
+
import { spawn } from "node:child_process";
|
|
115
|
+
import { Duplex } from "node:stream";
|
|
116
|
+
import { Client as Client2 } from "ssh2";
|
|
117
|
+
|
|
113
118
|
// src/transport/ssh.ts
|
|
114
119
|
import { Client } from "ssh2";
|
|
115
120
|
function connect(login) {
|
|
@@ -202,6 +207,107 @@ function upload(client, path, content) {
|
|
|
202
207
|
});
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
// src/transport/console.ts
|
|
211
|
+
var RELAY = `import fcntl, os, select, sys, termios
|
|
212
|
+
|
|
213
|
+
port, baud = sys.argv[1], int(sys.argv[2])
|
|
214
|
+
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
218
|
+
except OSError:
|
|
219
|
+
sys.stderr.write("busy: another session holds " + port + "\\n")
|
|
220
|
+
sys.exit(3)
|
|
221
|
+
|
|
222
|
+
attrs = termios.tcgetattr(fd)
|
|
223
|
+
attrs[0] = attrs[1] = attrs[3] = 0
|
|
224
|
+
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
225
|
+
attrs[4] = attrs[5] = getattr(termios, "B%d" % baud)
|
|
226
|
+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
227
|
+
termios.tcflush(fd, termios.TCIOFLUSH)
|
|
228
|
+
|
|
229
|
+
sys.stderr.write("ready\\n")
|
|
230
|
+
sys.stderr.flush()
|
|
231
|
+
|
|
232
|
+
while True:
|
|
233
|
+
ready, _, _ = select.select([0, fd], [], [])
|
|
234
|
+
|
|
235
|
+
if 0 in ready:
|
|
236
|
+
data = os.read(0, 4096)
|
|
237
|
+
if not data:
|
|
238
|
+
break
|
|
239
|
+
while data:
|
|
240
|
+
try:
|
|
241
|
+
data = data[os.write(fd, data):]
|
|
242
|
+
except BlockingIOError:
|
|
243
|
+
select.select([], [fd], [])
|
|
244
|
+
|
|
245
|
+
if fd in ready:
|
|
246
|
+
try:
|
|
247
|
+
data = os.read(fd, 4096)
|
|
248
|
+
except BlockingIOError:
|
|
249
|
+
continue
|
|
250
|
+
if data:
|
|
251
|
+
os.write(1, data)
|
|
252
|
+
`;
|
|
253
|
+
var RELAY_PATH = "/tmp/circuit-console.py";
|
|
254
|
+
var PORT = /^\/dev\/[\w./:+-]+$/;
|
|
255
|
+
function connectJump(jump) {
|
|
256
|
+
return new Promise((done, fail) => {
|
|
257
|
+
const client = new Client2;
|
|
258
|
+
const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60000 };
|
|
259
|
+
client.on("ready", () => done(client)).on("error", fail);
|
|
260
|
+
if (!jump.cloudflared) {
|
|
261
|
+
client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
|
|
265
|
+
tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
|
|
266
|
+
client.on("close", () => tunnel.kill());
|
|
267
|
+
client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function openConsole(console2) {
|
|
271
|
+
if (!PORT.test(console2.port))
|
|
272
|
+
throw new Error(`console port ${console2.port} is not a path under /dev`);
|
|
273
|
+
if (!Number.isInteger(console2.baud) || console2.baud <= 0)
|
|
274
|
+
throw new Error(`console baud ${console2.baud} is not a line speed`);
|
|
275
|
+
const client = await connectJump(console2.jump);
|
|
276
|
+
try {
|
|
277
|
+
const encoded = Buffer.from(RELAY).toString("base64");
|
|
278
|
+
await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30000);
|
|
279
|
+
const channel = await new Promise((done, fail) => {
|
|
280
|
+
client.exec(`python3 ${RELAY_PATH} ${console2.port} ${console2.baud}`, (error, stream) => error ? fail(error) : done(stream));
|
|
281
|
+
});
|
|
282
|
+
await new Promise((done, fail) => {
|
|
283
|
+
let said = "";
|
|
284
|
+
const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30000);
|
|
285
|
+
channel.stderr.on("data", (chunk) => {
|
|
286
|
+
said += chunk.toString("utf8");
|
|
287
|
+
if (said.includes(`ready
|
|
288
|
+
`)) {
|
|
289
|
+
clearTimeout(limit);
|
|
290
|
+
done();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
channel.on("close", () => {
|
|
294
|
+
clearTimeout(limit);
|
|
295
|
+
fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
channel,
|
|
300
|
+
close: () => {
|
|
301
|
+
channel.end();
|
|
302
|
+
client.end();
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
client.end();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
205
311
|
// src/adapters/devices/fortios/names.ts
|
|
206
312
|
function nameOf(device, name) {
|
|
207
313
|
if (device.interfaces && name in device.interfaces)
|
|
@@ -1134,7 +1240,9 @@ var TABLES = {
|
|
|
1134
1240
|
"firewall policy6": ORDERED,
|
|
1135
1241
|
"firewall local-in-policy": ORDERED,
|
|
1136
1242
|
"firewall local-in-policy6": ORDERED,
|
|
1137
|
-
"firewall central-snat-map": ORDERED
|
|
1243
|
+
"firewall central-snat-map": ORDERED,
|
|
1244
|
+
"router static": { distinct: true },
|
|
1245
|
+
"router static6": { distinct: true }
|
|
1138
1246
|
};
|
|
1139
1247
|
var IGNORED = new Set(["uuid", "snmp-index"]);
|
|
1140
1248
|
var IGNORED_IN = { "system global": ["cfg-save", "cfg-revert-timeout", "alias"] };
|
|
@@ -1271,7 +1379,7 @@ function compare(path, want, have, table, top, context) {
|
|
|
1271
1379
|
}
|
|
1272
1380
|
for (const [header, child] of want.blocks)
|
|
1273
1381
|
nested(header, child, have.blocks.get(header) ?? new Block);
|
|
1274
|
-
const entries = table?.byContent ? byContent(path, want, have) : byId(path, want, have, table, context);
|
|
1382
|
+
const entries = table?.byContent ? byContent(path, want, have) : table?.distinct ? distinctById(path, want, have, table, context) : byId(path, want, have, table, context);
|
|
1275
1383
|
send.push(...entries.send);
|
|
1276
1384
|
show.push(...entries.show);
|
|
1277
1385
|
if (top) {
|
|
@@ -1320,6 +1428,30 @@ function byId(path, want, have, table, context) {
|
|
|
1320
1428
|
}
|
|
1321
1429
|
return { send, show, removals, removed };
|
|
1322
1430
|
}
|
|
1431
|
+
function distinctById(path, want, have, table, context) {
|
|
1432
|
+
const deletes = [];
|
|
1433
|
+
const creates = [];
|
|
1434
|
+
const show = [];
|
|
1435
|
+
for (const [key, entry] of want.entries) {
|
|
1436
|
+
const old = have.entries.get(key);
|
|
1437
|
+
const where = [...path, bare(entry.id)];
|
|
1438
|
+
if (old) {
|
|
1439
|
+
if (!compare(where, entry.block, old.block, undefined, false, context).send.length)
|
|
1440
|
+
continue;
|
|
1441
|
+
deletes.push(`delete ${old.id}`);
|
|
1442
|
+
show.push(...shown("-", where, old.block));
|
|
1443
|
+
}
|
|
1444
|
+
creates.push(...creation(entry, table));
|
|
1445
|
+
show.push(...shown("+", where, entry.block));
|
|
1446
|
+
}
|
|
1447
|
+
for (const [key, entry] of have.entries) {
|
|
1448
|
+
if (want.entries.has(key) || isFactory(table, entry))
|
|
1449
|
+
continue;
|
|
1450
|
+
deletes.push(`delete ${entry.id}`);
|
|
1451
|
+
show.push(...shown("-", [...path, bare(entry.id)], entry.block));
|
|
1452
|
+
}
|
|
1453
|
+
return { send: [...deletes, ...creates], show, removals: [], removed: [] };
|
|
1454
|
+
}
|
|
1323
1455
|
function moves(want, have) {
|
|
1324
1456
|
const wanted = [...want.entries.keys()];
|
|
1325
1457
|
const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
|
|
@@ -2356,7 +2488,93 @@ async function login(device) {
|
|
|
2356
2488
|
password: resolve(user.password)
|
|
2357
2489
|
});
|
|
2358
2490
|
}
|
|
2359
|
-
|
|
2491
|
+
var CONSOLE_STATES = [
|
|
2492
|
+
["login", /login: $/],
|
|
2493
|
+
["password", /Password: $/],
|
|
2494
|
+
["banner", BANNER2],
|
|
2495
|
+
["pager", /--More-- $/],
|
|
2496
|
+
["context", /\([^)\n]*\) [#$] $/],
|
|
2497
|
+
["prompt", /[#$] $/]
|
|
2498
|
+
];
|
|
2499
|
+
function prompted(channel, write, timeout = 30000) {
|
|
2500
|
+
return new Promise((done, fail) => {
|
|
2501
|
+
let text = "";
|
|
2502
|
+
const stop = () => {
|
|
2503
|
+
clearTimeout(limit);
|
|
2504
|
+
channel.removeListener("data", onData);
|
|
2505
|
+
};
|
|
2506
|
+
const onData = (chunk) => {
|
|
2507
|
+
text += chunk.toString("utf8").replace(/\r/g, "");
|
|
2508
|
+
const state = CONSOLE_STATES.find(([, pattern]) => pattern.test(text));
|
|
2509
|
+
if (!state)
|
|
2510
|
+
return;
|
|
2511
|
+
stop();
|
|
2512
|
+
done(state[0]);
|
|
2513
|
+
};
|
|
2514
|
+
const limit = setTimeout(() => {
|
|
2515
|
+
stop();
|
|
2516
|
+
fail(new Error(`the console did not answer: ${text.slice(-200)}`));
|
|
2517
|
+
}, timeout);
|
|
2518
|
+
channel.on("data", onData);
|
|
2519
|
+
channel.write(write);
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
async function consoleLogin(channel, user, password) {
|
|
2523
|
+
const toward = {
|
|
2524
|
+
password: "\r",
|
|
2525
|
+
banner: "a",
|
|
2526
|
+
pager: "q",
|
|
2527
|
+
context: "end\r",
|
|
2528
|
+
prompt: "exit\r"
|
|
2529
|
+
};
|
|
2530
|
+
let state = await prompted(channel, "\r");
|
|
2531
|
+
for (let step = 0;state !== "login"; step++) {
|
|
2532
|
+
if (step >= 10)
|
|
2533
|
+
throw new Error(`the console never reached a login prompt; it is at ${state}`);
|
|
2534
|
+
state = await prompted(channel, toward[state]);
|
|
2535
|
+
}
|
|
2536
|
+
if (await prompted(channel, `${user}\r`) !== "password")
|
|
2537
|
+
throw new Error("the console did not ask for a password");
|
|
2538
|
+
const answer = await prompted(channel, `${password}\r`, 60000);
|
|
2539
|
+
if (answer !== "prompt")
|
|
2540
|
+
throw new Error(`the console refused the login of ${user}`);
|
|
2541
|
+
return new Cli(channel, "\r");
|
|
2542
|
+
}
|
|
2543
|
+
async function consoleLogout(cli) {
|
|
2544
|
+
await leave(cli).catch(() => {
|
|
2545
|
+
return;
|
|
2546
|
+
});
|
|
2547
|
+
await cli.run("exit", 1e4).catch(() => {
|
|
2548
|
+
return;
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
function consoleOf(device) {
|
|
2552
|
+
const console2 = device.connection.console;
|
|
2553
|
+
if (!console2)
|
|
2554
|
+
throw new Error(`${device.name}: no connection.console`);
|
|
2555
|
+
const user = device.users?.[device.connection.user];
|
|
2556
|
+
if (!user)
|
|
2557
|
+
throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
2558
|
+
return { console: console2, user: device.connection.user, password: resolve(user.password) };
|
|
2559
|
+
}
|
|
2560
|
+
async function consoleSession(device, work) {
|
|
2561
|
+
const { console: console2, user, password } = consoleOf(device);
|
|
2562
|
+
const opened = await openConsole(console2);
|
|
2563
|
+
try {
|
|
2564
|
+
const cli = await consoleLogin(opened.channel, user, password);
|
|
2565
|
+
const relogin = async () => {
|
|
2566
|
+
await consoleLogin(opened.channel, user, password);
|
|
2567
|
+
};
|
|
2568
|
+
const result = await work(cli, relogin);
|
|
2569
|
+
await consoleLogout(cli);
|
|
2570
|
+
return result;
|
|
2571
|
+
} finally {
|
|
2572
|
+
opened.close();
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
async function session(device, work, options = {}) {
|
|
2576
|
+
if (options.console)
|
|
2577
|
+
return consoleSession(device, (cli) => work(cli));
|
|
2360
2578
|
const client = await login(device);
|
|
2361
2579
|
try {
|
|
2362
2580
|
const channel = await shell(client, 4000);
|
|
@@ -2412,8 +2630,8 @@ async function readConfig(cli, device) {
|
|
|
2412
2630
|
throw new Error(`${device.name} returned no configuration`);
|
|
2413
2631
|
return scrub(clean(text));
|
|
2414
2632
|
}
|
|
2415
|
-
function read(device) {
|
|
2416
|
-
return session(device, (cli) => readConfig(cli, device));
|
|
2633
|
+
function read(device, _network = [], options = {}) {
|
|
2634
|
+
return session(device, (cli) => readConfig(cli, device), options);
|
|
2417
2635
|
}
|
|
2418
2636
|
function withSecrets(line) {
|
|
2419
2637
|
const key = line.startsWith("set private-key ");
|
|
@@ -2490,7 +2708,7 @@ async function configure(cli, plan, guard) {
|
|
|
2490
2708
|
class Reloaded extends Error {
|
|
2491
2709
|
}
|
|
2492
2710
|
var RELOAD = { attempts: 20, interval: 15000 };
|
|
2493
|
-
async function settle(device) {
|
|
2711
|
+
async function settle(device, options) {
|
|
2494
2712
|
for (let attempt = 0;attempt < RELOAD.attempts; attempt++) {
|
|
2495
2713
|
await new Promise((done) => setTimeout(done, RELOAD.interval));
|
|
2496
2714
|
const settled = await session(device, async (cli) => {
|
|
@@ -2498,18 +2716,26 @@ async function settle(device) {
|
|
|
2498
2716
|
return false;
|
|
2499
2717
|
await saving(cli, "automatic");
|
|
2500
2718
|
return true;
|
|
2501
|
-
}).catch(() => false);
|
|
2719
|
+
}, options).catch(() => false);
|
|
2502
2720
|
if (settled)
|
|
2503
2721
|
return "the device is back and saves changes on its own again";
|
|
2504
2722
|
}
|
|
2505
2723
|
return "the device did not come back in time and still reverts every change; set `cfg-save automatic` under `config system global`";
|
|
2506
2724
|
}
|
|
2507
2725
|
async function apply(device, _network, plan, options) {
|
|
2726
|
+
const seconds = options.rollback * 60;
|
|
2508
2727
|
try {
|
|
2728
|
+
if (options.console) {
|
|
2729
|
+
await consoleSession(device, async (cli, relogin) => {
|
|
2730
|
+
await checkVersion(cli, device);
|
|
2731
|
+
await configure(cli, plan, { seconds, prove: relogin });
|
|
2732
|
+
});
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2509
2735
|
await session(device, async (cli) => {
|
|
2510
2736
|
await checkVersion(cli, device);
|
|
2511
2737
|
await configure(cli, plan, {
|
|
2512
|
-
seconds
|
|
2738
|
+
seconds,
|
|
2513
2739
|
prove: async () => {
|
|
2514
2740
|
const proof = await login(device);
|
|
2515
2741
|
proof.end();
|
|
@@ -2520,7 +2746,7 @@ async function apply(device, _network, plan, options) {
|
|
|
2520
2746
|
if (!(error instanceof Reloaded))
|
|
2521
2747
|
throw error;
|
|
2522
2748
|
throw new Error(`${error.message}
|
|
2523
|
-
${await settle(device)}`);
|
|
2749
|
+
${await settle(device, options)}`);
|
|
2524
2750
|
}
|
|
2525
2751
|
}
|
|
2526
2752
|
function fillLines(set, family, prefixes) {
|
|
@@ -2920,6 +3146,7 @@ function serviceGaps(device) {
|
|
|
2920
3146
|
}
|
|
2921
3147
|
var fortios = {
|
|
2922
3148
|
extension: ".conf",
|
|
3149
|
+
console: true,
|
|
2923
3150
|
unsupported,
|
|
2924
3151
|
render,
|
|
2925
3152
|
read,
|
|
@@ -8661,6 +8888,7 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
8661
8888
|
apply <device> read the device, plan against what it runs, and send with --confirm
|
|
8662
8889
|
--secrets also send every secret, to rotate one
|
|
8663
8890
|
--rollback=N minutes before a device that can restore itself does, default 10
|
|
8891
|
+
--console through the device's serial console on its jump host, instead of SSH; also for snapshot
|
|
8664
8892
|
refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
|
|
8665
8893
|
wireguard <device> <interface> <peer>
|
|
8666
8894
|
a client config for one peer, its private key left for the peer to fill
|
|
@@ -8721,7 +8949,8 @@ var names = rest.filter((arg) => !arg.startsWith("--"));
|
|
|
8721
8949
|
var confirmed = flags.includes("--confirm");
|
|
8722
8950
|
var options = {
|
|
8723
8951
|
secrets: flags.includes("--secrets"),
|
|
8724
|
-
rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10)
|
|
8952
|
+
rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
|
|
8953
|
+
...flags.includes("--console") ? { console: true } : {}
|
|
8725
8954
|
};
|
|
8726
8955
|
function fail(message) {
|
|
8727
8956
|
console.error(message);
|
|
@@ -8738,7 +8967,16 @@ function one() {
|
|
|
8738
8967
|
const [device] = selected(false);
|
|
8739
8968
|
return device ?? fail("name a device");
|
|
8740
8969
|
}
|
|
8741
|
-
|
|
8970
|
+
function adapterOf(device) {
|
|
8971
|
+
const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
8972
|
+
if (!options.console)
|
|
8973
|
+
return adapter;
|
|
8974
|
+
if (!adapter.console)
|
|
8975
|
+
fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
8976
|
+
if (!device.connection.console)
|
|
8977
|
+
fail(`${device.name}: --console needs connection.console`);
|
|
8978
|
+
return adapter;
|
|
8979
|
+
}
|
|
8742
8980
|
var generated = join4(root2, ".circuit");
|
|
8743
8981
|
var stateDirectory = join4(generated, "state");
|
|
8744
8982
|
var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
|
|
@@ -8805,7 +9043,7 @@ function diffCommand() {
|
|
|
8805
9043
|
}
|
|
8806
9044
|
async function snapshotCommand() {
|
|
8807
9045
|
for (const device of selected(true)) {
|
|
8808
|
-
const text = await adapterOf(device).read(device, network3.devices);
|
|
9046
|
+
const text = await adapterOf(device).read(device, network3.devices, options);
|
|
8809
9047
|
writeState(device, text);
|
|
8810
9048
|
console.log(`${device.name}: ${text.split(`
|
|
8811
9049
|
`).length} lines`);
|
|
@@ -8824,7 +9062,7 @@ async function applyCommand() {
|
|
|
8824
9062
|
const device = one();
|
|
8825
9063
|
guard([device]);
|
|
8826
9064
|
const adapter = adapterOf(device);
|
|
8827
|
-
const live = await adapter.read(device, network3.devices);
|
|
9065
|
+
const live = await adapter.read(device, network3.devices, options);
|
|
8828
9066
|
writeState(device, live);
|
|
8829
9067
|
const plan = adapter.plan(adapter.render(device), live, options);
|
|
8830
9068
|
if (!show(device, plan))
|
|
@@ -8838,7 +9076,7 @@ Nothing sent. Re-run with --confirm.`);
|
|
|
8838
9076
|
return;
|
|
8839
9077
|
}
|
|
8840
9078
|
await adapter.apply(device, network3.devices, plan, options);
|
|
8841
|
-
writeState(device, await adapter.read(device, network3.devices));
|
|
9079
|
+
writeState(device, await adapter.read(device, network3.devices, options));
|
|
8842
9080
|
console.log("snapshot updated");
|
|
8843
9081
|
}
|
|
8844
9082
|
async function refreshCommand() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Client } from "ssh2";
|
|
2
2
|
import type { Device, Family } from "../../../schema";
|
|
3
|
-
import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
|
|
3
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
4
4
|
/** What a session reads and writes: an SSH shell, or anything else that streams the CLI. */
|
|
5
5
|
export type Channel = {
|
|
6
6
|
write(data: string): unknown;
|
|
@@ -26,13 +26,17 @@ export declare class Cli {
|
|
|
26
26
|
private collect;
|
|
27
27
|
}
|
|
28
28
|
export declare function login(device: Device): Promise<Client>;
|
|
29
|
+
/**
|
|
30
|
+
* Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
|
|
31
|
+
*/
|
|
32
|
+
export declare function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli>;
|
|
29
33
|
/** Refuse a device on a release this adapter was not proved on, before anything is read or sent. */
|
|
30
34
|
export declare function checkVersion(cli: Cli, device: Device): Promise<void>;
|
|
31
35
|
/** Secrets the device prints: a password or an SNMPv3 key as a cipher, a certificate's key encrypted, and the SNMP community in clear. None leave the read. */
|
|
32
36
|
export declare function scrub(text: string): string;
|
|
33
37
|
/** Every section Circuit manages, as the device holds it, credentials scrubbed. */
|
|
34
38
|
export declare function readConfig(cli: Cli, device: Device): Promise<string>;
|
|
35
|
-
export declare function read(device: Device): Promise<string>;
|
|
39
|
+
export declare function read(device: Device, _network?: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
36
40
|
/**
|
|
37
41
|
* A line with each secret's value in place, escaped inside its quotes. A certificate's key from a variable is PEM encoded as one line of base64, and is sent as the PEM it holds. A file saved with Windows line endings is sent with plain ones: the CLI takes each carriage return as a line of its own, and refuses the key, `Invalid private key`. A `?`, which the CLI takes as a call for help, is typed literally.
|
|
38
42
|
*/
|
|
@@ -12,6 +12,8 @@ type Table = {
|
|
|
12
12
|
created?: (entry: Block) => readonly string[];
|
|
13
13
|
/** Settings an entry keeps for life. A change deletes the entry and creates it again. */
|
|
14
14
|
fixed?: readonly string[];
|
|
15
|
+
/** The device refuses an entry that holds the same as another, `Duplicated entry`, even for a moment while entries are renumbered. */
|
|
16
|
+
distinct?: true;
|
|
15
17
|
};
|
|
16
18
|
/** Tables whose entries need more than a plain comparison, by section. */
|
|
17
19
|
export declare const TABLES: Record<string, Table>;
|
|
@@ -15,16 +15,22 @@ export type ApplyOptions = {
|
|
|
15
15
|
secrets: boolean;
|
|
16
16
|
/** Minutes before a platform that can restore itself does so, if the run does not prove access survived. */
|
|
17
17
|
rollback: number;
|
|
18
|
+
/** Reach the device through its serial console, `connection.console`, instead of SSH to its management address. */
|
|
19
|
+
console?: boolean;
|
|
18
20
|
};
|
|
21
|
+
/** How a device is read. */
|
|
22
|
+
export type ReadOptions = Pick<ApplyOptions, "console">;
|
|
19
23
|
export type DeviceAdapter = {
|
|
20
24
|
/** File extension of a saved snapshot, for syntax highlighting. */
|
|
21
25
|
extension: string;
|
|
22
26
|
/** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
|
|
23
27
|
unsupported(device: Device): string[];
|
|
28
|
+
/** The platform can be read and applied through a serial console, `--console`. */
|
|
29
|
+
console?: true;
|
|
24
30
|
/** The whole configuration the device should run. Secrets appear as markers, never values. */
|
|
25
31
|
render(device: Device): string;
|
|
26
32
|
/** What the device runs now, secrets removed, in the form `render` produces where the platform allows. */
|
|
27
|
-
read(device: Device, network: readonly Device[]): Promise<string>;
|
|
33
|
+
read(device: Device, network: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
28
34
|
/** What it takes to go from `current` to `desired`. */
|
|
29
35
|
plan(desired: string, current: string, options: ApplyOptions): Plan;
|
|
30
36
|
/** Replace the contents of a prefix set fetched from a registry. Absent on a platform that cannot hold one. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IP } from "./common";
|
|
1
|
+
import type { IP, Secret } from "./common";
|
|
2
2
|
import type { AclRule, Firewall } from "./firewall";
|
|
3
3
|
import type { Interface, Port, Vlan } from "./interfaces";
|
|
4
4
|
import type { PrefixSet, RoutePolicy } from "./policy";
|
|
@@ -12,6 +12,30 @@ export type Connection<U extends string> = {
|
|
|
12
12
|
user: U;
|
|
13
13
|
/** SSH port. 22 when absent. */
|
|
14
14
|
port?: number;
|
|
15
|
+
/** The device's serial console, for `--console`: the way in before the device has a management address, or when its management network is down. */
|
|
16
|
+
console?: Console;
|
|
17
|
+
};
|
|
18
|
+
/** A computer with SSH that holds the serial consoles of devices, such as a laptop beside the rack. It runs Python 3. */
|
|
19
|
+
export type Jump = {
|
|
20
|
+
/** Its address or name. */
|
|
21
|
+
host: string;
|
|
22
|
+
/** The user Circuit logs in as. It must be allowed to open the serial ports. */
|
|
23
|
+
user: string;
|
|
24
|
+
/** The user's password. */
|
|
25
|
+
password: Secret;
|
|
26
|
+
/** SSH port. 22 when absent. */
|
|
27
|
+
port?: number;
|
|
28
|
+
/** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
|
|
29
|
+
cloudflared?: true;
|
|
30
|
+
};
|
|
31
|
+
/** A serial console, reached through a jump host. */
|
|
32
|
+
export type Console = {
|
|
33
|
+
/** The jump host the console cable is plugged into. */
|
|
34
|
+
jump: Jump;
|
|
35
|
+
/** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
|
|
36
|
+
port: string;
|
|
37
|
+
/** Line speed in bits per second, such as 9600 or 115200. */
|
|
38
|
+
baud: number;
|
|
15
39
|
};
|
|
16
40
|
/** A separate routing table, with the interfaces that route in it. */
|
|
17
41
|
export type Vrf = {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ClientChannel } from "ssh2";
|
|
2
|
+
import type { Console } from "../schema";
|
|
3
|
+
/** An open console: the stream to the device's CLI, and how to let it go. */
|
|
4
|
+
export type ConsoleSession = {
|
|
5
|
+
channel: ClientChannel;
|
|
6
|
+
close: () => void;
|
|
7
|
+
};
|
|
8
|
+
/** Open a device's serial console through its jump host. Fails when another session holds the port. */
|
|
9
|
+
export declare function openConsole(console: Console): Promise<ConsoleSession>;
|
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
|
|
|
19
19
|
- Global `alias` is set again at every start, whatever was saved.
|
|
20
20
|
- `log memory setting` prints its `status` whether on or off, and `unset status` turns memory logging on.
|
|
21
21
|
- Two interfaces that are up cannot have overlapping subnets, `Subnets overlap between ...`, so an address moving from one to another must leave the first before it joins the second.
|
|
22
|
-
- A static route identical to another is refused, `Duplicated entry
|
|
22
|
+
- A static route identical to another is refused, `Duplicated entry`, even for a moment: editing route 1 into what route 2 holds fails before route 2 is changed. A plan deletes every route that changes or goes before it creates any. An IPv4 route's destination is not printed when it is the default route; neither is IPv6's.
|
|
23
23
|
- A deny policy is given `logtraffic disable`, and its action is not printed, since deny is the default. `send-deny-packet` exists only while a policy denies: set before `action deny` or unset after `action accept`, it is `command parse error`. So the render writes `action deny` before `send-deny-packet`, and a plan removes the settings that go before it sets the ones that come: `send-deny-packet` is unset while the policy still denies, and set only once it does.
|
|
24
24
|
- `show` prints `unset` in two places: an ICMP service's `unset icmptype`, and a prefix list rule's `unset ge` and `unset le` for a bound it does not have.
|
|
25
25
|
- An SNMPv3 user authenticates with MD5 or SHA1 only, and has no list of sources: only a community has hosts. Circuit decides who reaches every service with local-in policies instead.
|
|
@@ -38,3 +38,4 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
|
|
|
38
38
|
- There is no WireGuard and no RPKI, `command parse error`. A route map has no large community and a BGP neighbor no role: neither is among their settings. SNMPv3 offers no SHA256.
|
|
39
39
|
- `execute backup config flash <comment>` keeps a revision on the device, and `execute restore config flash <id>` restores one.
|
|
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
|
+
- 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.
|
|
@@ -6,9 +6,10 @@ import type { Client } from "ssh2";
|
|
|
6
6
|
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
|
+
import { openConsole } from "../../../transport/console";
|
|
9
10
|
import { connect, shell } from "../../../transport/ssh";
|
|
10
11
|
import { portsOf, spell } from "../catalog";
|
|
11
|
-
import type { ApplyOptions, DeviceAdapter, Plan } from "../types";
|
|
12
|
+
import type { ApplyOptions, DeviceAdapter, Plan, ReadOptions } from "../types";
|
|
12
13
|
import { acceptsBeforeDrop, familiesOf, isImplied, serviceOf } from "./firewall";
|
|
13
14
|
import { holderOf, interfaceFor, network, settingsOf, subnet } from "./names";
|
|
14
15
|
import { clean, plan } from "./plan";
|
|
@@ -147,8 +148,115 @@ export async function login(device: Device): Promise<Client> {
|
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
151
|
+
/** What a console shows when it waits for something, in the order they are checked. */
|
|
152
|
+
const CONSOLE_STATES = [
|
|
153
|
+
["login", /login: $/],
|
|
154
|
+
["password", /Password: $/],
|
|
155
|
+
["banner", BANNER],
|
|
156
|
+
["pager", /--More-- $/],
|
|
157
|
+
["context", /\([^)\n]*\) [#$] $/],
|
|
158
|
+
["prompt", /[#$] $/],
|
|
159
|
+
] as const;
|
|
160
|
+
|
|
161
|
+
type ConsoleState = (typeof CONSOLE_STATES)[number][0];
|
|
162
|
+
|
|
163
|
+
/** Write to a console, then wait until it shows one of its states. */
|
|
164
|
+
function prompted(channel: Channel, write: string, timeout = 30_000): Promise<ConsoleState> {
|
|
165
|
+
return new Promise((done, fail) => {
|
|
166
|
+
let text = "";
|
|
167
|
+
|
|
168
|
+
const stop = () => {
|
|
169
|
+
clearTimeout(limit);
|
|
170
|
+
channel.removeListener("data", onData);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const onData = (chunk: Buffer) => {
|
|
174
|
+
text += chunk.toString("utf8").replace(/\r/g, "");
|
|
175
|
+
|
|
176
|
+
const state = CONSOLE_STATES.find(([, pattern]) => pattern.test(text));
|
|
177
|
+
if (!state) return;
|
|
178
|
+
|
|
179
|
+
stop();
|
|
180
|
+
done(state[0]);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const limit = setTimeout(() => {
|
|
184
|
+
stop();
|
|
185
|
+
fail(new Error(`the console did not answer: ${text.slice(-200)}`));
|
|
186
|
+
}, timeout);
|
|
187
|
+
|
|
188
|
+
channel.on("data", onData);
|
|
189
|
+
channel.write(write);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Log in on a serial console, whatever state the last session left it in: a pager is quit, a `config` level left, a session someone left open logged out, so every session starts from a fresh login. The CLI ends a line at a carriage return.
|
|
195
|
+
*/
|
|
196
|
+
export async function consoleLogin(channel: Channel, user: string, password: string): Promise<Cli> {
|
|
197
|
+
// What to send in each state to get closer to a login prompt.
|
|
198
|
+
const toward: Record<Exclude<ConsoleState, "login">, string> = {
|
|
199
|
+
password: "\r",
|
|
200
|
+
banner: "a",
|
|
201
|
+
pager: "q",
|
|
202
|
+
context: "end\r",
|
|
203
|
+
prompt: "exit\r",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let state = await prompted(channel, "\r");
|
|
207
|
+
for (let step = 0; state !== "login"; step++) {
|
|
208
|
+
if (step >= 10) throw new Error(`the console never reached a login prompt; it is at ${state}`);
|
|
209
|
+
state = await prompted(channel, toward[state]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if ((await prompted(channel, `${user}\r`)) !== "password") throw new Error("the console did not ask for a password");
|
|
213
|
+
|
|
214
|
+
const answer = await prompted(channel, `${password}\r`, 60_000);
|
|
215
|
+
if (answer !== "prompt") throw new Error(`the console refused the login of ${user}`);
|
|
216
|
+
|
|
217
|
+
return new Cli(channel, "\r");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Log out of a console, so no session is left open on it. */
|
|
221
|
+
async function consoleLogout(cli: Cli): Promise<void> {
|
|
222
|
+
await leave(cli).catch(() => undefined);
|
|
223
|
+
await cli.run("exit", 10_000).catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The device's console and its login. */
|
|
227
|
+
function consoleOf(device: Device) {
|
|
228
|
+
const console = device.connection.console;
|
|
229
|
+
if (!console) throw new Error(`${device.name}: no connection.console`);
|
|
230
|
+
|
|
231
|
+
const user = device.users?.[device.connection.user];
|
|
232
|
+
if (!user) throw new Error(`${device.name}: connection user ${device.connection.user} is not one of its users`);
|
|
233
|
+
|
|
234
|
+
return { console, user: device.connection.user as string, password: resolve(user.password) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Log in on the console, open the CLI, and run `work` in it. `work` is also given a way to log in afresh on the same console. */
|
|
238
|
+
async function consoleSession<T>(device: Device, work: (cli: Cli, relogin: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
239
|
+
const { console, user, password } = consoleOf(device);
|
|
240
|
+
const opened = await openConsole(console);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const cli = await consoleLogin(opened.channel, user, password);
|
|
244
|
+
const relogin = async () => {
|
|
245
|
+
await consoleLogin(opened.channel, user, password);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const result = await work(cli, relogin);
|
|
249
|
+
await consoleLogout(cli);
|
|
250
|
+
return result;
|
|
251
|
+
} finally {
|
|
252
|
+
opened.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Log in, open the CLI, and run `work` in it: through the console when asked, else over SSH. */
|
|
257
|
+
async function session<T>(device: Device, work: (cli: Cli) => Promise<T>, options: ReadOptions = {}): Promise<T> {
|
|
258
|
+
if (options.console) return consoleSession(device, (cli) => work(cli));
|
|
259
|
+
|
|
152
260
|
const client = await login(device);
|
|
153
261
|
|
|
154
262
|
try {
|
|
@@ -216,8 +324,8 @@ export async function readConfig(cli: Cli, device: Device): Promise<string> {
|
|
|
216
324
|
return scrub(clean(text));
|
|
217
325
|
}
|
|
218
326
|
|
|
219
|
-
export function read(device: Device): Promise<string> {
|
|
220
|
-
return session(device, (cli) => readConfig(cli, device));
|
|
327
|
+
export function read(device: Device, _network: readonly Device[] = [], options: ReadOptions = {}): Promise<string> {
|
|
328
|
+
return session(device, (cli) => readConfig(cli, device), options);
|
|
221
329
|
}
|
|
222
330
|
|
|
223
331
|
/**
|
|
@@ -317,15 +425,19 @@ const RELOAD = { attempts: 20, interval: 15_000 } as const;
|
|
|
317
425
|
/**
|
|
318
426
|
* Once the device is back from a reload, set it to save changes on its own again: the saved configuration still says to revert. Setting it saves what runs, so it is set only once the device says nothing is left to reload; until then the reload is asked for again.
|
|
319
427
|
*/
|
|
320
|
-
async function settle(device: Device): Promise<string> {
|
|
428
|
+
async function settle(device: Device, options: ReadOptions): Promise<string> {
|
|
321
429
|
for (let attempt = 0; attempt < RELOAD.attempts; attempt++) {
|
|
322
430
|
await new Promise((done) => setTimeout(done, RELOAD.interval));
|
|
323
431
|
|
|
324
|
-
const settled = await session(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
432
|
+
const settled = await session(
|
|
433
|
+
device,
|
|
434
|
+
async (cli) => {
|
|
435
|
+
if (!/no config to be reloaded/.test(await cli.run("execute cfg reload"))) return false;
|
|
436
|
+
await saving(cli, "automatic");
|
|
437
|
+
return true;
|
|
438
|
+
},
|
|
439
|
+
options
|
|
440
|
+
).catch(() => false);
|
|
329
441
|
if (settled) return "the device is back and saves changes on its own again";
|
|
330
442
|
}
|
|
331
443
|
|
|
@@ -333,12 +445,23 @@ async function settle(device: Device): Promise<string> {
|
|
|
333
445
|
}
|
|
334
446
|
|
|
335
447
|
export async function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void> {
|
|
448
|
+
const seconds = options.rollback * 60;
|
|
449
|
+
|
|
336
450
|
try {
|
|
451
|
+
// Through the console, a fresh login on the same console proves the change left the device's logins working; over SSH, a fresh SSH session proves the management path too.
|
|
452
|
+
if (options.console) {
|
|
453
|
+
await consoleSession(device, async (cli, relogin) => {
|
|
454
|
+
await checkVersion(cli, device);
|
|
455
|
+
await configure(cli, plan, { seconds, prove: relogin });
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
337
460
|
await session(device, async (cli) => {
|
|
338
461
|
await checkVersion(cli, device);
|
|
339
462
|
|
|
340
463
|
await configure(cli, plan, {
|
|
341
|
-
seconds
|
|
464
|
+
seconds,
|
|
342
465
|
prove: async () => {
|
|
343
466
|
const proof = await login(device);
|
|
344
467
|
proof.end();
|
|
@@ -347,7 +470,7 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
|
|
|
347
470
|
});
|
|
348
471
|
} catch (error) {
|
|
349
472
|
if (!(error instanceof Reloaded)) throw error;
|
|
350
|
-
throw new Error(`${error.message}\n ${await settle(device)}`);
|
|
473
|
+
throw new Error(`${error.message}\n ${await settle(device, options)}`);
|
|
351
474
|
}
|
|
352
475
|
}
|
|
353
476
|
|
|
@@ -791,6 +914,7 @@ function serviceGaps(device: Device): string[] {
|
|
|
791
914
|
|
|
792
915
|
export const fortios: DeviceAdapter = {
|
|
793
916
|
extension: ".conf",
|
|
917
|
+
console: true,
|
|
794
918
|
unsupported,
|
|
795
919
|
render,
|
|
796
920
|
read,
|
|
@@ -18,6 +18,8 @@ type Table = {
|
|
|
18
18
|
created?: (entry: Block) => readonly string[];
|
|
19
19
|
/** Settings an entry keeps for life. A change deletes the entry and creates it again. */
|
|
20
20
|
fixed?: readonly string[];
|
|
21
|
+
/** The device refuses an entry that holds the same as another, `Duplicated entry`, even for a moment while entries are renumbered. */
|
|
22
|
+
distinct?: true;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
/** Objects every FortiGate running 6.2 is shipped with, which policies and profiles of the factory name. */
|
|
@@ -168,6 +170,8 @@ export const TABLES: Record<string, Table> = {
|
|
|
168
170
|
"firewall local-in-policy": ORDERED,
|
|
169
171
|
"firewall local-in-policy6": ORDERED,
|
|
170
172
|
"firewall central-snat-map": ORDERED,
|
|
173
|
+
"router static": { distinct: true },
|
|
174
|
+
"router static6": { distinct: true },
|
|
171
175
|
};
|
|
172
176
|
|
|
173
177
|
/** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
|
|
@@ -346,7 +350,11 @@ function compare(path: string[], want: Block, have: Block, table: Table | undefi
|
|
|
346
350
|
|
|
347
351
|
for (const [header, child] of want.blocks) nested(header, child, have.blocks.get(header) ?? new Block());
|
|
348
352
|
|
|
349
|
-
const entries = table?.byContent
|
|
353
|
+
const entries = table?.byContent
|
|
354
|
+
? byContent(path, want, have)
|
|
355
|
+
: table?.distinct
|
|
356
|
+
? distinctById(path, want, have, table, context)
|
|
357
|
+
: byId(path, want, have, table, context);
|
|
350
358
|
send.push(...entries.send);
|
|
351
359
|
show.push(...entries.show);
|
|
352
360
|
|
|
@@ -410,6 +418,37 @@ function byId(path: string[], want: Block, have: Block, table: Table | undefined
|
|
|
410
418
|
return { send, show, removals, removed };
|
|
411
419
|
}
|
|
412
420
|
|
|
421
|
+
/** Entries matched by name in a table that refuses two alike: every entry that changes or goes is deleted before any is created, so none is ever briefly the same as another. */
|
|
422
|
+
function distinctById(path: string[], want: Block, have: Block, table: Table, context: Context): Diff {
|
|
423
|
+
const deletes: string[] = [];
|
|
424
|
+
const creates: string[] = [];
|
|
425
|
+
const show: string[] = [];
|
|
426
|
+
|
|
427
|
+
for (const [key, entry] of want.entries) {
|
|
428
|
+
const old = have.entries.get(key);
|
|
429
|
+
const where = [...path, bare(entry.id)];
|
|
430
|
+
|
|
431
|
+
if (old) {
|
|
432
|
+
if (!compare(where, entry.block, old.block, undefined, false, context).send.length) continue;
|
|
433
|
+
|
|
434
|
+
deletes.push(`delete ${old.id}`);
|
|
435
|
+
show.push(...shown("-", where, old.block));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
creates.push(...creation(entry, table));
|
|
439
|
+
show.push(...shown("+", where, entry.block));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const [key, entry] of have.entries) {
|
|
443
|
+
if (want.entries.has(key) || isFactory(table, entry)) continue;
|
|
444
|
+
|
|
445
|
+
deletes.push(`delete ${entry.id}`);
|
|
446
|
+
show.push(...shown("-", [...path, bare(entry.id)], entry.block));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { send: [...deletes, ...creates], show, removals: [], removed: [] };
|
|
450
|
+
}
|
|
451
|
+
|
|
413
452
|
/** The `move` commands that put a table's entries in the render's order. Kept entries hold their place on the device and new ones are added at the end. */
|
|
414
453
|
function moves(want: Block, have: Block): string[] {
|
|
415
454
|
const wanted = [...want.entries.keys()];
|
|
@@ -11,17 +11,24 @@ export type ApplyOptions = {
|
|
|
11
11
|
secrets: boolean;
|
|
12
12
|
/** Minutes before a platform that can restore itself does so, if the run does not prove access survived. */
|
|
13
13
|
rollback: number;
|
|
14
|
+
/** Reach the device through its serial console, `connection.console`, instead of SSH to its management address. */
|
|
15
|
+
console?: boolean;
|
|
14
16
|
};
|
|
15
17
|
|
|
18
|
+
/** How a device is read. */
|
|
19
|
+
export type ReadOptions = Pick<ApplyOptions, "console">;
|
|
20
|
+
|
|
16
21
|
export type DeviceAdapter = {
|
|
17
22
|
/** File extension of a saved snapshot, for syntax highlighting. */
|
|
18
23
|
extension: string;
|
|
19
24
|
/** Fields this platform cannot express. Validation fails on any of them, so nothing is skipped silently. */
|
|
20
25
|
unsupported(device: Device): string[];
|
|
26
|
+
/** The platform can be read and applied through a serial console, `--console`. */
|
|
27
|
+
console?: true;
|
|
21
28
|
/** The whole configuration the device should run. Secrets appear as markers, never values. */
|
|
22
29
|
render(device: Device): string;
|
|
23
30
|
/** What the device runs now, secrets removed, in the form `render` produces where the platform allows. */
|
|
24
|
-
read(device: Device, network: readonly Device[]): Promise<string>;
|
|
31
|
+
read(device: Device, network: readonly Device[], options?: ReadOptions): Promise<string>;
|
|
25
32
|
/** What it takes to go from `current` to `desired`. */
|
|
26
33
|
plan(desired: string, current: string, options: ApplyOptions): Plan;
|
|
27
34
|
/** Replace the contents of a prefix set fetched from a registry. Absent on a platform that cannot hold one. */
|
package/src/cli/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ const HELP = `usage: circuit <command> [device...] [options]
|
|
|
32
32
|
apply <device> read the device, plan against what it runs, and send with --confirm
|
|
33
33
|
--secrets also send every secret, to rotate one
|
|
34
34
|
--rollback=N minutes before a device that can restore itself does, default 10
|
|
35
|
+
--console through the device's serial console on its jump host, instead of SSH; also for snapshot
|
|
35
36
|
refresh <device> fetch prefix sets that come from a registry onto the device, with --confirm
|
|
36
37
|
wireguard <device> <interface> <peer>
|
|
37
38
|
a client config for one peer, its private key left for the peer to fill
|
|
@@ -109,6 +110,7 @@ const confirmed = flags.includes("--confirm");
|
|
|
109
110
|
const options: ApplyOptions = {
|
|
110
111
|
secrets: flags.includes("--secrets"),
|
|
111
112
|
rollback: Number(flags.find((flag) => flag.startsWith("--rollback="))?.split("=")[1] ?? 10),
|
|
113
|
+
...(flags.includes("--console") ? { console: true } : {}),
|
|
112
114
|
};
|
|
113
115
|
|
|
114
116
|
function fail(message: string): never {
|
|
@@ -131,7 +133,15 @@ function one(): Device {
|
|
|
131
133
|
return device ?? fail("name a device");
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
/** The adapter of a device, which must be able to use its console when `--console` asks for it. */
|
|
137
|
+
function adapterOf(device: Device): DeviceAdapter {
|
|
138
|
+
const adapter = adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
139
|
+
if (!options.console) return adapter;
|
|
140
|
+
|
|
141
|
+
if (!adapter.console) fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
142
|
+
if (!device.connection.console) fail(`${device.name}: --console needs connection.console`);
|
|
143
|
+
return adapter;
|
|
144
|
+
}
|
|
135
145
|
/** What Circuit writes, beside the config. */
|
|
136
146
|
const generated = join(root, ".circuit");
|
|
137
147
|
const stateDirectory = join(generated, "state");
|
|
@@ -215,7 +225,7 @@ function diffCommand(): void {
|
|
|
215
225
|
|
|
216
226
|
async function snapshotCommand(): Promise<void> {
|
|
217
227
|
for (const device of selected(true)) {
|
|
218
|
-
const text = await adapterOf(device).read(device, network.devices);
|
|
228
|
+
const text = await adapterOf(device).read(device, network.devices, options);
|
|
219
229
|
writeState(device, text);
|
|
220
230
|
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
221
231
|
}
|
|
@@ -238,7 +248,7 @@ async function applyCommand(): Promise<void> {
|
|
|
238
248
|
const adapter = adapterOf(device);
|
|
239
249
|
|
|
240
250
|
// The plan is made against what the device runs now, never against an old snapshot.
|
|
241
|
-
const live = await adapter.read(device, network.devices);
|
|
251
|
+
const live = await adapter.read(device, network.devices, options);
|
|
242
252
|
writeState(device, live);
|
|
243
253
|
|
|
244
254
|
const plan = adapter.plan(adapter.render(device), live, options);
|
|
@@ -253,7 +263,7 @@ async function applyCommand(): Promise<void> {
|
|
|
253
263
|
|
|
254
264
|
await adapter.apply(device, network.devices, plan, options);
|
|
255
265
|
|
|
256
|
-
writeState(device, await adapter.read(device, network.devices));
|
|
266
|
+
writeState(device, await adapter.read(device, network.devices, options));
|
|
257
267
|
console.log("snapshot updated");
|
|
258
268
|
}
|
|
259
269
|
|
package/src/schema/device.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IP } from "./common";
|
|
1
|
+
import type { IP, Secret } from "./common";
|
|
2
2
|
import type { AclRule, Firewall } from "./firewall";
|
|
3
3
|
import type { Interface, Port, Vlan } from "./interfaces";
|
|
4
4
|
import type { PrefixSet, RoutePolicy } from "./policy";
|
|
@@ -13,6 +13,32 @@ export type Connection<U extends string> = {
|
|
|
13
13
|
user: U;
|
|
14
14
|
/** SSH port. 22 when absent. */
|
|
15
15
|
port?: number;
|
|
16
|
+
/** The device's serial console, for `--console`: the way in before the device has a management address, or when its management network is down. */
|
|
17
|
+
console?: Console;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A computer with SSH that holds the serial consoles of devices, such as a laptop beside the rack. It runs Python 3. */
|
|
21
|
+
export type Jump = {
|
|
22
|
+
/** Its address or name. */
|
|
23
|
+
host: string;
|
|
24
|
+
/** The user Circuit logs in as. It must be allowed to open the serial ports. */
|
|
25
|
+
user: string;
|
|
26
|
+
/** The user's password. */
|
|
27
|
+
password: Secret;
|
|
28
|
+
/** SSH port. 22 when absent. */
|
|
29
|
+
port?: number;
|
|
30
|
+
/** The jump host is reached through a Cloudflare Tunnel, so Circuit connects with `cloudflared access ssh`, which must be installed. */
|
|
31
|
+
cloudflared?: true;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A serial console, reached through a jump host. */
|
|
35
|
+
export type Console = {
|
|
36
|
+
/** The jump host the console cable is plugged into. */
|
|
37
|
+
jump: Jump;
|
|
38
|
+
/** The serial port on the jump host. A path under `/dev/serial/by-path/` stays the same when two adapters swap names. */
|
|
39
|
+
port: string;
|
|
40
|
+
/** Line speed in bits per second, such as 9600 or 115200. */
|
|
41
|
+
baud: number;
|
|
16
42
|
};
|
|
17
43
|
|
|
18
44
|
/** A separate routing table, with the interfaces that route in it. */
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Serial consoles on a jump host. Circuit logs in to the jump host over SSH and starts a small relay there, which joins the serial port to the session. The device's CLI then streams through it as through an SSH shell.
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { Duplex } from "node:stream";
|
|
5
|
+
import { Client, type ClientChannel } from "ssh2";
|
|
6
|
+
import { resolve } from "../core/secrets";
|
|
7
|
+
import type { Console, Jump } from "../schema";
|
|
8
|
+
import { exec } from "./ssh";
|
|
9
|
+
|
|
10
|
+
/** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
|
|
11
|
+
const RELAY = `import fcntl, os, select, sys, termios
|
|
12
|
+
|
|
13
|
+
port, baud = sys.argv[1], int(sys.argv[2])
|
|
14
|
+
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
18
|
+
except OSError:
|
|
19
|
+
sys.stderr.write("busy: another session holds " + port + "\\n")
|
|
20
|
+
sys.exit(3)
|
|
21
|
+
|
|
22
|
+
attrs = termios.tcgetattr(fd)
|
|
23
|
+
attrs[0] = attrs[1] = attrs[3] = 0
|
|
24
|
+
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
25
|
+
attrs[4] = attrs[5] = getattr(termios, "B%d" % baud)
|
|
26
|
+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
27
|
+
termios.tcflush(fd, termios.TCIOFLUSH)
|
|
28
|
+
|
|
29
|
+
sys.stderr.write("ready\\n")
|
|
30
|
+
sys.stderr.flush()
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
ready, _, _ = select.select([0, fd], [], [])
|
|
34
|
+
|
|
35
|
+
if 0 in ready:
|
|
36
|
+
data = os.read(0, 4096)
|
|
37
|
+
if not data:
|
|
38
|
+
break
|
|
39
|
+
while data:
|
|
40
|
+
try:
|
|
41
|
+
data = data[os.write(fd, data):]
|
|
42
|
+
except BlockingIOError:
|
|
43
|
+
select.select([], [fd], [])
|
|
44
|
+
|
|
45
|
+
if fd in ready:
|
|
46
|
+
try:
|
|
47
|
+
data = os.read(fd, 4096)
|
|
48
|
+
except BlockingIOError:
|
|
49
|
+
continue
|
|
50
|
+
if data:
|
|
51
|
+
os.write(1, data)
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const RELAY_PATH = "/tmp/circuit-console.py";
|
|
55
|
+
|
|
56
|
+
/** A serial port path the relay is started with, which reaches the jump host's shell. */
|
|
57
|
+
const PORT = /^\/dev\/[\w./:+-]+$/;
|
|
58
|
+
|
|
59
|
+
/** A session to a jump host, through `cloudflared access ssh` when it sits behind a Cloudflare Tunnel. */
|
|
60
|
+
function connectJump(jump: Jump): Promise<Client> {
|
|
61
|
+
return new Promise((done, fail) => {
|
|
62
|
+
const client = new Client();
|
|
63
|
+
const login = { username: jump.user, password: resolve(jump.password), readyTimeout: 60_000 };
|
|
64
|
+
|
|
65
|
+
client.on("ready", () => done(client)).on("error", fail);
|
|
66
|
+
|
|
67
|
+
if (!jump.cloudflared) {
|
|
68
|
+
client.connect({ ...login, host: jump.host, port: jump.port ?? 22 });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tunnel = spawn("cloudflared", ["access", "ssh", "--hostname", jump.host], { stdio: ["pipe", "pipe", "ignore"] });
|
|
73
|
+
tunnel.on("error", (error) => fail(new Error(`cloudflared did not start: ${error.message}`)));
|
|
74
|
+
client.on("close", () => tunnel.kill());
|
|
75
|
+
|
|
76
|
+
client.connect({ ...login, sock: Duplex.from({ readable: tunnel.stdout, writable: tunnel.stdin }) });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An open console: the stream to the device's CLI, and how to let it go. */
|
|
81
|
+
export type ConsoleSession = { channel: ClientChannel; close: () => void };
|
|
82
|
+
|
|
83
|
+
/** Open a device's serial console through its jump host. Fails when another session holds the port. */
|
|
84
|
+
export async function openConsole(console: Console): Promise<ConsoleSession> {
|
|
85
|
+
if (!PORT.test(console.port)) throw new Error(`console port ${console.port} is not a path under /dev`);
|
|
86
|
+
if (!Number.isInteger(console.baud) || console.baud <= 0) throw new Error(`console baud ${console.baud} is not a line speed`);
|
|
87
|
+
|
|
88
|
+
const client = await connectJump(console.jump);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const encoded = Buffer.from(RELAY).toString("base64");
|
|
92
|
+
await exec(client, `sh -c 'printf %s ${encoded} | base64 -d > ${RELAY_PATH}'`, 30_000);
|
|
93
|
+
|
|
94
|
+
const channel = await new Promise<ClientChannel>((done, fail) => {
|
|
95
|
+
client.exec(`python3 ${RELAY_PATH} ${console.port} ${console.baud}`, (error, stream) => (error ? fail(error) : done(stream)));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The relay says it is ready once it holds the port, or why it cannot.
|
|
99
|
+
await new Promise<void>((done, fail) => {
|
|
100
|
+
let said = "";
|
|
101
|
+
const limit = setTimeout(() => fail(new Error(`the console relay did not start: ${said.trim()}`)), 30_000);
|
|
102
|
+
|
|
103
|
+
channel.stderr.on("data", (chunk: Buffer) => {
|
|
104
|
+
said += chunk.toString("utf8");
|
|
105
|
+
if (said.includes("ready\n")) {
|
|
106
|
+
clearTimeout(limit);
|
|
107
|
+
done();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
channel.on("close", () => {
|
|
111
|
+
clearTimeout(limit);
|
|
112
|
+
fail(new Error(`the console relay stopped: ${said.trim() || "no reason given"}`));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
channel,
|
|
118
|
+
close: () => {
|
|
119
|
+
channel.end();
|
|
120
|
+
client.end();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
} catch (error) {
|
|
124
|
+
client.end();
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|