@meshxdata/fops 0.1.36 → 0.1.38

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 (59) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/fops.mjs +37 -14
  3. package/package.json +1 -1
  4. package/src/agent/llm.js +2 -0
  5. package/src/auth/azure.js +92 -0
  6. package/src/auth/cloudflare.js +125 -0
  7. package/src/auth/index.js +2 -0
  8. package/src/commands/index.js +8 -4
  9. package/src/commands/lifecycle.js +31 -10
  10. package/src/plugins/bundled/fops-plugin-azure/index.js +44 -2896
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +130 -2
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +497 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +51 -13
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +206 -52
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +128 -34
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-shared-cache.js +1 -1
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +4 -4
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +2 -2
  19. package/src/plugins/bundled/fops-plugin-azure/lib/commands/fleet-cmds.js +254 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +894 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +314 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +893 -0
  23. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-backend.yaml +13 -0
  24. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-frontend.yaml +13 -0
  25. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-backend.yaml +13 -0
  26. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-frontend.yaml +13 -0
  27. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-hive.yaml +13 -0
  28. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-kafka.yaml +13 -0
  29. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-meltano.yaml +13 -0
  30. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-mlflow.yaml +13 -0
  31. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-opa.yaml +13 -0
  32. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-processor.yaml +13 -0
  33. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-scheduler.yaml +13 -0
  34. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-storage-engine.yaml +13 -0
  35. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-trino.yaml +13 -0
  36. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-watcher.yaml +13 -0
  37. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/config/repository.yaml +66 -0
  38. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/kustomization.yaml +30 -0
  39. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/acr-webhook-controller.yaml +63 -0
  40. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/externalsecrets.yaml +15 -0
  41. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/istio.yaml +42 -0
  42. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kafka.yaml +15 -0
  43. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kube-reflector.yaml +33 -0
  44. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kubecost.yaml +12 -0
  45. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/nats-server.yaml +15 -0
  46. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/prometheus-agent.yaml +34 -0
  47. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/reloader.yaml +12 -0
  48. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/spark.yaml +112 -0
  49. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/tailscale.yaml +67 -0
  50. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/vertical-pod-autoscaler.yaml +15 -0
  51. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +1 -1
  52. package/src/plugins/bundled/fops-plugin-file/index.js +81 -12
  53. package/src/plugins/bundled/fops-plugin-file/lib/match.js +133 -15
  54. package/src/plugins/bundled/fops-plugin-file/lib/report.js +3 -0
  55. package/src/plugins/bundled/fops-plugin-foundation/index.js +26 -6
  56. package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +9 -5
  57. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +32 -0
  58. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/schema.js +20 -1
  59. package/src/plugins/loader.js +23 -6
@@ -618,7 +618,9 @@ const FOPS_KNOCK_TAG = "fopsKnock";
618
618
  /** Persist knock sequence to VM tag so other machines can fetch it. */
