@iann29/synapse 1.8.8 → 1.8.10
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/lib/commands/https-setup.js +28 -0
- package/lib/doctor/checks.js +249 -1
- package/lib/doctor/renderer.js +19 -2
- package/lib/doctor/runner.js +10 -1
- package/lib/https/hosts.js +59 -6
- package/lib/https/planner.js +26 -3
- package/package.json +1 -1
|
@@ -240,6 +240,34 @@ Examples:
|
|
|
240
240
|
process.exitCode = 1;
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
// v1.8.10: post-execute verification. Even after every step
|
|
245
|
+
// reports ✓ the actual end-state can be wrong — most commonly on
|
|
246
|
+
// Windows where the DNS Client cache holds a negative response
|
|
247
|
+
// even after the hosts file is updated. Re-scan and verify the
|
|
248
|
+
// domain ACTUALLY resolves to 127.0.0.1 (when we wrote hosts).
|
|
249
|
+
// If it doesn't, surface a yellow warning with a concrete
|
|
250
|
+
// remediation instead of pretending the setup is healthy.
|
|
251
|
+
const wroteHosts = results.some(
|
|
252
|
+
(r) => r.id === "hosts" && r.kind === "ok",
|
|
253
|
+
);
|
|
254
|
+
const verifyOk = after.resolution.resolvesToLoopback;
|
|
255
|
+
if (wroteHosts && !verifyOk && !skipHosts) {
|
|
256
|
+
summary.verifyResolution = false;
|
|
257
|
+
summary.verifyHint =
|
|
258
|
+
detection.platform.id === "windows"
|
|
259
|
+
? "Windows DNS Client cache is still serving stale NXDOMAIN for this domain. Run `ipconfig /flushdns` from an Administrator PowerShell, then retry `npm run dev:https`. If it still fails, restart your machine."
|
|
260
|
+
: "Hosts file was written but the OS resolver still doesn't return 127.0.0.1. Try restarting nscd / systemd-resolved if you use one, or open a new shell.";
|
|
261
|
+
if (!ctx.out.json) {
|
|
262
|
+
ctx.out.stdout.write("\n");
|
|
263
|
+
ctx.out.warn(
|
|
264
|
+
`${domain} doesn't resolve to 127.0.0.1 yet — ${summary.verifyHint}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
} else if (wroteHosts && verifyOk) {
|
|
268
|
+
summary.verifyResolution = true;
|
|
269
|
+
}
|
|
270
|
+
|
|
243
271
|
ctx.out.stdout.write(
|
|
244
272
|
`${colors.green("✓")} ${colors.bold(`HTTPS ready for ${domain}`)}\n`,
|
|
245
273
|
);
|
package/lib/doctor/checks.js
CHANGED
|
@@ -634,6 +634,238 @@ function makeDeploymentCheck(target) {
|
|
|
634
634
|
};
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
// ============================================================
|
|
638
|
+
// v1.8.9: Local HTTPS dev category. Checks the `synapse https setup`
|
|
639
|
+
// surface — mkcert presence, CA trust, cert file presence + expiry,
|
|
640
|
+
// hostname resolution.
|
|
641
|
+
//
|
|
642
|
+
// These checks are CONTEXT-GATED: they only emit a meaningful result
|
|
643
|
+
// when the operator is in a project that uses dev:https OR has
|
|
644
|
+
// generated certs under ~/.config/dev-certs/. When neither is true,
|
|
645
|
+
// every check returns `silentlySkip: true` in its data, and the
|
|
646
|
+
// renderer hides the whole category — operators in a "no HTTPS
|
|
647
|
+
// here" project don't see a section of dim "skipped" lines.
|
|
648
|
+
//
|
|
649
|
+
// We require the https detect helpers; importing them here is a
|
|
650
|
+
// pure dependency (no side effects at require time).
|
|
651
|
+
// ============================================================
|
|
652
|
+
|
|
653
|
+
const httpsDetect = require("../https/detect");
|
|
654
|
+
|
|
655
|
+
// Read the cwd's package.json dev:https script (if any) and extract
|
|
656
|
+
// the --hostname / --experimental-https-cert / --experimental-https-key
|
|
657
|
+
// values. Returns null when there's no script, malformed JSON, or
|
|
658
|
+
// the script isn't recognisable.
|
|
659
|
+
function parseDevHttpsScript(cwd) {
|
|
660
|
+
const info = httpsDetect.detectPackageJson(cwd);
|
|
661
|
+
if (!info.present || !info.existingDevHttps) return null;
|
|
662
|
+
const cmd = info.existingDevHttps;
|
|
663
|
+
const hostMatch = cmd.match(/--hostname[\s=]+(\S+)/);
|
|
664
|
+
const certMatch = cmd.match(/--experimental-https-cert[\s=]+(\S+)/);
|
|
665
|
+
const keyMatch = cmd.match(/--experimental-https-key[\s=]+(\S+)/);
|
|
666
|
+
if (!hostMatch || !certMatch || !keyMatch) return null;
|
|
667
|
+
return {
|
|
668
|
+
domain: hostMatch[1],
|
|
669
|
+
cert: certMatch[1],
|
|
670
|
+
key: keyMatch[1],
|
|
671
|
+
raw: cmd,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// "Does this operator's machine HAVE any local-HTTPS context?"
|
|
676
|
+
// True when either:
|
|
677
|
+
// - cwd has a dev:https script, OR
|
|
678
|
+
// - ~/.config/dev-certs/ has at least one cert pair (any shape)
|
|
679
|
+
//
|
|
680
|
+
// Used as the gate for every check in this category — when it's
|
|
681
|
+
// false, the checks silently skip and the category is hidden.
|
|
682
|
+
function hasLocalHttpsContext(cwd) {
|
|
683
|
+
const dev = parseDevHttpsScript(cwd);
|
|
684
|
+
if (dev) return true;
|
|
685
|
+
// Check the cert store.
|
|
686
|
+
try {
|
|
687
|
+
const fs = require("node:fs");
|
|
688
|
+
const root = httpsDetect.certsRoot();
|
|
689
|
+
if (!fs.existsSync(root)) return false;
|
|
690
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
691
|
+
// Either a subdir (canonical) or a .pem pair (flat) means context.
|
|
692
|
+
if (entries.some((e) => e.isDirectory())) return true;
|
|
693
|
+
const flat = httpsDetect.detectFlatCertsInStore();
|
|
694
|
+
return flat.length > 0;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const checkHttpsMkcert = {
|
|
701
|
+
id: "https-mkcert",
|
|
702
|
+
category: "local-https-dev",
|
|
703
|
+
title: "mkcert installed",
|
|
704
|
+
autoFix: "never",
|
|
705
|
+
dependsOn: [],
|
|
706
|
+
run: safeRun(async (ctx) => {
|
|
707
|
+
if (!hasLocalHttpsContext(ctx.cwd)) {
|
|
708
|
+
return { status: "skipped", summary: "no local HTTPS context", data: { silentlySkip: true } };
|
|
709
|
+
}
|
|
710
|
+
const m = httpsDetect.detectMkcert();
|
|
711
|
+
if (m.present) {
|
|
712
|
+
return { status: "ok", summary: m.version || "(version unknown)", data: { version: m.version, caroot: m.caroot } };
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
status: "issue",
|
|
716
|
+
summary: "mkcert not found in PATH",
|
|
717
|
+
remediation: "Install mkcert (apt/pacman/dnf/brew/choco/scoop/winget), then run `synapse https setup <domain>`.",
|
|
718
|
+
data: { present: false },
|
|
719
|
+
};
|
|
720
|
+
}),
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const checkHttpsCaTrusted = {
|
|
724
|
+
id: "https-ca-trusted",
|
|
725
|
+
category: "local-https-dev",
|
|
726
|
+
title: "local CA trusted (rootCA.pem present)",
|
|
727
|
+
autoFix: "never",
|
|
728
|
+
dependsOn: ["https-mkcert"],
|
|
729
|
+
run: safeRun(async (ctx) => {
|
|
730
|
+
if (!hasLocalHttpsContext(ctx.cwd)) {
|
|
731
|
+
return { status: "skipped", summary: "no local HTTPS context", data: { silentlySkip: true } };
|
|
732
|
+
}
|
|
733
|
+
const m = httpsDetect.detectMkcert();
|
|
734
|
+
if (!m.present) {
|
|
735
|
+
return { status: "skipped", summary: "mkcert missing", data: {} };
|
|
736
|
+
}
|
|
737
|
+
if (httpsDetect.detectCaTrusted(m)) {
|
|
738
|
+
return { status: "ok", summary: `rootCA in ${m.caroot}`, data: { caroot: m.caroot } };
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
status: "issue",
|
|
742
|
+
summary: "rootCA.pem not found in mkcert's CAROOT",
|
|
743
|
+
remediation: "Run `mkcert -install` once (or just `synapse https setup <domain>`).",
|
|
744
|
+
data: { caroot: m.caroot },
|
|
745
|
+
};
|
|
746
|
+
}),
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const checkHttpsCertFiles = {
|
|
750
|
+
id: "https-cert-files",
|
|
751
|
+
category: "local-https-dev",
|
|
752
|
+
title: "dev:https cert + key files exist",
|
|
753
|
+
autoFix: "never",
|
|
754
|
+
dependsOn: ["https-mkcert"],
|
|
755
|
+
run: safeRun(async (ctx) => {
|
|
756
|
+
const dev = parseDevHttpsScript(ctx.cwd);
|
|
757
|
+
if (!dev) {
|
|
758
|
+
return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
|
|
759
|
+
}
|
|
760
|
+
const fs = require("node:fs");
|
|
761
|
+
const certExists = fs.existsSync(dev.cert);
|
|
762
|
+
const keyExists = fs.existsSync(dev.key);
|
|
763
|
+
if (certExists && keyExists) {
|
|
764
|
+
return {
|
|
765
|
+
status: "ok",
|
|
766
|
+
summary: `${dev.domain} ✓ both files present`,
|
|
767
|
+
data: { domain: dev.domain, cert: dev.cert, key: dev.key },
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const missing = [];
|
|
771
|
+
if (!certExists) missing.push("cert");
|
|
772
|
+
if (!keyExists) missing.push("key");
|
|
773
|
+
return {
|
|
774
|
+
status: "issue",
|
|
775
|
+
summary: `${dev.domain}: missing ${missing.join(" + ")}`,
|
|
776
|
+
remediation: `Run \`synapse https setup ${dev.domain}\` to regenerate.`,
|
|
777
|
+
data: { domain: dev.domain, cert: dev.cert, key: dev.key, certExists, keyExists },
|
|
778
|
+
};
|
|
779
|
+
}),
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const checkHttpsDomainResolves = {
|
|
783
|
+
id: "https-domain-resolves",
|
|
784
|
+
category: "local-https-dev",
|
|
785
|
+
title: "dev:https domain resolves to 127.0.0.1",
|
|
786
|
+
autoFix: "never",
|
|
787
|
+
dependsOn: [],
|
|
788
|
+
run: safeRun(async (ctx) => {
|
|
789
|
+
const dev = parseDevHttpsScript(ctx.cwd);
|
|
790
|
+
if (!dev) {
|
|
791
|
+
return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
|
|
792
|
+
}
|
|
793
|
+
const resolution = await httpsDetect.detectDomainResolution(dev.domain);
|
|
794
|
+
if (resolution.resolvesToLoopback) {
|
|
795
|
+
return {
|
|
796
|
+
status: "ok",
|
|
797
|
+
summary: `${dev.domain} → 127.0.0.1 (via ${resolution.source})`,
|
|
798
|
+
data: { domain: dev.domain, source: resolution.source },
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
status: "issue",
|
|
803
|
+
summary: `${dev.domain} doesn't resolve to 127.0.0.1 (got: ${resolution.got.join(", ") || "nothing"})`,
|
|
804
|
+
remediation: `Run \`synapse https setup ${dev.domain}\` to add the hosts entry, OR add a DNS A record pointing the domain at 127.0.0.1.`,
|
|
805
|
+
data: { domain: dev.domain, got: resolution.got },
|
|
806
|
+
};
|
|
807
|
+
}),
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const checkHttpsCertExpiry = {
|
|
811
|
+
id: "https-cert-expiry",
|
|
812
|
+
category: "local-https-dev",
|
|
813
|
+
title: "cert not expiring soon (>30 days)",
|
|
814
|
+
autoFix: "never",
|
|
815
|
+
dependsOn: ["https-cert-files"],
|
|
816
|
+
run: safeRun(async (ctx) => {
|
|
817
|
+
const dev = parseDevHttpsScript(ctx.cwd);
|
|
818
|
+
if (!dev) {
|
|
819
|
+
return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
|
|
820
|
+
}
|
|
821
|
+
const fs = require("node:fs");
|
|
822
|
+
if (!fs.existsSync(dev.cert)) {
|
|
823
|
+
return { status: "skipped", summary: "cert file missing", data: {} };
|
|
824
|
+
}
|
|
825
|
+
if (!httpsDetect.commandExists("openssl")) {
|
|
826
|
+
return { status: "skipped", summary: "openssl not available — can't check expiry", data: {} };
|
|
827
|
+
}
|
|
828
|
+
const { execFileSync } = require("node:child_process");
|
|
829
|
+
let expiry;
|
|
830
|
+
try {
|
|
831
|
+
const out = execFileSync(
|
|
832
|
+
"openssl",
|
|
833
|
+
["x509", "-in", dev.cert, "-noout", "-enddate"],
|
|
834
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
835
|
+
);
|
|
836
|
+
const m = out.match(/notAfter=(.+)/);
|
|
837
|
+
if (m) expiry = new Date(m[1]);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
return { status: "skipped", summary: `openssl read failed: ${err.message}`, data: {} };
|
|
840
|
+
}
|
|
841
|
+
if (!expiry || Number.isNaN(expiry.getTime())) {
|
|
842
|
+
return { status: "skipped", summary: "could not parse cert expiry", data: {} };
|
|
843
|
+
}
|
|
844
|
+
const daysLeft = Math.floor((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
845
|
+
if (daysLeft < 0) {
|
|
846
|
+
return {
|
|
847
|
+
status: "issue",
|
|
848
|
+
summary: `${dev.domain}: cert EXPIRED ${-daysLeft} days ago (${expiry.toISOString().slice(0, 10)})`,
|
|
849
|
+
remediation: `Regenerate: \`synapse https setup ${dev.domain} --force\`.`,
|
|
850
|
+
data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
if (daysLeft < 30) {
|
|
854
|
+
return {
|
|
855
|
+
status: "warn",
|
|
856
|
+
summary: `${dev.domain}: cert expires in ${daysLeft} days (${expiry.toISOString().slice(0, 10)})`,
|
|
857
|
+
remediation: `Regenerate soon: \`synapse https setup ${dev.domain} --force\`.`,
|
|
858
|
+
data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
status: "ok",
|
|
863
|
+
summary: `${dev.domain}: ${daysLeft} days left (expires ${expiry.toISOString().slice(0, 10)})`,
|
|
864
|
+
data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
|
|
865
|
+
};
|
|
866
|
+
}),
|
|
867
|
+
};
|
|
868
|
+
|
|
637
869
|
const ALL_CHECKS = [
|
|
638
870
|
// Tier A
|
|
639
871
|
checkNodeVersion,
|
|
@@ -653,6 +885,22 @@ const ALL_CHECKS = [
|
|
|
653
885
|
// Tier C (per deployment)
|
|
654
886
|
makeDeploymentCheck("dev"),
|
|
655
887
|
makeDeploymentCheck("prod"),
|
|
888
|
+
|
|
889
|
+
// Tier D — local HTTPS dev (v1.8.9). Context-gated: if the cwd
|
|
890
|
+
// doesn't have a dev:https script AND no certs are configured, the
|
|
891
|
+
// whole category is hidden from the report.
|
|
892
|
+
checkHttpsMkcert,
|
|
893
|
+
checkHttpsCaTrusted,
|
|
894
|
+
checkHttpsCertFiles,
|
|
895
|
+
checkHttpsDomainResolves,
|
|
896
|
+
checkHttpsCertExpiry,
|
|
656
897
|
];
|
|
657
898
|
|
|
658
|
-
module.exports = {
|
|
899
|
+
module.exports = {
|
|
900
|
+
ALL_CHECKS,
|
|
901
|
+
isBrowserReachable,
|
|
902
|
+
cmpVer,
|
|
903
|
+
// exports for tests
|
|
904
|
+
parseDevHttpsScript,
|
|
905
|
+
hasLocalHttpsContext,
|
|
906
|
+
};
|
package/lib/doctor/renderer.js
CHANGED
|
@@ -21,10 +21,19 @@ const CATEGORY_TITLE = {
|
|
|
21
21
|
project: "Project",
|
|
22
22
|
backend: "Backend",
|
|
23
23
|
deployments: "Deployments",
|
|
24
|
+
"local-https-dev": "Local HTTPS dev",
|
|
24
25
|
upstream: "Upstream",
|
|
25
26
|
workspace: "Workspace",
|
|
26
27
|
};
|
|
27
|
-
const CATEGORY_ORDER = [
|
|
28
|
+
const CATEGORY_ORDER = [
|
|
29
|
+
"local-env",
|
|
30
|
+
"project",
|
|
31
|
+
"backend",
|
|
32
|
+
"deployments",
|
|
33
|
+
"local-https-dev",
|
|
34
|
+
"upstream",
|
|
35
|
+
"workspace",
|
|
36
|
+
];
|
|
28
37
|
|
|
29
38
|
function renderHeader(report, write) {
|
|
30
39
|
const parts = ["Synapse doctor"];
|
|
@@ -74,8 +83,16 @@ function renderReport(report, { stdout, verbose = false } = {}) {
|
|
|
74
83
|
for (const cat of CATEGORY_ORDER) {
|
|
75
84
|
const rows = byCategory.get(cat);
|
|
76
85
|
if (!rows || rows.length === 0) continue;
|
|
86
|
+
// v1.8.9: filter out individual results marked `silentlySkip`
|
|
87
|
+
// (checks that decided their context is missing — e.g. `dev:https`
|
|
88
|
+
// checks in a project without that script). Then hide the
|
|
89
|
+
// category if NOTHING is left.
|
|
90
|
+
const visibleRows = rows.filter(
|
|
91
|
+
(r) => !(r.status === "skipped" && r.data && r.data.silentlySkip),
|
|
92
|
+
);
|
|
93
|
+
if (visibleRows.length === 0) continue;
|
|
77
94
|
write(` ${colors.bold(CATEGORY_TITLE[cat] ?? cat)}\n`);
|
|
78
|
-
for (const r of
|
|
95
|
+
for (const r of visibleRows) renderCheck(r, { verbose }, write);
|
|
79
96
|
write("\n");
|
|
80
97
|
}
|
|
81
98
|
|
package/lib/doctor/runner.js
CHANGED
|
@@ -39,13 +39,22 @@ async function runChecks(checks, ctx) {
|
|
|
39
39
|
return r.status === "issue" || r.status === "skipped";
|
|
40
40
|
});
|
|
41
41
|
if (failedDeps.length > 0) {
|
|
42
|
+
// v1.8.9: propagate `silentlySkip` from any silent-skipped
|
|
43
|
+
// prereq so the cascade-skipped child stays hidden too.
|
|
44
|
+
// Without this, https-cert-expiry would re-surface as a
|
|
45
|
+
// visible "·" every time its parent (https-cert-files) was
|
|
46
|
+
// silently skipped for "no dev:https script in cwd".
|
|
47
|
+
const silentParent = failedDeps.some((d) => {
|
|
48
|
+
const parent = resultsById.get(d);
|
|
49
|
+
return parent && parent.data && parent.data.silentlySkip;
|
|
50
|
+
});
|
|
42
51
|
resultsById.set(id, {
|
|
43
52
|
id,
|
|
44
53
|
category: check.category,
|
|
45
54
|
title: check.title,
|
|
46
55
|
status: "skipped",
|
|
47
56
|
summary: `skipped (prereq failed: ${failedDeps.join(", ")})`,
|
|
48
|
-
data: { skippedBecause: failedDeps },
|
|
57
|
+
data: { skippedBecause: failedDeps, silentlySkip: silentParent },
|
|
49
58
|
durationMs: 0,
|
|
50
59
|
});
|
|
51
60
|
pending.delete(id);
|
package/lib/https/hosts.js
CHANGED
|
@@ -178,6 +178,26 @@ function readHosts(hostsPath) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Best-effort DNS cache flush. On Windows the DNS Client (Dnscache)
|
|
182
|
+
// service caches lookups separately from the hosts file — editing
|
|
183
|
+
// /etc/hosts on Windows does NOT invalidate the cache, so `next dev`
|
|
184
|
+
// and Node's dns.lookup() can both return ENOTFOUND despite the
|
|
185
|
+
// entry being written correctly. This bit Matheus's Windows machine
|
|
186
|
+
// in real-world testing (v1.8.10 bug report).
|
|
187
|
+
//
|
|
188
|
+
// `ipconfig /flushdns` is safe to call without admin elevation on
|
|
189
|
+
// every Windows version since at least Windows 7, but we never let a
|
|
190
|
+
// failure here block the wider flow — it's a hint, not a guarantee.
|
|
191
|
+
function flushDnsCacheIfWindows({ execImpl = execFileSync, platform = process.platform } = {}) {
|
|
192
|
+
if (platform !== "win32") return { ran: false, reason: "non-windows platform" };
|
|
193
|
+
try {
|
|
194
|
+
execImpl("ipconfig", ["/flushdns"], { stdio: "ignore", timeout: 5000 });
|
|
195
|
+
return { ran: true };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return { ran: false, reason: err.message };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
// Writes content to the hosts file. Three strategies are tried in
|
|
182
202
|
// order based on the platform + the `elevation` knob:
|
|
183
203
|
//
|
|
@@ -191,6 +211,13 @@ function readHosts(hostsPath) {
|
|
|
191
211
|
// - "never": only try direct write; error if it fails
|
|
192
212
|
// - "always": skip the direct write attempt
|
|
193
213
|
//
|
|
214
|
+
// On Windows we additionally:
|
|
215
|
+
// - Normalise line endings to CRLF (the Windows hosts file
|
|
216
|
+
// traditionally uses CRLF; some Windows components are tolerant
|
|
217
|
+
// of LF but defensive normalisation costs nothing)
|
|
218
|
+
// - Run `ipconfig /flushdns` after a successful write so the
|
|
219
|
+
// DNS Client cache picks up the new entry immediately
|
|
220
|
+
//
|
|
194
221
|
// `writeImpl` is injected for tests.
|
|
195
222
|
function writeHosts(hostsPath, content, {
|
|
196
223
|
elevation = "auto",
|
|
@@ -198,11 +225,15 @@ function writeHosts(hostsPath, content, {
|
|
|
198
225
|
execImpl = execFileSync,
|
|
199
226
|
platform = process.platform,
|
|
200
227
|
} = {}) {
|
|
228
|
+
const onDiskContent =
|
|
229
|
+
platform === "win32" ? content.replace(/\r?\n/g, "\r\n") : content;
|
|
230
|
+
|
|
201
231
|
// Try direct write first (fast path).
|
|
232
|
+
let result;
|
|
202
233
|
if (elevation !== "always") {
|
|
203
234
|
try {
|
|
204
|
-
writeImpl(hostsPath,
|
|
205
|
-
|
|
235
|
+
writeImpl(hostsPath, onDiskContent);
|
|
236
|
+
result = { method: "direct", elevated: false };
|
|
206
237
|
} catch (err) {
|
|
207
238
|
if (elevation === "never") {
|
|
208
239
|
throw new HostsError(
|
|
@@ -214,10 +245,25 @@ function writeHosts(hostsPath, content, {
|
|
|
214
245
|
}
|
|
215
246
|
}
|
|
216
247
|
|
|
248
|
+
if (!result) {
|
|
249
|
+
if (platform === "win32") {
|
|
250
|
+
result = writeHostsViaRunAs(hostsPath, onDiskContent, { execImpl });
|
|
251
|
+
} else {
|
|
252
|
+
result = writeHostsViaSudo(hostsPath, onDiskContent, { execImpl });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Post-write DNS cache flush (best-effort, Windows only). The
|
|
257
|
+
// elevated runas path ALREADY flushes inside its PowerShell script
|
|
258
|
+
// — see writeHostsViaRunAs. Calling here covers the direct-write
|
|
259
|
+
// case (operator already in an elevated shell). Doubling up is
|
|
260
|
+
// harmless: a second flush is a no-op.
|
|
217
261
|
if (platform === "win32") {
|
|
218
|
-
|
|
262
|
+
const flush = flushDnsCacheIfWindows({ execImpl, platform });
|
|
263
|
+
result.dnsFlushed = flush.ran;
|
|
264
|
+
if (!flush.ran) result.dnsFlushReason = flush.reason;
|
|
219
265
|
}
|
|
220
|
-
return
|
|
266
|
+
return result;
|
|
221
267
|
}
|
|
222
268
|
|
|
223
269
|
// Linux/macOS elevated write. We pipe the new content into `sudo
|
|
@@ -269,12 +315,18 @@ function writeHostsViaRunAs(hostsPath, content, { execImpl = execFileSync } = {}
|
|
|
269
315
|
const tmpFile = path.join(os.tmpdir(), `synapse-hosts-${Date.now()}.txt`);
|
|
270
316
|
fs.writeFileSync(tmpFile, content);
|
|
271
317
|
const backupPath = `${hostsPath}.synapse-bak.${Date.now()}`;
|
|
272
|
-
// PowerShell script
|
|
273
|
-
// Copy
|
|
318
|
+
// PowerShell script — runs ELEVATED via Start-Process RunAs below.
|
|
319
|
+
// 1. Copy current hosts to backup
|
|
320
|
+
// 2. Move new content over hosts
|
|
321
|
+
// 3. ipconfig /flushdns (v1.8.10): WITHOUT this the Windows DNS
|
|
322
|
+
// Client cache keeps returning NXDOMAIN for the freshly-added
|
|
323
|
+
// entry, breaking `next dev --hostname dev.foo.com` with
|
|
324
|
+
// ENOTFOUND despite the hosts file being correct.
|
|
274
325
|
const script = [
|
|
275
326
|
`try {`,
|
|
276
327
|
` Copy-Item -LiteralPath '${hostsPath}' -Destination '${backupPath}' -ErrorAction Stop;`,
|
|
277
328
|
` Move-Item -LiteralPath '${tmpFile}' -Destination '${hostsPath}' -Force -ErrorAction Stop;`,
|
|
329
|
+
` & ipconfig /flushdns | Out-Null;`,
|
|
278
330
|
` exit 0`,
|
|
279
331
|
`} catch {`,
|
|
280
332
|
` Write-Error $_.Exception.Message;`,
|
|
@@ -345,6 +397,7 @@ module.exports = {
|
|
|
345
397
|
MANAGED_BLOCK_START,
|
|
346
398
|
MANAGED_BLOCK_END,
|
|
347
399
|
hostsPathForOS,
|
|
400
|
+
flushDnsCacheIfWindows,
|
|
348
401
|
planAddEntry,
|
|
349
402
|
planRemoveEntry,
|
|
350
403
|
readHosts,
|
package/lib/https/planner.js
CHANGED
|
@@ -182,6 +182,16 @@ function plan(detection, {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
// ---- Step: hosts file ----------------------------------------------
|
|
185
|
+
// Decision tree:
|
|
186
|
+
// 1. --skip-hosts → skip
|
|
187
|
+
// 2. Resolves via public DNS → skip (any machine works)
|
|
188
|
+
// 3. Hosts has entry + resolution works → skip (idempotent)
|
|
189
|
+
// 4. Hosts has entry + resolution still fails → exec ("DNS cache
|
|
190
|
+
// stale" fix — re-writing triggers ipconfig /flushdns on Windows).
|
|
191
|
+
// This is the v1.8.10 fix for the bug Matheus hit: hosts file
|
|
192
|
+
// had the entry but Windows DNS Client kept returning ENOTFOUND.
|
|
193
|
+
// 5. No entry → exec (write + flush)
|
|
194
|
+
const hostsHasEntry = detection.hosts.matches.some((m) => m.address === "127.0.0.1");
|
|
185
195
|
if (skipHosts) {
|
|
186
196
|
steps.push({
|
|
187
197
|
id: "hosts",
|
|
@@ -202,13 +212,26 @@ function plan(detection, {
|
|
|
202
212
|
skipReason:
|
|
203
213
|
"no hosts edit needed — DNS A record points at loopback (any machine on any OS resolves correctly)",
|
|
204
214
|
});
|
|
205
|
-
} else if (
|
|
215
|
+
} else if (hostsHasEntry && detection.resolution.resolvesToLoopback) {
|
|
206
216
|
steps.push({
|
|
207
217
|
id: "hosts",
|
|
208
218
|
title: "Hosts file entry",
|
|
209
219
|
kind: "skip",
|
|
210
|
-
reason: `${detection.hosts.path} already maps ${detection.domain} to 127.0.0.1`,
|
|
211
|
-
skipReason: "entry present",
|
|
220
|
+
reason: `${detection.hosts.path} already maps ${detection.domain} to 127.0.0.1 and resolution works`,
|
|
221
|
+
skipReason: "entry present + DNS cache fresh",
|
|
222
|
+
});
|
|
223
|
+
} else if (hostsHasEntry && !detection.resolution.resolvesToLoopback) {
|
|
224
|
+
steps.push({
|
|
225
|
+
id: "hosts",
|
|
226
|
+
title: `Refresh DNS cache for ${detection.domain}`,
|
|
227
|
+
kind: "exec",
|
|
228
|
+
reason:
|
|
229
|
+
detection.platform.id === "windows"
|
|
230
|
+
? `${detection.hosts.path} has the entry but Windows DNS cache is stale — re-writing to trigger \`ipconfig /flushdns\``
|
|
231
|
+
: `${detection.hosts.path} has the entry but resolution still fails — re-writing to force a refresh`,
|
|
232
|
+
async run() {
|
|
233
|
+
return hostsMod.addEntry(detection.hosts.path, detection.domain);
|
|
234
|
+
},
|
|
212
235
|
});
|
|
213
236
|
} else {
|
|
214
237
|
const needsElevation = !detection.hosts.writable;
|