@meshxdata/fops 0.1.48 → 0.1.50

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +30 -11
  4. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
  5. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
  26. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
  28. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
  29. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
  30. package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
  31. package/src/plugins/bundled/fops-plugin-foundation/index.js +309 -44
@@ -0,0 +1,527 @@
1
+ /**
2
+ * azure-ops-knock.js - Port knocking and SSH security operations
3
+ *
4
+ * Split from azure-ops.js for maintainability.
5
+ */
6
+
7
+ import chalk from "chalk";
8
+ import { performKnock, closeKnock, removeKnockd } from "./port-knock.js";
9
+ import { writeVmState, listVms, requireVmState } from "./azure-state.js";
10
+ import {
11
+ DEFAULTS, DIM, OK, WARN, ERR, LABEL,
12
+ banner, hint, nameArg,
13
+ lazyExeca, ensureAzCli, ensureAzAuth, subArgs, fetchMyIp,
14
+ sshCmd, MUX_OPTS,
15
+ setVmKnockTag,
16
+ } from "./azure-helpers.js";
17
+
18
+ // ── knock ────────────────────────────────────────────────────────────────────
19
+
20
+ export async function azureKnock(opts = {}) {
21
+ const { ensureKnockSequence } = await import("./azure-helpers.js");
22
+ // Sync IP + knock sequence from Azure before using them
23
+ const state = await ensureKnockSequence(requireVmState(opts.vmName));
24
+ if (!state?.knockSequence?.length) {
25
+ console.error(ERR("\n Port knocking is not configured on this VM."));
26
+ hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
27
+ process.exit(1);
28
+ }
29
+ const ip = state.publicIp;
30
+ if (!ip) { console.error(ERR("\n No IP address. Is the VM running?\n")); process.exit(1); }
31
+
32
+ await performKnock(ip, state.knockSequence);
33
+
34
+ hint("SSH (22) open for ~5 min from your IP.");
35
+ hint(`Connect: ssh ${DEFAULTS.adminUser}@${ip}`);
36
+ if (state.publicUrl) hint(`Browse: ${state.publicUrl}`);
37
+ console.log();
38
+ }
39
+
40
+ export async function azureKnockClose(opts = {}) {
41
+ const execa = await lazyExeca();
42
+ const state = requireVmState(opts.vmName);
43
+ if (!state.knockSequence?.length) {
44
+ console.error(ERR("\n Port knocking is not configured on this VM."));
45
+ hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
46
+ process.exit(1);
47
+ }
48
+ const ip = state.publicIp;
49
+ if (!ip) { console.error(ERR("\n No IP. Is the VM running?\n")); process.exit(1); }
50
+
51
+ await performKnock(ip, state.knockSequence, { quiet: true });
52
+ const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd);
53
+ await closeKnock(ssh);
54
+ }
55
+
56
+ /**
57
+ * Run the same teardown as removeKnockd on the VM via Azure Run Command (no SSH needed).
58
+ * Use when VM is unreachable (e.g. knock broken, wrong sequence).
59
+ */
60
+ async function removeKnockdViaRunCommand(execa, rg, vmName, sub) {
61
+ const script = [
62
+ "for dport in $(sudo iptables -L INPUT -n 2>/dev/null | grep 'tcp dpt:' | grep -oP 'dpt:\\K[0-9]+' | sort -u); do sudo iptables -D INPUT -p tcp --dport $dport -j DROP 2>/dev/null || true; sudo iptables -D INPUT -p tcp --dport $dport -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true; done",
63
+ "sudo netfilter-persistent save 2>/dev/null || true",
64
+ "sudo systemctl stop knockd 2>/dev/null || true",
65
+ "sudo systemctl disable knockd 2>/dev/null || true",
66
+ ].join(" && ");
67
+ const { exitCode } = await execa("az", [
68
+ "vm", "run-command", "invoke", "--resource-group", rg, "--name", vmName,
69
+ "--command-id", "RunShellScript", "--scripts", script,
70
+ "--output", "none", ...subArgs(sub),
71
+ ], { timeout: 60000, reject: false });
72
+ return exitCode === 0;
73
+ }
74
+
75
+ export async function azureKnockDisable(opts = {}) {
76
+ const execa = await lazyExeca();
77
+ const state = requireVmState(opts.vmName);
78
+ const ip = state.publicIp;
79
+ if (!ip) { console.error(chalk.red("\n No IP. Is the VM running?\n")); process.exit(1); }
80
+
81
+ if (!state.knockSequence?.length) {
82
+ console.log(chalk.dim("\n Port knocking is not configured.\n"));
83
+ return;
84
+ }
85
+
86
+ const rg = state.resourceGroup;
87
+ const vmName = state.vmName || opts.vmName;
88
+ const sub = opts.profile;
89
+
90
+ const trySsh = async () => {
91
+ await performKnock(ip, state.knockSequence, { quiet: true });
92
+ const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 15000);
93
+ await removeKnockd(ssh);
94
+ };
95
+
96
+ let sshOk = false;
97
+ if (rg && vmName) {
98
+ let sshReachable = false;
99
+ try {
100
+ await performKnock(ip, state.knockSequence, { quiet: true });
101
+ const { exitCode } = await execa("ssh", [
102
+ ...MUX_OPTS(ip, DEFAULTS.adminUser),
103
+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
104
+ `${DEFAULTS.adminUser}@${ip}`, "echo ok",
105
+ ], { timeout: 12000, reject: false });
106
+ sshReachable = exitCode === 0;
107
+ } catch { sshReachable = false; }
108
+ if (sshReachable) {
109
+ try {
110
+ await trySsh();
111
+ sshOk = true;
112
+ } catch (e) {
113
+ console.log(chalk.yellow(" SSH failed: " + (e?.message || e) + " — trying Azure Run Command…"));
114
+ }
115
+ } else {
116
+ console.log(chalk.dim(" VM not reachable via SSH — using Azure Run Command (no knock required)."));
117
+ }
118
+ if (!sshOk && rg && vmName) {
119
+ const ok = await removeKnockdViaRunCommand(execa, rg, vmName, sub);
120
+ if (ok) {
121
+ console.log(chalk.green(" ✓ Port knocking disabled via Azure Run Command — all ports open"));
122
+ sshOk = true;
123
+ } else {
124
+ console.error(chalk.red("\n Run Command failed. Ensure VM agent is running and you have access to the resource group.\n"));
125
+ process.exit(1);
126
+ }
127
+ }
128
+ }
129
+ if (!sshOk) {
130
+ try {
131
+ await trySsh();
132
+ } catch (e) {
133
+ if (rg && vmName) {
134
+ console.error(chalk.red("\n Could not reach VM via SSH and no resource group/vm name for Run Command.\n"));
135
+ } else {
136
+ console.error(chalk.red("\n Could not reach VM via SSH. If knock is broken, ensure state has resourceGroup/vmName and retry (uses az vm run-command).\n"));
137
+ }
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ writeVmState(state.vmName, { knockSequence: undefined });
143
+ }
144
+
145
+ export async function azureKnockVerify(opts = {}) {
146
+ const execa = await lazyExeca();
147
+ const { vms } = listVms();
148
+ const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
149
+
150
+ if (targets.length === 0) {
151
+ hint("No VMs tracked.\n");
152
+ return;
153
+ }
154
+
155
+ banner("Knock Verification");
156
+ let issues = 0;
157
+
158
+ const KNOCK_PORT_RANGE = { min: 49152, max: 49200 };
159
+ const knockRangeStr = `${KNOCK_PORT_RANGE.min}-${KNOCK_PORT_RANGE.max}`;
160
+
161
+ for (const name of targets) {
162
+ const state = vms[name];
163
+ if (!state) { console.log(ERR(` ✗ ${name}: not tracked`)); issues++; continue; }
164
+ const ip = state.publicIp;
165
+ if (!ip) { console.log(ERR(` ✗ ${name}: no public IP`)); issues++; continue; }
166
+
167
+ console.log(`\n ${LABEL(name)} ${DIM(`(${ip})`)}`);
168
+
169
+ // ── Azure NSG (firewall) — no SSH needed ─────────────────────────────────
170
+ const rg = state.resourceGroup;
171
+ const vmName = state.vmName || name;
172
+ if (rg) {
173
+ try {
174
+ const { stdout: nicId } = await execa("az", [
175
+ "vm", "show", "-g", rg, "-n", vmName,
176
+ "--query", "networkProfile.networkInterfaces[0].id", "-o", "tsv",
177
+ ...subArgs(opts.profile),
178
+ ], { timeout: 15000, reject: false });
179
+ if (nicId?.trim()) {
180
+ const { stdout: nsgId } = await execa("az", [
181
+ "network", "nic", "show", "--ids", nicId.trim(),
182
+ "--query", "networkSecurityGroup.id", "-o", "tsv",
183
+ ...subArgs(opts.profile),
184
+ ], { timeout: 10000, reject: false });
185
+ if (nsgId?.trim()) {
186
+ const nsgName = nsgId.trim().split("/").pop();
187
+ const { stdout: rulesJson } = await execa("az", [
188
+ "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName,
189
+ "--output", "json", ...subArgs(opts.profile),
190
+ ], { timeout: 10000, reject: false });
191
+ const rules = rulesJson ? JSON.parse(rulesJson) : [];
192
+ const allow = rules.filter((r) => r.direction === "Inbound" && r.access === "Allow");
193
+ const has22 = allow.some((r) =>
194
+ r.destinationPortRange === "22" || (r.destinationPortRanges || []).includes("22")
195
+ );
196
+ const hasKnockRange = allow.some((r) =>
197
+ r.destinationPortRange === knockRangeStr ||
198
+ (r.destinationPortRanges || []).some((p) => {
199
+ const n = parseInt(p, 10);
200
+ return n >= KNOCK_PORT_RANGE.min && n <= KNOCK_PORT_RANGE.max;
201
+ })
202
+ );
203
+ if (has22 && hasKnockRange) {
204
+ console.log(OK(` ✓ NSG "${nsgName}": SSH (22) + knock range (${knockRangeStr}) allowed`));
205
+ } else {
206
+ if (!has22) { console.log(ERR(` ✗ NSG: no rule allowing port 22`)); issues++; }
207
+ if (!hasKnockRange) { console.log(ERR(` ✗ NSG: no rule allowing knock ports ${knockRangeStr}`)); issues++; }
208
+ hint(` Fix: fops azure up ${name} (reconciles NSG rules)`);
209
+ }
210
+ } else {
211
+ console.log(WARN(" ⚠ No NSG on NIC — SSH/knock not restricted at Azure level"));
212
+ }
213
+ }
214
+ } catch (e) {
215
+ console.log(WARN(` ⚠ Could not check NSG: ${e.message || e}`));
216
+ }
217
+ }
218
+
219
+ // Check local config
220
+ if (!state.knockSequence?.length) {
221
+ console.log(WARN(" ⚠ No knock sequence stored locally"));
222
+ console.log(DIM(" Fix: fops azure up " + name));
223
+ issues++;
224
+ continue;
225
+ }
226
+ console.log(OK(` ✓ Local sequence: ${state.knockSequence.join(" → ")}`));
227
+
228
+ // Knock to get SSH access for diagnostics
229
+ await performKnock(ip, state.knockSequence, { quiet: true });
230
+
231
+ const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 10000);
232
+
233
+ // In-guest checks (may fail if knock/SSH still blocked)
234
+ let sshOk = false;
235
+ try {
236
+ const { exitCode } = await execa("ssh", [
237
+ ...MUX_OPTS(ip, DEFAULTS.adminUser),
238
+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
239
+ `${DEFAULTS.adminUser}@${ip}`, "echo ok",
240
+ ], { timeout: 12000, reject: false });
241
+ sshOk = exitCode === 0;
242
+ } catch { sshOk = false; }
243
+ if (!sshOk) {
244
+ console.log(ERR(" ✗ SSH unreachable after knock — check NSG above and sequence match"));
245
+ hint(` Try: fops azure knock open ${name} then fops azure ssh ${name}`);
246
+ hint(` Or: fops azure knock fix ${name} to re-setup knock`);
247
+ issues++;
248
+ continue;
249
+ }
250
+
251
+ // Check knockd service
252
+ const { stdout: knockdStatus } = await ssh(
253
+ "systemctl is-active knockd 2>/dev/null || echo inactive"
254
+ );
255
+ if (knockdStatus?.trim() === "active") {
256
+ console.log(OK(" ✓ knockd service: active"));
257
+ } else {
258
+ console.log(ERR(" ✗ knockd service: " + (knockdStatus?.trim() || "unknown")));
259
+ console.log(DIM(" Fix: fops azure up " + name));
260
+ issues++;
261
+ }
262
+
263
+ // Check iptables DROP rules for port 22 and 443
264
+ const { stdout: iptables } = await ssh(
265
+ "sudo iptables -L INPUT -n --line-numbers 2>/dev/null"
266
+ );
267
+ const lines = (iptables || "").split("\n");
268
+
269
+ for (const port of [22, 443]) {
270
+ const dropRule = lines.find(l => l.includes("DROP") && l.includes(`dpt:${port}`));
271
+ const estRule = lines.find(l => l.includes("ACCEPT") && l.includes(`dpt:${port}`) && l.includes("ESTABLISHED"));
272
+
273
+ if (dropRule && estRule) {
274
+ const dropNum = parseInt(dropRule.trim());
275
+ const estNum = parseInt(estRule.trim());
276
+ if (estNum < dropNum) {
277
+ console.log(OK(` ✓ Port ${port}: DROP rule #${dropNum}, ESTABLISHED rule #${estNum} (correct order)`));
278
+ } else {
279
+ console.log(ERR(` ✗ Port ${port}: ESTABLISHED rule #${estNum} is AFTER DROP #${dropNum} (wrong order)`));
280
+ issues++;
281
+ }
282
+ } else if (!dropRule) {
283
+ console.log(ERR(` ✗ Port ${port}: no DROP rule — port is wide open`));
284
+ issues++;
285
+ } else if (!estRule) {
286
+ console.log(WARN(` ⚠ Port ${port}: DROP exists but no ESTABLISHED rule (may break active sessions)`));
287
+ issues++;
288
+ }
289
+ }
290
+
291
+ // Check for broad ACCEPT rules that would override DROP
292
+ const broadAccept = lines.filter(l =>
293
+ l.includes("ACCEPT") &&
294
+ l.includes("0.0.0.0/0") &&
295
+ !l.includes("dpt:") &&
296
+ !l.includes("ESTABLISHED") &&
297
+ !l.includes("RELATED")
298
+ );
299
+ if (broadAccept.length > 0) {
300
+ for (const rule of broadAccept) {
301
+ const ruleNum = parseInt(rule.trim());
302
+ const drop22 = lines.find(l => l.includes("DROP") && l.includes("dpt:22"));
303
+ const dropNum = drop22 ? parseInt(drop22.trim()) : 999;
304
+ if (ruleNum < dropNum) {
305
+ console.log(ERR(` ✗ Broad ACCEPT-all rule #${ruleNum} is before DROP #${dropNum} — bypasses knock!`));
306
+ issues++;
307
+ }
308
+ }
309
+ }
310
+
311
+ // Check knockd config matches local sequence
312
+ const { stdout: confRaw } = await ssh("sudo cat /etc/knockd.conf 2>/dev/null || echo ''");
313
+ const seqMatch = confRaw?.match(/sequence\s*=\s*([\d,]+)/);
314
+ if (seqMatch) {
315
+ const remoteSeq = seqMatch[1].split(",").map(Number);
316
+ const localSeq = state.knockSequence;
317
+ if (JSON.stringify(remoteSeq) === JSON.stringify(localSeq)) {
318
+ console.log(OK(" ✓ knockd.conf sequence matches local state"));
319
+ } else {
320
+ console.log(ERR(` ✗ Sequence mismatch — remote: [${remoteSeq.join(",")}] vs local: [${localSeq.join(",")}]`));
321
+ console.log(DIM(" Fix: fops azure knock disable " + name + " && fops azure up " + name));
322
+ issues++;
323
+ }
324
+ } else {
325
+ console.log(ERR(" ✗ Could not read /etc/knockd.conf"));
326
+ issues++;
327
+ }
328
+
329
+ // Close the door after verification
330
+ await closeKnock(ssh, { quiet: true });
331
+ }
332
+
333
+ console.log("");
334
+ if (issues > 0) {
335
+ console.log(ERR(` ${issues} issue(s) found.`));
336
+ hint("To re-setup knock on all VMs: fops azure knock fix\n");
337
+ } else {
338
+ console.log(OK(" ✓ All VMs properly configured — ports are blocked until knocked.\n"));
339
+ }
340
+ }
341
+
342
+ export async function azureKnockFix(opts = {}) {
343
+ const execa = await lazyExeca();
344
+ const { generateKnockSequence } = await import("./port-knock.js");
345
+ const { vms } = listVms();
346
+ const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
347
+
348
+ if (targets.length === 0) {
349
+ hint("No VMs tracked.\n");
350
+ return;
351
+ }
352
+
353
+ await ensureAzCli(execa);
354
+ await ensureAzAuth(execa, { subscription: opts.profile });
355
+
356
+ banner("Knock Fix");
357
+
358
+ for (const name of targets) {
359
+ const state = vms[name];
360
+ if (!state?.resourceGroup) {
361
+ console.log(chalk.dim(` ${name}: no resource group — skipped`));
362
+ continue;
363
+ }
364
+ const { publicIp: ip, resourceGroup: rg } = state;
365
+
366
+ console.log(`\n ${LABEL(name)} ${DIM(`(${ip || "no IP"})`)}`);
367
+
368
+ const knockSeq = state.knockSequence?.length ? state.knockSequence : generateKnockSequence();
369
+ const appPort = DEFAULTS.publicPort || 443;
370
+
371
+ // Build the setup script to run via az vm run-command (works even when SSH is locked)
372
+ const script = buildKnockdScript(knockSeq, appPort);
373
+
374
+ console.log(chalk.dim(" Running via az vm run-command (bypasses firewall)..."));
375
+ const { stdout, exitCode } = await execa("az", [
376
+ "vm", "run-command", "invoke",
377
+ "--resource-group", rg,
378
+ "--name", name,
379
+ "--command-id", "RunShellScript",
380
+ "--scripts", script,
381
+ "--output", "json",
382
+ ...subArgs(opts.profile),
383
+ ], { reject: false, timeout: 180000 });
384
+
385
+ if (exitCode !== 0) {
386
+ console.error(ERR(` ✗ az run-command failed for ${name}`));
387
+ continue;
388
+ }
389
+
390
+ // Check script output for errors
391
+ try {
392
+ const result = JSON.parse(stdout);
393
+ const msg = result?.value?.[0]?.message || "";
394
+ if (msg.toLowerCase().includes("failed") || msg.toLowerCase().includes("error")) {
395
+ console.log(chalk.dim(` Script output: ${msg.slice(0, 200)}`));
396
+ }
397
+ } catch { /* ignore parse errors */ }
398
+
399
+ writeVmState(name, { knockSequence: knockSeq });
400
+ await setVmKnockTag(execa, rg, name, knockSeq, opts.profile);
401
+
402
+ console.log(OK(` ✓ Knock re-configured (sequence: ${knockSeq.join(" → ")})`));
403
+ }
404
+
405
+ console.log(OK(`\n ✓ Done — run fops azure knock verify to confirm.\n`));
406
+ }
407
+
408
+ /**
409
+ * Build a self-contained bash script that installs knockd and configures
410
+ * iptables. Designed to run via az vm run-command (no SSH needed).
411
+ */
412
+ function buildKnockdScript(sequence, appPort) {
413
+ const ports = [22];
414
+ if (appPort && appPort !== 22) ports.push(Number(appPort));
415
+
416
+ const openCmds = ports.map(p => `/usr/sbin/iptables -I INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
417
+ const closeCmds = ports.map(p => `/usr/sbin/iptables -D INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
418
+ const KNOCK_CMD_TIMEOUT = 300;
419
+
420
+ // iptables rules: clean up stale entries, then insert DROP + ESTABLISHED
421
+ const iptRules = [];
422
+ for (const p of ports) {
423
+ iptRules.push(
424
+ `while iptables -D INPUT -p tcp --dport ${p} -j DROP 2>/dev/null; do :; done`,
425
+ `while iptables -D INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; do :; done`,
426
+ `while iptables -D INPUT -p tcp --dport ${p} -j ACCEPT 2>/dev/null; do :; done`,
427
+ );
428
+ }
429
+ for (const p of ports) {
430
+ iptRules.push(
431
+ `iptables -I INPUT -p tcp --dport ${p} -j DROP`,
432
+ `iptables -I INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
433
+ );
434
+ }
435
+
436
+ return [
437
+ "#!/bin/bash",
438
+ "set -e",
439
+ // Detect interface dynamically inside the script
440
+ "IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)",
441
+ "IFACE=${IFACE:-eth0}",
442
+ // Install knockd
443
+ "export DEBIAN_FRONTEND=noninteractive",
444
+ "apt-get update -qq",
445
+ "apt-get install -y -qq knockd iptables-persistent 2>/dev/null || true",
446
+ // Write knockd.conf using printf to avoid heredoc quoting issues
447
+ `printf '[options]\\n UseSyslog\\n Interface = %s\\n\\n[openPorts]\\n sequence = ${sequence.join(",")}\\n seq_timeout = 15\\n command = ${openCmds}\\n tcpflags = syn\\n cmd_timeout = ${KNOCK_CMD_TIMEOUT}\\n stop_command = ${closeCmds}\\n' "$IFACE" > /etc/knockd.conf`,
448
+ // Enable knockd service
449
+ "sed -i 's/^START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd 2>/dev/null || true",
450
+ `sed -i "s|^KNOCKD_OPTS=.*|KNOCKD_OPTS=\\"-i $IFACE\\"|" /etc/default/knockd 2>/dev/null || true`,
451
+ // Apply iptables rules
452
+ ...iptRules,
453
+ "netfilter-persistent save 2>/dev/null || true",
454
+ "systemctl enable knockd",
455
+ "systemctl restart knockd",
456
+ "echo 'knockd configured OK'",
457
+ ].join("\n");
458
+ }
459
+
460
+ // ── ssh whitelist-me ─────────────────────────────────────────────────────────
461
+
462
+ export async function azureSshWhitelistMe(opts = {}) {
463
+ const execa = await lazyExeca();
464
+ const sub = opts.profile;
465
+ await ensureAzCli(execa);
466
+ await ensureAzAuth(execa, { subscription: sub });
467
+ const state = requireVmState(opts.vmName);
468
+ const { vmName, resourceGroup: rg } = state;
469
+
470
+ const myIp = await fetchMyIp();
471
+ if (!myIp) {
472
+ console.error(ERR("\n Could not detect your public IP address.\n"));
473
+ process.exit(1);
474
+ }
475
+ const myCidr = `${myIp}/32`;
476
+
477
+ // Resolve NSG name from NIC
478
+ const nicName = `${vmName}VMNic`;
479
+ const { stdout: nicJson, exitCode: nicCode } = await execa("az", [
480
+ "network", "nic", "show", "-g", rg, "-n", nicName, "--output", "json",
481
+ ...subArgs(sub),
482
+ ], { reject: false, timeout: 15000 });
483
+ let nsgName = `${vmName}NSG`;
484
+ if (nicCode === 0) {
485
+ const nsgId = JSON.parse(nicJson).networkSecurityGroup?.id || "";
486
+ if (nsgId) nsgName = nsgId.split("/").pop();
487
+ }
488
+
489
+ // Fetch current SSH rule
490
+ const { stdout: rulesJson, exitCode: rulesCode } = await execa("az", [
491
+ "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName, "--output", "json",
492
+ ...subArgs(sub),
493
+ ], { reject: false, timeout: 15000 });
494
+ const rules = rulesCode === 0 ? JSON.parse(rulesJson || "[]") : [];
495
+ const inboundAllow = rules.filter(r => r.access === "Allow" && r.direction === "Inbound");
496
+ const sshRule = inboundAllow.find(r =>
497
+ r.destinationPortRange === "22" ||
498
+ (Array.isArray(r.destinationPortRanges) && r.destinationPortRanges.includes("22"))
499
+ );
500
+
501
+ const currentSources = [];
502
+ if (sshRule?.sourceAddressPrefix) currentSources.push(sshRule.sourceAddressPrefix);
503
+ if (Array.isArray(sshRule?.sourceAddressPrefixes)) currentSources.push(...sshRule.sourceAddressPrefixes);
504
+
505
+ if (currentSources.includes(myCidr)) {
506
+ console.log(OK(`\n ✓ ${myCidr} is already whitelisted for SSH on ${vmName}\n`));
507
+ return;
508
+ }
509
+
510
+ const merged = [...new Set([...currentSources.filter(s => s && s !== "*" && s !== "Internet"), myCidr])];
511
+ console.log(chalk.yellow(` ↻ Adding ${myCidr} to SSH rule on ${nsgName} (${currentSources.length} existing)...`));
512
+ const { exitCode: updateCode } = await execa("az", [
513
+ "network", "nsg", "rule", "create", "-g", rg, "--nsg-name", nsgName,
514
+ "-n", sshRule?.name || "allow-ssh", "--priority", String(sshRule?.priority || 1000),
515
+ "--destination-port-ranges", "22", "--access", "Allow",
516
+ "--source-address-prefixes", ...merged,
517
+ "--protocol", "Tcp", "--direction", "Inbound", "--output", "none",
518
+ ...subArgs(sub),
519
+ ], { reject: false, timeout: 30000 });
520
+
521
+ if (updateCode !== 0) {
522
+ console.error(ERR(`\n Failed to update NSG rule on ${nsgName}\n`));
523
+ process.exit(1);
524
+ }
525
+ console.log(OK(`\n ✓ SSH (22) whitelisted for ${myCidr} on ${vmName} (${nsgName})\n`));
526
+ console.log(` Sources: ${merged.join(", ")}\n`);
527
+ }