619
619
  export async function setVmKnockTag(execa, rg, vmName, knockSequence, sub) {
620
620
  if (!knockSequence?.length) return;
621
- const value = knockSequence.join(",");
621
+ // Use dash delimiter — Azure CLI --set treats commas as array separators
622
+ // which wraps the value in parens "(49198, 49200, 49180)" and breaks parsing.
623
+ const value = knockSequence.join("-");
622
624
  try {
623
625
  await execa("az", [
624
626
  "vm", "update", "--resource-group", rg, "--name", vmName,
@@ -629,23 +631,54 @@ export async function setVmKnockTag(execa, rg, vmName, knockSequence, sub) {
629
631
  } catch { /* non-fatal */ }
630
632
  }
631
633
 
634
+ // Per-process cache: vmName → true, so we only fetch from Azure once per CLI invocation.
635
+ const _vmStateSynced = new Set();
636
+
632
637
  /**
633
- * If local state has no knock sequence, try to load it from the VM's Azure tag
634
- * (set at provision time). Use when switching to another machine.
638
+ * Sync VM state (public IP + knock sequence) from Azure on the first call per process.
639
+ * Azure is the canonical source of truth local state drifts when VMs are
640
+ * restarted/reallocated (new IP) or knock sequences change.
635
641
  */
636
642
  export async function ensureKnockSequence(state) {
637
- if (state?.knockSequence?.length) return state;
638
643
  if (!state?.resourceGroup || !state?.vmName) return state;
644
+ if (_vmStateSynced.has(state.vmName)) return readVmState(state.vmName);
639
645
  const execa = await lazyExeca();
640
- const { stdout, exitCode } = await execa("az", [
641
- "vm", "show", "-g", state.resourceGroup, "-n", state.vmName,
642
- "--query", `tags.${FOPS_KNOCK_TAG}`, "-o", "tsv",
643
- ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "", exitCode: 1 }));
644
- const raw = (stdout || "").trim();
645
- if (exitCode !== 0 || !raw) return state;
646
- const knockSequence = raw.split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
647
- if (knockSequence.length < 2) return state;
648
- writeVmState(state.vmName, { knockSequence });
646
+ _vmStateSynced.add(state.vmName);
647
+
648
+ // Fetch public IP and knock sequence tag in parallel
649
+ const [ipResult, tagResult] = await Promise.all([
650
+ execa("az", [
651
+ "vm", "list-ip-addresses", "-g", state.resourceGroup, "-n", state.vmName, "--output", "json",
652
+ ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "[]", exitCode: 1 })),
653
+ execa("az", [
654
+ "vm", "show", "-g", state.resourceGroup, "-n", state.vmName,
655
+ "--query", `tags.${FOPS_KNOCK_TAG}`, "-o", "tsv",
656
+ ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "", exitCode: 1 })),
657
+ ]);
658
+
659
+ const patch = {};
660
+
661
+ // Sync public IP
662
+ try {
663
+ const ips = JSON.parse(ipResult.stdout || "[]");
664
+ const freshIp = ips?.[0]?.virtualMachine?.network?.publicIpAddresses?.[0]?.ipAddress || "";
665
+ if (freshIp && freshIp !== state.publicIp) patch.publicIp = freshIp;
666
+ } catch {}
667
+
668
+ // Sync knock sequence — tag value may use dash or comma delimiter;
669
+ // Azure CLI --set sometimes wraps comma values in parens like "(49198, 49200, 49180)"
670
+ const raw = (tagResult.stdout || "").trim().replace(/[()]/g, "");
671
+ if (tagResult.exitCode === 0 && raw) {
672
+ const knockSequence = raw.split(/[-,]/).map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0);
673
+ if (knockSequence.length >= 2) {
674
+ const local = state.knockSequence;
675
+ if (!local?.length || local.length !== knockSequence.length || local.some((v, i) => v !== knockSequence[i])) {
676
+ patch.knockSequence = knockSequence;
677
+ }
678
+ }
679
+ }
680
+
681
+ if (Object.keys(patch).length > 0) writeVmState(state.vmName, patch);
649
682
  return readVmState(state.vmName);
650
683
  }
651
684
 
@@ -656,6 +689,11 @@ export async function knockForVm(vmNameOrState) {
656
689
  ? readVmState(vmNameOrState) : vmNameOrState;
657
690
  state = await ensureKnockSequence(state);
658
691
  if (state?.knockSequence?.length && state.publicIp) {
692
+ // Close stale mux before knocking — ControlPersist=600 keeps the master alive for
693
+ // 10 min, but if the VM rebooted the underlying TCP is broken and all SSH commands
694
+ // through it will fail even after a successful knock.
695
+ const execa = await lazyExeca();
696
+ await closeMux(execa, state.publicIp, DEFAULTS.adminUser);
659
697
  await performKnock(state.publicIp, state.knockSequence, { quiet: true });
660
698
  }
661
699
  }
