@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.
- package/CHANGELOG.md +207 -0
- package/fops.mjs +37 -14
- package/package.json +1 -1
- package/src/agent/llm.js +2 -0
- package/src/auth/azure.js +92 -0
- package/src/auth/cloudflare.js +125 -0
- package/src/auth/index.js +2 -0
- package/src/commands/index.js +8 -4
- package/src/commands/lifecycle.js +31 -10
- package/src/plugins/bundled/fops-plugin-azure/index.js +44 -2896
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +130 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +497 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +51 -13
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +206 -52
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +128 -34
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-shared-cache.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +4 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +2 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/fleet-cmds.js +254 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +894 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +314 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +893 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-backend.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-frontend.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-backend.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-frontend.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-hive.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-kafka.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-meltano.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-mlflow.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-opa.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-processor.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-scheduler.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-storage-engine.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-trino.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-watcher.yaml +13 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/config/repository.yaml +66 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/kustomization.yaml +30 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/acr-webhook-controller.yaml +63 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/externalsecrets.yaml +15 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/istio.yaml +42 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kafka.yaml +15 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kube-reflector.yaml +33 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kubecost.yaml +12 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/nats-server.yaml +15 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/prometheus-agent.yaml +34 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/reloader.yaml +12 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/spark.yaml +112 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/tailscale.yaml +67 -0
- package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/vertical-pod-autoscaler.yaml +15 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +1 -1
- package/src/plugins/bundled/fops-plugin-file/index.js +81 -12
- package/src/plugins/bundled/fops-plugin-file/lib/match.js +133 -15
- package/src/plugins/bundled/fops-plugin-file/lib/report.js +3 -0
- package/src/plugins/bundled/fops-plugin-foundation/index.js +26 -6
- package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +9 -5
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +32 -0
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/schema.js +20 -1
- 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
|
-
|
|
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
|
-
*
|
|
634
|
-
*
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
412
|
-
|
|
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(
|
|
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
|
|
2779
|
-
|
|
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 {
|
|
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?.
|
|
3113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|