@meshxdata/fops 0.1.35 → 0.1.37

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.
@@ -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
  );
@@ -2775,8 +2783,10 @@ export async function azureApply(file, opts = {}) {
2775
2783
  // ── knock ────────────────────────────────────────────────────────────────────
2776
2784
 
2777
2785
  export async function azureKnock(opts = {}) {
2778
- const state = requireVmState(opts.vmName);
2779
- if (!state.knockSequence?.length) {
2786
+ const { ensureKnockSequence } = await import("./azure-helpers.js");
2787
+ // Sync IP + knock sequence from Azure before using them
2788
+ const state = await ensureKnockSequence(requireVmState(opts.vmName));
2789
+ if (!state?.knockSequence?.length) {
2780
2790
  console.error(ERR("\n Port knocking is not configured on this VM."));
2781
2791
  hint(`Provision with: fops azure up${nameArg(state?.vmName)}\n`);
2782
2792
  process.exit(1);
@@ -3096,7 +3106,7 @@ export async function azureKnockVerify(opts = {}) {
3096
3106
 
3097
3107
  export async function azureKnockFix(opts = {}) {
3098
3108
  const execa = await lazyExeca();
3099
- const { setupKnockd, generateKnockSequence } = await import("./port-knock.js");
3109
+ const { generateKnockSequence } = await import("./port-knock.js");
3100
3110
  const { vms } = listVms();
3101
3111
  const targets = opts.vmName ? [opts.vmName] : Object.keys(vms);
3102
3112
 
@@ -3105,33 +3115,178 @@ export async function azureKnockFix(opts = {}) {
3105
3115
  return;
3106
3116
  }
3107
3117
 
3118
+ await ensureAzCli(execa);
3119
+ await ensureAzAuth(execa, { subscription: opts.profile });
3120
+
3108
3121
  banner("Knock Fix");
3109
3122
 
3110
3123
  for (const name of targets) {
3111
3124
  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 });
3125
+ if (!state?.resourceGroup) {
3126
+ console.log(chalk.dim(` ${name}: no resource group — skipped`));
3127
+ continue;
3120
3128
  }
3129
+ const { publicIp: ip, resourceGroup: rg } = state;
3121
3130
 
3122
- const ssh = (cmd) => sshCmd(execa, ip, DEFAULTS.adminUser, cmd, 30000);
3131
+ console.log(`\n ${LABEL(name)} ${DIM(`(${ip || "no IP"})`)}`);
3123
3132
 
3124
- // Generate new sequence and re-setup
3125
3133
  const knockSeq = state.knockSequence?.length ? state.knockSequence : generateKnockSequence();
3126
3134
  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
3135
 
3131
- console.log(OK(` ✓ Knock re-configured`));
3136
+ // Build the setup script to run via az vm run-command (works even when SSH is locked)
3137
+ const script = buildKnockdScript(knockSeq, appPort);
3138
+
3139
+ console.log(chalk.dim(" Running via az vm run-command (bypasses firewall)..."));
3140
+ const { stdout, exitCode } = await execa("az", [
3141
+ "vm", "run-command", "invoke",
3142
+ "--resource-group", rg,
3143
+ "--name", name,
3144
+ "--command-id", "RunShellScript",
3145
+ "--scripts", script,
3146
+ "--output", "json",
3147
+ ...subArgs(opts.profile),
3148
+ ], { reject: false, timeout: 180000 });
3149
+
3150
+ if (exitCode !== 0) {
3151
+ console.error(ERR(` ✗ az run-command failed for ${name}`));
3152
+ continue;
3153
+ }
3154
+
3155
+ // Check script output for errors
3156
+ try {
3157
+ const result = JSON.parse(stdout);
3158
+ const msg = result?.value?.[0]?.message || "";
3159
+ if (msg.toLowerCase().includes("failed") || msg.toLowerCase().includes("error")) {
3160
+ console.log(chalk.dim(` Script output: ${msg.slice(0, 200)}`));
3161
+ }
3162
+ } catch { /* ignore parse errors */ }
3132
3163
 
3133
- await closeKnock(ssh, { quiet: true });
3164
+ writeVmState(name, { knockSequence: knockSeq });
3165
+ await setVmKnockTag(execa, rg, name, knockSeq, opts.profile);
3166
+
3167
+ console.log(OK(` ✓ Knock re-configured (sequence: ${knockSeq.join(" → ")})`));
3134
3168
  }
3135
3169
 
3136
3170
  console.log(OK(`\n ✓ Done — run fops azure knock verify to confirm.\n`));
3137
3171
  }
3172
+
3173
+ /**
3174
+ * Build a self-contained bash script that installs knockd and configures
3175
+ * iptables. Designed to run via az vm run-command (no SSH needed).
3176
+ */
3177
+ function buildKnockdScript(sequence, appPort) {
3178
+ const ports = [22];
3179
+ if (appPort && appPort !== 22) ports.push(Number(appPort));
3180
+
3181
+ const openCmds = ports.map(p => `/usr/sbin/iptables -I INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3182
+ const closeCmds = ports.map(p => `/usr/sbin/iptables -D INPUT -s %IP% -p tcp --dport ${p} -j ACCEPT`).join(" && ");
3183
+ const KNOCK_CMD_TIMEOUT = 300;
3184
+
3185
+ // iptables rules: clean up stale entries, then insert DROP + ESTABLISHED
3186
+ const iptRules = [];
3187
+ for (const p of ports) {
3188
+ iptRules.push(
3189
+ `while iptables -D INPUT -p tcp --dport ${p} -j DROP 2>/dev/null; do :; done`,
3190
+ `while iptables -D INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; do :; done`,
3191
+ `while iptables -D INPUT -p tcp --dport ${p} -j ACCEPT 2>/dev/null; do :; done`,
3192
+ );
3193
+ }
3194
+ for (const p of ports) {
3195
+ iptRules.push(
3196
+ `iptables -I INPUT -p tcp --dport ${p} -j DROP`,
3197
+ `iptables -I INPUT -p tcp --dport ${p} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT`,
3198
+ );
3199
+ }
3200
+
3201
+ return [
3202
+ "#!/bin/bash",
3203
+ "set -e",
3204
+ // Detect interface dynamically inside the script
3205
+ "IFACE=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1)",
3206
+ "IFACE=${IFACE:-eth0}",
3207
+ // Install knockd
3208
+ "export DEBIAN_FRONTEND=noninteractive",
3209
+ "apt-get update -qq",
3210
+ "apt-get install -y -qq knockd iptables-persistent 2>/dev/null || true",
3211
+ // Write knockd.conf using printf to avoid heredoc quoting issues
3212
+ `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`,
3213
+ // Enable knockd service
3214
+ "sed -i 's/^START_KNOCKD=0/START_KNOCKD=1/' /etc/default/knockd 2>/dev/null || true",
3215
+ `sed -i "s|^KNOCKD_OPTS=.*|KNOCKD_OPTS=\\"-i $IFACE\\"|" /etc/default/knockd 2>/dev/null || true`,
3216
+ // Apply iptables rules
3217
+ ...iptRules,
3218
+ "netfilter-persistent save 2>/dev/null || true",
3219
+ "systemctl enable knockd",
3220
+ "systemctl restart knockd",
3221
+ "echo 'knockd configured OK'",
3222
+ ].join("\n");
3223
+ }
3224
+
3225
+ // ── ssh whitelist-me ─────────────────────────────────────────────────────────
3226
+
3227
+ export async function azureSshWhitelistMe(opts = {}) {
3228
+ const execa = await lazyExeca();
3229
+ const sub = opts.profile;
3230
+ await ensureAzCli(execa);
3231
+ await ensureAzAuth(execa, { subscription: sub });
3232
+ const state = requireVmState(opts.vmName);
3233
+ const { vmName, resourceGroup: rg } = state;
3234
+
3235
+ const myIp = await fetchMyIp();
3236
+ if (!myIp) {
3237
+ console.error(ERR("\n Could not detect your public IP address.\n"));
3238
+ process.exit(1);
3239
+ }
3240
+ const myCidr = `${myIp}/32`;
3241
+
3242
+ // Resolve NSG name from NIC
3243
+ const nicName = `${vmName}VMNic`;
3244
+ const { stdout: nicJson, exitCode: nicCode } = await execa("az", [
3245
+ "network", "nic", "show", "-g", rg, "-n", nicName, "--output", "json",
3246
+ ...subArgs(sub),
3247
+ ], { reject: false, timeout: 15000 });
3248
+ let nsgName = `${vmName}NSG`;
3249
+ if (nicCode === 0) {
3250
+ const nsgId = JSON.parse(nicJson).networkSecurityGroup?.id || "";
3251
+ if (nsgId) nsgName = nsgId.split("/").pop();
3252
+ }
3253
+
3254
+ // Fetch current SSH rule
3255
+ const { stdout: rulesJson, exitCode: rulesCode } = await execa("az", [
3256
+ "network", "nsg", "rule", "list", "-g", rg, "--nsg-name", nsgName, "--output", "json",
3257
+ ...subArgs(sub),
3258
+ ], { reject: false, timeout: 15000 });
3259
+ const rules = rulesCode === 0 ? JSON.parse(rulesJson || "[]") : [];
3260
+ const inboundAllow = rules.filter(r => r.access === "Allow" && r.direction === "Inbound");
3261
+ const sshRule = inboundAllow.find(r =>
3262
+ r.destinationPortRange === "22" ||
3263
+ (Array.isArray(r.destinationPortRanges) && r.destinationPortRanges.includes("22"))
3264
+ );
3265
+
3266
+ const currentSources = [];
3267
+ if (sshRule?.sourceAddressPrefix) currentSources.push(sshRule.sourceAddressPrefix);
3268
+ if (Array.isArray(sshRule?.sourceAddressPrefixes)) currentSources.push(...sshRule.sourceAddressPrefixes);
3269
+
3270
+ if (currentSources.includes(myCidr)) {
3271
+ console.log(OK(`\n ✓ ${myCidr} is already whitelisted for SSH on ${vmName}\n`));
3272
+ return;
3273
+ }
3274
+
3275
+ const merged = [...new Set([...currentSources.filter(s => s && s !== "*" && s !== "Internet"), myCidr])];
3276
+ console.log(chalk.yellow(` ↻ Adding ${myCidr} to SSH rule on ${nsgName} (${currentSources.length} existing)...`));
3277
+ const { exitCode: updateCode } = await execa("az", [
3278
+ "network", "nsg", "rule", "create", "-g", rg, "--nsg-name", nsgName,
3279
+ "-n", sshRule?.name || "allow-ssh", "--priority", String(sshRule?.priority || 1000),
3280
+ "--destination-port-ranges", "22", "--access", "Allow",
3281
+ "--source-address-prefixes", ...merged,
3282
+ "--protocol", "Tcp", "--direction", "Inbound", "--output", "none",
3283
+ ...subArgs(sub),
3284
+ ], { reject: false, timeout: 30000 });
3285
+
3286
+ if (updateCode !== 0) {
3287
+ console.error(ERR(`\n Failed to update NSG rule on ${nsgName}\n`));
3288
+ process.exit(1);
3289
+ }
3290
+ console.log(OK(`\n ✓ SSH (22) whitelisted for ${myCidr} on ${vmName} (${nsgName})\n`));
3291
+ console.log(` Sources: ${merged.join(", ")}\n`);
3292
+ }
@@ -8,7 +8,7 @@ import {
8
8
  DEFAULTS, DIM, OK, WARN, ERR,
9
9
  banner, hint, kvLine,
10
10
  resolvePublicIp, subArgs, buildTags, fetchMyIp,
11
- sshCmd, waitForSsh, fopsUpCmd, buildPublicUrl,
11
+ sshCmd, waitForSsh, closeMux, fopsUpCmd, buildPublicUrl,
12
12
  runReconcilers, ensureOpenAiNetworkAccess,
13
13
  reconcileOk, RECONCILE_LABEL_WIDTH,
14
14
  } from "./azure-helpers.js";
@@ -448,7 +448,36 @@ async function vmReconcileNetworking(ctx) {
448
448
  }
449
449
  }
450
450
  } else {
451
- console.log(chalk.yellow(" ⚠ No public IP attached to NIC"));
451
+ // No public IP attached create one and attach it
452
+ const pipName = `${vmName}PublicIP`;
453
+ console.log(chalk.dim(` ↻ ${"Public IP".padEnd(RECONCILE_LABEL_WIDTH)} — creating ${pipName}…`));
454
+ const { exitCode: createCode } = await execa("az", [
455
+ "network", "public-ip", "create", "-g", rg, "-n", pipName,
456
+ "--sku", "Standard", "--allocation-method", "Static", "--output", "none",
457
+ ...subArgs(sub),
458
+ ], { reject: false, timeout: 30000 });
459
+ if (createCode === 0) {
460
+ const { exitCode: attachCode } = await execa("az", [
461
+ "network", "nic", "ip-config", "update",
462
+ "-g", rg, "--nic-name", ctx.nicName, "-n", "ipconfig1",
463
+ "--public-ip-address", pipName, "--output", "none",
464
+ ...subArgs(sub),
465
+ ], { reject: false, timeout: 30000 });
466
+ if (attachCode === 0) {
467
+ const { stdout: newPipJson } = await execa("az", [
468
+ "network", "public-ip", "show", "-g", rg, "-n", pipName, "--output", "json",
469
+ ...subArgs(sub),
470
+ ], { reject: false, timeout: 15000 });
471
+ try {
472
+ ctx.ip = JSON.parse(newPipJson).ipAddress || "";
473
+ } catch {}
474
+ reconcileOk("Public IP", ctx.ip ? `${ctx.ip} (created)` : `${pipName} (created)`);
475
+ } else {
476
+ console.log(chalk.yellow(` ⚠ Created ${pipName} but could not attach to NIC`));
477
+ }
478
+ } else {
479
+ console.log(chalk.yellow(` ⚠ Could not create public IP — VM will have no public IP`));
480
+ }
452
481
  }
453
482
 
454
483
  const nsgId = nic.networkSecurityGroup?.id || "";
@@ -560,6 +589,7 @@ async function vmReconcileNsg(ctx) {
560
589
  ...subArgs(sub),
561
590
  ], { reject: false, timeout: 30000 });
562
591
  reconcileOk("SSH (22)", source);
592
+ ctx.operatorIpChanged = true;
563
593
  } else if (sshSource && !sshHasMyIp) {
564
594
  // Add our IP to existing admin IPs
565
595
  const merged = [...new Set([...sshCurrentSources, sshSource])];
@@ -573,6 +603,7 @@ async function vmReconcileNsg(ctx) {
573
603
  ...subArgs(sub),
574
604
  ], { reject: false, timeout: 30000 });
575
605
  reconcileOk("SSH (22)", merged.join(", "));
606
+ ctx.operatorIpChanged = true;
576
607
  } else {
577
608
  reconcileOk("SSH (22)", sshCurrentSources.join(", ") || "*");
578
609
  }
@@ -1028,12 +1059,19 @@ async function removeSshBypassViaRunCommand(execa, rg, vmName, sourceCidr, sub)
1028
1059
  async function vmReconcileSsh(ctx) {
1029
1060
  const { execa, ip, adminUser, knockSequence, port, desiredUrl, vmName, rg, sub } = ctx;
1030
1061
  console.log(chalk.dim(" Checking SSH..."));
1031
- const maxWaitFirst = 90000;
1062
+ // Close any stale mux master before probing. ControlPersist=600 keeps the SSH master
1063
+ // alive for 10 min, but if the VM rebooted its underlying TCP is broken — every probe
1064
+ // through the stale mux fails even after a successful knock. A fresh connect fixes it.
1065
+ await closeMux(execa, ip, adminUser);
1066
+ // When Run Command is available (rg set), use a short knock probe then fall through to
1067
+ // Run Command rather than burning 2+ minutes on knock retries.
1068
+ const canRunCommand = knockSequence?.length && rg;
1069
+ const maxWaitFirst = canRunCommand ? 20000 : 90000;
1032
1070
  if (knockSequence?.length) {
1033
1071
  await performKnock(ip, knockSequence, { quiet: true });
1034
1072
  }
1035
1073
  let sshReady = await waitForSsh(execa, ip, adminUser, maxWaitFirst);
1036
- if (!sshReady && knockSequence?.length) {
1074
+ if (!sshReady && knockSequence?.length && !canRunCommand) {
1037
1075
  console.log(chalk.dim(" Re-sending knock and retrying SSH..."));
1038
1076
  await performKnock(ip, knockSequence, { quiet: true });
1039
1077
  await new Promise((r) => setTimeout(r, 5000));
@@ -1081,8 +1119,25 @@ async function vmReconcileKnock(ctx) {
1081
1119
  );
1082
1120
  const knockdRunning = knockdCheck?.trim() === "active";
1083
1121
 
1084
- if (knockdRunning && ctx.knockSequence?.length) {
1085
- reconcileOk("Port knocking", "active");
1122
+ // Read the authoritative sequence from knockd.conf on the VM
1123
+ const { stdout: knockConfOut } = await ssh(
1124
+ "grep -m1 'sequence' /etc/knockd.conf 2>/dev/null | sed 's/.*sequence[[:space:]]*=[[:space:]]*//'", 8000,
1125
+ );
1126
+ const vmKnockSeq = (knockConfOut || "").trim()
1127
+ .split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0);
1128
+
1129
+ if (knockdRunning && vmKnockSeq.length >= 2) {
1130
+ // Reconcile: keep tag + local state in sync with knockd.conf
1131
+ const local = ctx.knockSequence;
1132
+ const seqDiffers = !local?.length || local.length !== vmKnockSeq.length || local.some((v, i) => v !== vmKnockSeq[i]);
1133
+ if (seqDiffers) {
1134
+ console.log(chalk.dim(` ↻ ${"Port knocking".padEnd(RECONCILE_LABEL_WIDTH)} — sequence drift detected, syncing…`));
1135
+ writeVmState(vmName, { knockSequence: vmKnockSeq });
1136
+ const { setVmKnockTag } = await import("./azure-helpers.js");
1137
+ await setVmKnockTag(execa, ctx.rg, vmName, vmKnockSeq, ctx.sub);
1138
+ ctx.knockSequence = vmKnockSeq;
1139
+ }
1140
+ reconcileOk("Port knocking", `active [${vmKnockSeq.join(", ")}]`);
1086
1141
  } else if (!knockdRunning) {
1087
1142
  console.log(chalk.dim(" Setting up port knocking (after post-start + UI reachable)..."));
1088
1143
  const knockSeq = generateKnockSequence();
@@ -1092,8 +1147,7 @@ async function vmReconcileKnock(ctx) {
1092
1147
  await setVmKnockTag(execa, ctx.rg, vmName, knockSeq, ctx.sub);
1093
1148
  ctx.knockSequence = knockSeq;
1094
1149
  } else {
1095
- console.log(chalk.yellow(" ⚠ knockd active on VM but no local knock sequence stored"));
1096
- console.log(chalk.dim(" To re-create: fops azure knock disable && fops azure up"));
1150
+ console.log(chalk.yellow(" ⚠ knockd active on VM but could not read sequence from knockd.conf"));
1097
1151
  }
1098
1152
 
1099
1153
  if (ctx.knockSequence?.length) {
@@ -27,7 +27,7 @@ export {
27
27
  managedImageId, resolvePublicIp,
28
28
  resolveGithubToken, verifyGithubToken,
29
29
  sshCmd, closeMux, MUX_OPTS, muxSocketPath, waitForSsh,
30
- knockForVm, fopsUpCmd,
30
+ knockForVm, ensureKnockSequence, fopsUpCmd,
31
31
  runReconcilers, ensureOpenAiNetworkAccess,
32
32
  } from "./azure-helpers.js";
33
33
 
@@ -46,7 +46,7 @@ export {
46
46
 
47
47
  // ── VM operations ────────────────────────────────────────────────────────────
48
48
  export {
49
- azureStatus, azureTrinoStatus, azureSsh, azurePortForward, azureSshAdminAdd, azureVmCheck, azureAgent, azureOpenAiDebugVm,
49
+ azureStatus, azureTrinoStatus, azureSsh, azureSshWhitelistMe, azurePortForward, azureSshAdminAdd, azureVmCheck, azureAgent, azureOpenAiDebugVm,
50
50
  azureDeploy, azurePull, azureDeployVersion, azureRunUp, azureConfig, azureConfigVersions, azureUpdate,
51
51
  azureLogs, azureGrantAdmin, azureContext,
52
52
  azureList, azureApply,