@@ -8,7 +8,7 @@ import { readState, saveState, readVmState, writeVmState, listVms, requireVmStat
8
8
  import {
9
9
  DEFAULTS, DIM, OK, WARN, ERR, LABEL, ACCENT,
10
10
  banner, hint, kvLine, nameArg,
11
- lazyExeca, ensureAzCli, ensureAzAuth, subArgs,
11
+ lazyExeca, ensureAzCli, ensureAzAuth, subArgs, fetchMyIp,
12
12
  resolvePublicIp, resolveGithubToken, verifyGithubToken,
13
13
  buildPublicUrl, buildDefaultUrl, resolveUniqueDomain,
14
14
  sshCmd, closeMux, MUX_OPTS, muxSocketPath, waitForSsh, knockForVm, fopsUpCmd, buildRemoteFopsUpArgs,
@@ -408,17 +408,25 @@ export async function azureVmCheck(opts = {}) {
408
408
  // ── ssh ─────────────────────────────────────────────────────────────────────
409
409
 
410
410
  export async function azureSsh(opts = {}) {
411
- const state = requireVmState(opts.vmName);
412
- const ip = state.publicIp;
411
+ const { knockForVm, ensureKnockSequence } = await import("./azure-helpers.js");
412
+ // Sync IP + knock sequence from Azure before using them
413
+ const freshState = await ensureKnockSequence(requireVmState(opts.vmName));
414
+ const ip = freshState?.publicIp;
413
415
  if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
414
416
 
415
- await knockForVm(state);
417
+ await knockForVm(freshState);
416
418
 
417
419
  const { execa } = await import("execa");
418
- await execa("ssh", [
420
+ const result = await execa("ssh", [
419
421
  "-o", "StrictHostKeyChecking=no",
422
+ "-o", "UserKnownHostsFile=/dev/null",
423
+ "-o", "ConnectTimeout=15",
420
424
  `${DEFAULTS.adminUser}@${ip}`,
421
425
  ], { stdio: "inherit", reject: false });
426
+ if (result.exitCode !== 0 && result.exitCode !== null) {
427
+ console.error(chalk.red(`\n SSH failed (exit ${result.exitCode}). If port-knock is enabled, try: fops azure knock open ${freshState?.vmName}\n`));
428
+ process.exit(1);
429
+ }
422
430
  }
423
431
 
424
432
  // ── port forward ─────────────────────────────────────────────────────────────
@@ -1005,11 +1013,11 @@ export async function azureRunUp(opts = {}) {
1005
1013
  5000,
1006
1014
  );
1007
1015
  let npmCode = tryUserFirst.exitCode === 0
1008
- ? (await ssh('D="$(npm root -g 2>/dev/null)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest 2>&1', 300000)).exitCode
1016
+ ? (await ssh('sudo rm -f /usr/bin/fops /usr/local/bin/fops 2>/dev/null; D="$(npm root -g 2>/dev/null)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest 2>&1', 300000)).exitCode
1009
1017
  : 1;
1010
1018
  if (npmCode !== 0) {
1011
1019
  npmCode = (await ssh(
1012
- "sudo -E bash -c 'D=\"$(npm root -g)/@meshxdata\"; rm -rf \"$D\" 2>/dev/null; npm install -g @meshxdata/fops@latest' 2>&1",
1020
+ "sudo -E bash -c 'rm -f /usr/bin/fops /usr/local/bin/fops 2>/dev/null; D=\"$(npm root -g)/@meshxdata\"; rm -rf \"$D\" 2>/dev/null; npm install -g @meshxdata/fops@latest' 2>&1",
1013
1021
  300000,
1014
1022
  )).exitCode;
1015
1023
  }
@@ -1874,7 +1882,7 @@ export async function azureUpdate(opts = {}) {
1874
1882
  );
1875
1883
  if (tryUserFirst.exitCode === 0) {
1876
1884
  const userInstall = await ssh(
1877
- 'D="$(npm root -g 2>/dev/null)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest 2>&1',
1885
+ 'sudo rm -f /usr/bin/fops /usr/local/bin/fops 2>/dev/null; D="$(npm root -g 2>/dev/null)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest 2>&1',
1878
1886
  300000,
1879
1887
  );
1880
1888
  npmCode = userInstall.exitCode;
@@ -1884,7 +1892,7 @@ export async function azureUpdate(opts = {}) {
1884
1892
  if (npmCode !== 0) {
1885
1893
  const sudoInstall = await ssh(
1886
1894
  "sudo -E bash -c '" +
1887
- 'D="$(npm root -g)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest' +
1895
+ 'rm -f /usr/bin/fops /usr/local/bin/fops 2>/dev/null; D="$(npm root -g)/@meshxdata"; rm -rf "$D" 2>/dev/null; npm install -g @meshxdata/fops@latest' +
1888
1896
  "' 2>&1",
1889
1897
  300000,
1890
1898
  );
@@ -2361,31 +2369,6 @@ export async function azureList(opts = {}) {
2361
2369
  let aksClusters = (fullState.azure || {}).clusters || {};
2362
2370
  let hasAks = Object.keys(aksClusters).length > 0;
2363
2371
 
2364
- // Always try to discover VMs from Azure (tag managed=fops) so we re-add any that were
2365
- // lost from local state (e.g. state file reset or edited).
2366
- try {
2367
- const execa = await lazyExeca();
2368
- await ensureAzCli(execa);
2369
- await ensureAzAuth(execa, { subscription: opts.subscription });
2370
- const found = await discoverVmsFromAzure(execa, { quiet: true, subscription: opts.subscription });
2371
- if (found > 0) {
2372
- console.log(OK(` ✓ Re-discovered ${found} VM(s) from Azure`) + DIM(" (tag managed=fops)\n"));
2373
- ({ activeVm, vms } = listVms());
2374
- vmNames = Object.keys(vms);
2375
- fullState = readState();
2376
- aksClusters = (fullState.azure || {}).clusters || {};
2377
- hasAks = Object.keys(aksClusters).length > 0;
2378
- }
2379
- } catch { /* az not available or not authenticated */ }
2380
-
2381
- if (vmNames.length === 0 && !hasAks) {
2382
- banner("Azure VMs");
2383
- hint("No VMs or clusters found in Azure.");
2384
- hint("Create a VM: fops azure up <name>");
2385
- hint("Create a cluster: fops azure aks up <name>\n");
2386
- return;
2387
- }
2388
-
2389
2372
  // Use cache if fresh, otherwise try shared tags, then fall back to full sync
2390
2373
  const forceLive = opts.live;
2391
2374
  let cache = readCache();
@@ -2406,13 +2389,37 @@ export async function azureList(opts = {}) {
2406
2389
  } catch { /* tag read failed, fall through to full sync */ }
2407
2390
  }
2408
2391
 
2392
+ // Discovery + full sync only when all caches are stale
2409
2393
  if (!fresh) {
2394
+ try {
2395
+ const execa = await lazyExeca();
2396
+ await ensureAzCli(execa);
2397
+ await ensureAzAuth(execa, { subscription: opts.subscription });
2398
+ const found = await discoverVmsFromAzure(execa, { quiet: true, subscription: opts.subscription });
2399
+ if (found > 0) {
2400
+ console.log(OK(` ✓ Re-discovered ${found} VM(s) from Azure`) + DIM(" (tag managed=fops)\n"));
2401
+ ({ activeVm, vms } = listVms());
2402
+ vmNames = Object.keys(vms);
2403
+ fullState = readState();
2404
+ aksClusters = (fullState.azure || {}).clusters || {};
2405
+ hasAks = Object.keys(aksClusters).length > 0;
2406
+ }
2407
+ } catch { /* az not available or not authenticated */ }
2408
+
2410
2409
  await azureSync({ quiet: !opts.verbose });
2411
2410
  cache = readCache();
2412
2411
  cacheSource = "live";
2413
2412
  }
2414
2413
  }
2415
2414
 
2415
+ if (vmNames.length === 0 && !hasAks) {
2416
+ banner("Azure VMs");
2417
+ hint("No VMs or clusters found in Azure.");
2418
+ hint("Create a VM: fops azure up <name>");
2419
+ hint("Create a cluster: fops azure aks up <name>\n");
2420
+ return;
2421
+ }
2422
+
2416
2423
  const cachedVms = cache?.vms || {};
2417
2424
  const cachedClusters = cache?.clusters || {};
2418
2425
  const cacheTime = cache?.updatedAt;
@@ -2775,8 +2782,10 @@ export async function azureApply(file, opts = {}) {
2775
2782
  // ── knock ────────────────────────────────────────────────────────────────────
2776
2783
 
2777
2784
  export async function azureKnock(opts = {}) {
2778
- const state = requireVmState(opts.vmName);
2779
- if (!state.knockSequence?.length) {
2785
+ const { ensureKnockSequence } = await import("./azure-helpers.js");
2786
+ // Sync IP + knock sequence from Azure before using them
2787
+ const state = await ensureKnockSequence(requireVmState(opts.vmName));
2788
+ if (!state?.knockSequence?.length) {
2780
2789
  console.error(ERR("\n Port knocking is not configured on this VM."));
2781
2790
  hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
2782
2791
  process.exit(1);
@@ -3096,7 +3105,7 @@ export async function azureKnockVerify(opts = {}) {
3096
3105
 
3097
3106
  export async function azureKnockFix(opts = {}) {
3098
3107
  const execa = await lazyExeca();
3099
- const { setupKnockd, generateKnockSequence } = await import("./port-knock.js");
3108
+ const { generateKnockSequence } = await import("./port-knock.js");
3100
3109
  const { vms } = listVms();
3101
3110
  const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
3102
3111
 
@@ -3105,33 +3114,178 @@ export async function azureKnockFix(opts = {}) {
3105
3114
  return;
3106
3115
  }
3107
3116
 
3117
+ await ensureAzCli(execa);
3118
+ await ensureAzAuth(execa, { subscription: opts.profile });
3119
+
3108
3120
  banner("Knock Fix");
3109
3121
 
3110
3122
  for (const name of targets) {
3111
3123
  const state = vms[name];
3112
- if (!state?.publicIp) continue;
3113
- const ip = state.publicIp;
3114
-
3115
- console.log(`\n ${LABEL(name)} ${DIM(`(${ip})`)}`);
3116
-
3117
- // Knock with existing sequence if available
3118
- if (state.knockSequence?.length) {
3119
- await performKnock(ip, state.knockSequence, { quiet: true });
3124
+ if (!state?.resourceGroup) {
3125
+ console.log(chalk.dim(` ${name}: no resource group — skipped`));
3126
+ continue;
3120
3127
  }
3128
+ const { publicIp: ip, resourceGroup: rg } = state;
3121
3129
 
3122
- const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 30000);
3130
+ console.log(`\n ${LABEL(name)} ${DIM(`(${ip || "no IP"})`)}`);
3123
3131
 
3124
- // Generate new sequence and re-setup
3125
3132
  const knockSeq = state.knockSequence?.length ? state.knockSequence : generateKnockSequence();
3126
3133
  const appPort = DEFAULTS.publicPort || 443;
3127
- await setupKnockd(ssh, knockSeq, { appPort });
3128
- writeVmState(name, { knockSequence: knockSeq });
3129
- if (state.resourceGroup) await setVmKnockTag(execa, state.resourceGroup, name, knockSeq, opts.profile);
3130
3134
 
3131
- console.log(OK(` ✓ Knock re-configured`));
3135
+ // Build the setup script to run via az vm run-command (works even when SSH is locked)
3136
+ const script = buildKnockdScript(knockSeq, appPort);
3137
+
3138
+ console.log(chalk.dim(" Running via az vm run-command (bypasses firewall)..."));
3139
+ const { stdout, exitCode } = await execa("az", [
3140
+ "vm", "run-command", "invoke",
3141
+ "--resource-group", rg,
3142
+ "--name", name,
3143
+ "--command-id", "RunShellScript",
3144
+ "--scripts", script,
3145
+ "--output", "json",
3146
+ ...subArgs(opts.profile),
3147
+ ], { reject: false, timeout: 180000 });
3148
+
3149
+ if (exitCode !== 0) {
3150
+ console.error(ERR(` ✗ az run-command failed for ${name}`));
3151
+ continue;
3152
+ }
3132
3153
 
3133
- await closeKnock(ssh, { quiet: true });
3154
+ // Check script output for errors
3155
+ try {
3156
+ const result = JSON.parse(stdout);
3157
+ const msg = result?.value?.[0]?.message || "";
3158
+ if (msg.toLowerCase().includes("failed") || msg.toLowerCase().includes("error")) {
3159
+ console.log(chalk.dim(` Script output: ${msg.slice(0, 200)}`));
3160
+ }
3161
+ } catch { /* ignore parse errors */ }
3162
+
3163
+ writeVmState(name, { knockSequence: knockSeq });
3164
+ await setVmKnockTag(execa, rg, name, knockSeq, opts.profile);
3165
+
3166
+ console.log(OK(` ✓ Knock re-configured (sequence: ${knockSeq.join(" → ")})`));
3134
3167
  }
3135
3168
 
3136
3169
  console.log(OK(`\n ✓ Done — run fops azure knock verify to confirm.\n`));
3137
3170
  }
3171
+
3172
+ /**
3173
+ * Build a self-contained bash script that installs knockd and configures
3174
+ * iptables. Designed to run via az vm run-command (no SSH needed).
3175
+ */
3176
+ function buildKnockdScript(sequence, appPort) {
3177
+ const ports = [22];
3178
+ if (appPort && appPort !== 22) ports.push(Number(appPort));
3179
+
3180
+ const openCmds = ports.map(p => `/usr/sbin/iptables -I INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3181
+ const closeCmds = ports.map(p => `/usr/sbin/iptables -D INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3182
+ const KNOCK_CMD_TIMEOUT = 300;
3183
+
3184
+ // iptables rules: clean up stale entries, then insert DROP + ESTABLISHED
3185
+ const iptRules = [];
3186
+ for (const p of ports) {
3187
+ iptRules.push(
3188
+ `while iptables -D INPUT -p tcp --dport ${p} -j DROP 2>/dev/null; do :; done`,
3189
+ `while iptables -D INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; do :; done`,
3190
+ `while iptables -D INPUT -p tcp --dport ${p} -j ACCEPT 2>/dev/null; do :; done`,
3191
+ );
3192
+ }
3193
+ for (const p of ports) {
3194
+ iptRules.push(
3195
+ `iptables -I INPUT -p tcp --dport ${p} -j DROP`,
3196
+ `iptables -I INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
3197
+ );
3198
+ }
3199
+
3200
+ return [
3201
+ "#!/bin/bash",
3202
+ "set -e",
3203
+ // Detect interface dynamically inside the script
3204
+ "IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)",
3205
+ "IFACE=${IFACE:-eth0}",
3206
+ // Install knockd
3207
+ "export DEBIAN_FRONTEND=noninteractive",
3208
+ "apt-get update -qq",
3209
+ "apt-get install -y -qq knockd iptables-persistent 2>/dev/null || true",
3210
+ // Write knockd.conf using printf to avoid heredoc quoting issues
3211
+ `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`,
3212
+ // Enable knockd service
3213
+ "sed -i 's/^START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd 2>/dev/null || true",
3214
+ `sed -i "s|^KNOCKD_OPTS=.*|KNOCKD_OPTS=\\"-i $IFACE\\"|" /etc/default/knockd 2>/dev/null || true`,
3215
+ // Apply iptables rules
3216
+ ...iptRules,
3217
+ "netfilter-persistent save 2>/dev/null || true",
3218
+ "systemctl enable knockd",
3219
+ "systemctl restart knockd",
3220
+ "echo 'knockd configured OK'",
3221
+ ].join("\n");
3222
+ }
3223
+
3224
+ // ── ssh whitelist-me ─────────────────────────────────────────────────────────
3225
+
3226
+ export async function azureSshWhitelistMe(opts = {}) {
3227
+ const execa = await lazyExeca();
3228
+ const sub = opts.profile;
3229
+ await ensureAzCli(execa);
3230
+ await ensureAzAuth(execa, { subscription: sub });
3231
+ const state = requireVmState(opts.vmName);
3232
+ const { vmName, resourceGroup: rg } = state;
3233
+
3234
+ const myIp = await fetchMyIp();
3235
+ if (!myIp) {
3236
+ console.error(ERR("\n Could not detect your public IP address.\n"));
3237
+ process.exit(1);
3238
+ }
3239
+ const myCidr = `${myIp}/32`;
3240
+
3241
+ // Resolve NSG name from NIC
3242
+ const nicName = `${vmName}VMNic`;
3243
+ const { stdout: nicJson, exitCode: nicCode } = await execa("az", [
3244
+ "network", "nic", "show", "-g", rg, "-n", nicName, "--output", "json",
3245
+ ...subArgs(sub),
3246
+ ], { reject: false, timeout: 15000 });
3247
+ let nsgName = `${vmName}NSG`;
3248
+ if (nicCode === 0) {
3249
+ const nsgId = JSON.parse(nicJson).networkSecurityGroup?.id || "";
3250
+ if (nsgId) nsgName = nsgId.split("/").pop();
3251
+ }
3252
+
3253
+ // Fetch current SSH rule
3254
+ const { stdout: rulesJson, exitCode: rulesCode } = await execa("az", [
3255
+ "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName, "--output", "json",
3256
+ ...subArgs(sub),
3257
+ ], { reject: false, timeout: 15000 });
3258
+ const rules = rulesCode === 0 ? JSON.parse(rulesJson || "[]") : [];
3259
+ const inboundAllow = rules.filter(r => r.access === "Allow" && r.direction === "Inbound");
3260
+ const sshRule = inboundAllow.find(r =>
3261
+ r.destinationPortRange === "22" ||
3262
+ (Array.isArray(r.destinationPortRanges) && r.destinationPortRanges.includes("22"))
3263
+ );
3264
+
3265
+ const currentSources = [];
3266
+ if (sshRule?.sourceAddressPrefix) currentSources.push(sshRule.sourceAddressPrefix);
3267
+ if (Array.isArray(sshRule?.sourceAddressPrefixes)) currentSources.push(...sshRule.sourceAddressPrefixes);
3268
+
3269
+ if (currentSources.includes(myCidr)) {
3270
+ console.log(OK(`\n ✓ ${myCidr} is already whitelisted for SSH on ${vmName}\n`));
3271
+ return;
3272
+ }
3273
+
3274
+ const merged = [...new Set([...currentSources.filter(s => s && s !== "*" && s !== "Internet"), myCidr])];
3275
+ console.log(chalk.yellow(` ↻ Adding ${myCidr} to SSH rule on ${nsgName} (${currentSources.length} existing)...`));
3276
+ const { exitCode: updateCode } = await execa("az", [
3277
+ "network", "nsg", "rule", "create", "-g", rg, "--nsg-name", nsgName,
3278
+ "-n", sshRule?.name || "allow-ssh", "--priority", String(sshRule?.priority || 1000),
3279
+ "--destination-port-ranges", "22", "--access", "Allow",
3280
+ "--source-address-prefixes", ...merged,
3281
+ "--protocol", "Tcp", "--direction", "Inbound", "--output", "none",
3282
+ ...subArgs(sub),
3283
+ ], { reject: false, timeout: 30000 });
3284
+
3285
+ if (updateCode !== 0) {
3286
+ console.error(ERR(`\n Failed to update NSG rule on ${nsgName}\n`));
3287
+ process.exit(1);
3288
+ }
3289
+ console.log(OK(`\n ✓ SSH (22) whitelisted for ${myCidr} on ${vmName} (${nsgName})\n`));
3290
+ console.log(` Sources: ${merged.join(", ")}\n`);
3291
+ }