spinuptui 0.19.0
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 +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Local working-copy auto-discovery — Phase 2.
|
|
2
|
+
//
|
|
3
|
+
// Given the user's configured scan roots (directories where they keep local
|
|
4
|
+
// copies), enumerate the immediate subdirectories and try to match each to a
|
|
5
|
+
// SpinupWP site, most-reliable signal first:
|
|
6
|
+
// 1. git remote — local `.git/config` origin URL vs the site's `git.repo`
|
|
7
|
+
// 2. WP_HOME — a Bedrock `.env`'s WP_HOME host vs the site's domain(s)
|
|
8
|
+
// 3. folder name — the directory name vs the site's domain
|
|
9
|
+
// Read-only: we read a couple of small marker files, never write or run git.
|
|
10
|
+
// Matches to already-linked sites are skipped (this proposes *new* links).
|
|
11
|
+
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { readdir, stat } from "node:fs/promises"
|
|
16
|
+
import { findProjectRoot } from "./local.ts"
|
|
17
|
+
import type { Site } from "../api/types.ts"
|
|
18
|
+
|
|
19
|
+
export type Confidence = "high" | "medium" | "low"
|
|
20
|
+
|
|
21
|
+
export interface Proposal {
|
|
22
|
+
// Path as it will be stored on the link (root form preserved, e.g. with `~`).
|
|
23
|
+
path: string
|
|
24
|
+
site: Site
|
|
25
|
+
reason: string
|
|
26
|
+
confidence: Confidence
|
|
27
|
+
// Local URL discovered from WP_HOME, if any — prefilled onto the link.
|
|
28
|
+
localUrl?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ScanResult {
|
|
32
|
+
proposals: Proposal[]
|
|
33
|
+
scannedDirs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function expand(path: string): string {
|
|
37
|
+
if (path === "~") return homedir()
|
|
38
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2))
|
|
39
|
+
return path
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize a git URL to `host/org/repo` (lowercased, no protocol/user/.git) so
|
|
43
|
+
// scp-style and https forms of the same repo compare equal.
|
|
44
|
+
export function normalizeGitUrl(url: string | null | undefined): string | null {
|
|
45
|
+
if (!url) return null
|
|
46
|
+
let s = url.trim().toLowerCase()
|
|
47
|
+
if (!s) return null
|
|
48
|
+
s = s.replace(/^ssh:\/\//, "").replace(/^https?:\/\//, "").replace(/^git@/, "")
|
|
49
|
+
s = s.replace(/\.git$/, "")
|
|
50
|
+
s = s.replace(":", "/") // scp-style host:org/repo → host/org/repo
|
|
51
|
+
return s || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pull the origin remote URL out of a local `.git/config` (no subprocess).
|
|
55
|
+
function gitOriginUrl(dir: string): string | null {
|
|
56
|
+
const cfg = join(dir, ".git", "config")
|
|
57
|
+
if (!existsSync(cfg)) return null
|
|
58
|
+
try {
|
|
59
|
+
const text = readFileSync(cfg, "utf8")
|
|
60
|
+
// Find the [remote "origin"] section, then its first `url = …`.
|
|
61
|
+
const m = text.match(/\[remote "origin"\]([\s\S]*?)(?:\n\[|$)/)
|
|
62
|
+
const url = m?.[1].match(/url\s*=\s*(.+)/)?.[1]
|
|
63
|
+
return url?.trim() ?? null
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Read WP_HOME from a Bedrock-style `.env`. Returns the full URL string.
|
|
70
|
+
function wpHome(dir: string): string | null {
|
|
71
|
+
const env = join(dir, ".env")
|
|
72
|
+
if (!existsSync(env)) return null
|
|
73
|
+
try {
|
|
74
|
+
const line = readFileSync(env, "utf8").match(/^\s*WP_HOME\s*=\s*(.+)$/m)?.[1]
|
|
75
|
+
if (!line) return null
|
|
76
|
+
return line.trim().replace(/^['"]|['"]$/g, "") || null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hostOf(url: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(url).host.toLowerCase() || null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Every domain a site answers to (primary + additional), lowercased.
|
|
91
|
+
function siteDomains(site: Site): string[] {
|
|
92
|
+
const out = [site.domain.toLowerCase()]
|
|
93
|
+
for (const d of site.additional_domains ?? []) if (d.domain) out.push(d.domain.toLowerCase())
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function scanRoots(roots: string[], sites: Site[], linkedSiteIds: Set<number>): Promise<ScanResult> {
|
|
98
|
+
// Index unlinked sites by their matchable signals.
|
|
99
|
+
const candidates = sites.filter((s) => !linkedSiteIds.has(s.id))
|
|
100
|
+
const byGit = new Map<string, Site>()
|
|
101
|
+
const byDomain = new Map<string, Site>()
|
|
102
|
+
for (const s of candidates) {
|
|
103
|
+
const g = normalizeGitUrl(s.git?.repo)
|
|
104
|
+
if (g && !byGit.has(g)) byGit.set(g, s)
|
|
105
|
+
for (const d of siteDomains(s)) if (!byDomain.has(d)) byDomain.set(d, s)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const proposals: Proposal[] = []
|
|
109
|
+
const claimed = new Set<number>() // a site is proposed at most once
|
|
110
|
+
let scannedDirs = 0
|
|
111
|
+
|
|
112
|
+
for (const root of roots) {
|
|
113
|
+
const abs = expand(root)
|
|
114
|
+
let entries: string[]
|
|
115
|
+
try {
|
|
116
|
+
entries = await readdir(abs)
|
|
117
|
+
} catch {
|
|
118
|
+
continue // unreadable/missing root — skip
|
|
119
|
+
}
|
|
120
|
+
for (const name of entries) {
|
|
121
|
+
if (name.startsWith(".")) continue
|
|
122
|
+
const absDir = join(abs, name)
|
|
123
|
+
try {
|
|
124
|
+
if (!(await stat(absDir)).isDirectory()) continue
|
|
125
|
+
} catch {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
scannedDirs++
|
|
129
|
+
// Store the plain project folder (what the user recognizes; they cd from
|
|
130
|
+
// there). Read the matching signals from its detected root, which may be a
|
|
131
|
+
// nested subdir (app/, public/, …) — found by markers, not by name.
|
|
132
|
+
const storedPath = join(root, name) // preserve the root's form (e.g. `~`)
|
|
133
|
+
const projDir = findProjectRoot(absDir)
|
|
134
|
+
const home = wpHome(projDir)
|
|
135
|
+
const localUrl = home ?? undefined
|
|
136
|
+
|
|
137
|
+
// 1) git remote
|
|
138
|
+
const g = normalizeGitUrl(gitOriginUrl(projDir))
|
|
139
|
+
let match: { site: Site; reason: string; confidence: Confidence } | null = null
|
|
140
|
+
if (g && byGit.has(g)) {
|
|
141
|
+
match = { site: byGit.get(g)!, reason: "git remote", confidence: "high" }
|
|
142
|
+
} else if (home) {
|
|
143
|
+
// 2) WP_HOME host
|
|
144
|
+
const host = hostOf(home)
|
|
145
|
+
if (host && byDomain.has(host)) match = { site: byDomain.get(host)!, reason: "WP_HOME", confidence: "high" }
|
|
146
|
+
}
|
|
147
|
+
// 3) folder name == a domain (or its first label)
|
|
148
|
+
if (!match) {
|
|
149
|
+
const base = name.toLowerCase()
|
|
150
|
+
const site =
|
|
151
|
+
byDomain.get(base) ?? candidates.find((s) => siteDomains(s).some((d) => d.split(".")[0] === base))
|
|
152
|
+
if (site) match = { site, reason: "folder name", confidence: "low" }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (match && !claimed.has(match.site.id)) {
|
|
156
|
+
claimed.add(match.site.id)
|
|
157
|
+
proposals.push({ path: storedPath, site: match.site, reason: match.reason, confidence: match.confidence, localUrl })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Alphabetical by domain (the user scans this list by site name).
|
|
163
|
+
proposals.sort((a, b) => a.site.domain.localeCompare(b.site.domain))
|
|
164
|
+
return { proposals, scannedDirs }
|
|
165
|
+
}
|
package/src/lib/dns.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// DNS zone-host detection (read-only). Answers "where is this domain's DNS zone
|
|
2
|
+
// hosted?" — the signal a developer needs when migrating or cloning a site and
|
|
3
|
+
// has to inventory every domain's DNS host.
|
|
4
|
+
//
|
|
5
|
+
// No `dig` dependency: uses Node's built-in resolver (node:dns) pointed at public
|
|
6
|
+
// resolvers, so it works the same regardless of the machine's local DNS setup.
|
|
7
|
+
//
|
|
8
|
+
// The unit is the ZONE, not the hostname. DNS host is a property of the zone
|
|
9
|
+
// apex's NS records, so we WALK UP: query NS on the name; on ENODATA/NXDOMAIN
|
|
10
|
+
// (e.g. `www` is just a CNAME in the parent zone) strip the leftmost label and
|
|
11
|
+
// retry. The first label that returns NS records IS the zone apex — no Public
|
|
12
|
+
// Suffix List needed, and it's robust to multi-label TLDs (.co.uk).
|
|
13
|
+
|
|
14
|
+
import { Resolver } from "node:dns/promises"
|
|
15
|
+
|
|
16
|
+
export interface ZoneHost {
|
|
17
|
+
apex: string // the zone apex the NS records live at (e.g. "example.com")
|
|
18
|
+
nameservers: string[] // authoritative NS hostnames, lowercased + sorted
|
|
19
|
+
providerKey: string // stable key for the host (e.g. "route53"); editable later: route53 | cloudflare
|
|
20
|
+
providerLabel: string // human label (e.g. "AWS Route 53")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Query public resolvers so results don't depend on the local resolver / VPN.
|
|
24
|
+
const PUBLIC_RESOLVERS = ["1.1.1.1", "8.8.8.8"]
|
|
25
|
+
|
|
26
|
+
function makeResolver(): Resolver {
|
|
27
|
+
const r = new Resolver({ timeout: 5000, tries: 2 })
|
|
28
|
+
try {
|
|
29
|
+
r.setServers(PUBLIC_RESOLVERS)
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall back to the system resolver if the servers can't be set.
|
|
32
|
+
}
|
|
33
|
+
return r
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Lowercase, drop a trailing dot and a leading "www." — `www` is never its own
|
|
37
|
+
// zone, so collapsing it here means www + apex share one lookup and one cache key.
|
|
38
|
+
export function normalizeDomain(domain: string): string {
|
|
39
|
+
return domain.trim().toLowerCase().replace(/\.$/, "").replace(/^www\./, "")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The website-hosting hostnames for a set of configured site domains: each domain
|
|
43
|
+
// plus its sibling apex/www variant — the records that "count" in a server
|
|
44
|
+
// migration (apex A + www CNAME/A + each additional domain). Lowercased + deduped.
|
|
45
|
+
// Unlike normalizeDomain this does NOT collapse www: www is its own record here.
|
|
46
|
+
export function candidateHostnames(domains: string[]): string[] {
|
|
47
|
+
const set = new Set<string>()
|
|
48
|
+
for (const raw of domains) {
|
|
49
|
+
const d = raw.trim().toLowerCase().replace(/\.$/, "")
|
|
50
|
+
if (!d || !d.includes(".")) continue
|
|
51
|
+
set.add(d)
|
|
52
|
+
if (d.startsWith("www.")) set.add(d.slice(4))
|
|
53
|
+
else set.add("www." + d)
|
|
54
|
+
}
|
|
55
|
+
return [...set]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The next zone candidate up. Returns null once only a single-label TLD remains
|
|
59
|
+
// (so a dead/unregistered domain can't walk all the way up to the TLD's servers).
|
|
60
|
+
function parentDomain(name: string): string | null {
|
|
61
|
+
const i = name.indexOf(".")
|
|
62
|
+
if (i === -1) return null
|
|
63
|
+
const parent = name.slice(i + 1)
|
|
64
|
+
return parent.includes(".") ? parent : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve the authoritative DNS host for a domain's zone. Returns null when no NS
|
|
68
|
+
// records can be found at or above the domain (unregistered, lookup failed, etc.).
|
|
69
|
+
export async function resolveZone(input: string): Promise<ZoneHost | null> {
|
|
70
|
+
const start = normalizeDomain(input)
|
|
71
|
+
if (!start || !start.includes(".")) return null
|
|
72
|
+
const resolver = makeResolver()
|
|
73
|
+
let name: string | null = start
|
|
74
|
+
while (name && name.includes(".")) {
|
|
75
|
+
try {
|
|
76
|
+
const ns = await resolver.resolveNs(name)
|
|
77
|
+
if (ns && ns.length > 0) {
|
|
78
|
+
const nameservers = ns.map((n) => n.toLowerCase().replace(/\.$/, "")).sort()
|
|
79
|
+
const { key, label } = labelForNameservers(nameservers)
|
|
80
|
+
return { apex: name, nameservers, providerKey: key, providerLabel: label }
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// ENODATA / ENOTFOUND / timeout → the zone is higher up; try the parent.
|
|
84
|
+
}
|
|
85
|
+
name = parentDomain(name)
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ProviderEntry {
|
|
91
|
+
match: string // lowercase substring tested against each nameserver hostname
|
|
92
|
+
key: string
|
|
93
|
+
label: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Curated nameserver → host map for the common providers. `route53` and
|
|
97
|
+
// `cloudflare` are the ones a later editing phase can write to; the rest are
|
|
98
|
+
// label-only.
|
|
99
|
+
//
|
|
100
|
+
// PRINCIPLE: only curate a host when the NS pattern maps to ONE broadly-recognized
|
|
101
|
+
// brand (Cloudflare, Route 53, GoDaddy…). For white-label / reseller platforms
|
|
102
|
+
// where a single NS domain is resold under many brands (e.g. orderbox-dns.com →
|
|
103
|
+
// ResellerClub / BigRock / …), DON'T curate — the fallback's nameserver-domain
|
|
104
|
+
// label ("orderbox-dns.com") is the most honest, universal, and actionable signal.
|
|
105
|
+
// A wrong brand label is worse than the raw domain. Extend with single-brand hosts.
|
|
106
|
+
const PROVIDERS: ProviderEntry[] = [
|
|
107
|
+
{ match: "awsdns", key: "route53", label: "AWS Route 53" },
|
|
108
|
+
{ match: "cloudflare.com", key: "cloudflare", label: "Cloudflare" },
|
|
109
|
+
{ match: "domaincontrol.com", key: "godaddy", label: "GoDaddy" },
|
|
110
|
+
{ match: "registrar-servers.com", key: "namecheap", label: "Namecheap" },
|
|
111
|
+
{ match: "nsone.net", key: "ns1", label: "NS1" },
|
|
112
|
+
{ match: "dnsmadeeasy.com", key: "dnsme", label: "DNS Made Easy" },
|
|
113
|
+
{ match: "digitalocean.com", key: "digitalocean", label: "DigitalOcean" },
|
|
114
|
+
{ match: "ns-cloud-", key: "google", label: "Google Cloud DNS" },
|
|
115
|
+
{ match: "googledomains.com", key: "google", label: "Google Domains" },
|
|
116
|
+
{ match: "worldnic.com", key: "netsol", label: "Network Solutions" },
|
|
117
|
+
{ match: "dreamhost.com", key: "dreamhost", label: "DreamHost" },
|
|
118
|
+
{ match: "name-services.com", key: "enom", label: "eNom" },
|
|
119
|
+
{ match: "azure-dns", key: "azure", label: "Azure DNS" },
|
|
120
|
+
{ match: "wpengine.com", key: "wpengine", label: "WP Engine" },
|
|
121
|
+
{ match: "linode.com", key: "linode", label: "Linode" },
|
|
122
|
+
{ match: "vultr.com", key: "vultr", label: "Vultr" },
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
// Map a set of nameservers to a host label. Falls back to the registrable domain
|
|
126
|
+
// of the nameserver itself (e.g. ns1.somehost.net → "somehost.net"), so even an
|
|
127
|
+
// uncurated provider yields exactly the signal you'd write in an inventory.
|
|
128
|
+
export function labelForNameservers(ns: string[]): { key: string; label: string } {
|
|
129
|
+
for (const host of ns) {
|
|
130
|
+
for (const p of PROVIDERS) {
|
|
131
|
+
if (host.includes(p.match)) return { key: p.key, label: p.label }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const reg = registrableOf(ns[0] ?? "")
|
|
135
|
+
return reg ? { key: reg, label: reg } : { key: "unknown", label: "Unknown" }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Naive registrable domain (last two labels) — good enough for the fallback label
|
|
139
|
+
// since nameserver hostnames are almost always provider.tld style.
|
|
140
|
+
function registrableOf(host: string): string {
|
|
141
|
+
const parts = host.replace(/\.$/, "").split(".").filter(Boolean)
|
|
142
|
+
if (parts.length <= 2) return parts.join(".")
|
|
143
|
+
return parts.slice(-2).join(".")
|
|
144
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Disk-backed cache for DNS zone-host lookups, keyed by normalized domain.
|
|
2
|
+
//
|
|
3
|
+
// Modeled on stackCache.ts (same hydrate-on-startup / write-through lifecycle).
|
|
4
|
+
// DNS host rarely changes — except during the very migration you're using this
|
|
5
|
+
// for — so entries are time-stamped: the UI shows the age and offers a refresh,
|
|
6
|
+
// and a 24h staleness window keeps long-lived caches honest across launches.
|
|
7
|
+
//
|
|
8
|
+
// Keyed by the NORMALIZED domain (www-stripped, lowercased) rather than the zone
|
|
9
|
+
// apex, so a lookup can hit the cache WITHOUT a network round-trip (we can't know
|
|
10
|
+
// a domain's apex until we resolve it). www + apex share one key; a separate
|
|
11
|
+
// subdomain that happens to share a zone just stores a duplicate entry — cheap.
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
15
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
16
|
+
import { configDir } from "../config.ts"
|
|
17
|
+
import type { ZoneHost } from "./dns.ts"
|
|
18
|
+
|
|
19
|
+
const CACHE_VERSION = 1
|
|
20
|
+
const STALE_MS = 24 * 60 * 60 * 1000 // 24h
|
|
21
|
+
|
|
22
|
+
export interface CachedDns {
|
|
23
|
+
zone: ZoneHost | null // null = looked up, no host found (unregistered / unresolvable)
|
|
24
|
+
checkedAt: number // epoch ms
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CacheFile {
|
|
28
|
+
version: number
|
|
29
|
+
entries: Record<string, CachedDns> // key: normalized domain
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function dnsCachePath(): string {
|
|
33
|
+
return join(configDir(), "dns-cache.json")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class DnsCache {
|
|
37
|
+
private entries = new Map<string, CachedDns>()
|
|
38
|
+
// Serializes writes so concurrent batch lookups can't tear the file.
|
|
39
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
40
|
+
|
|
41
|
+
// Read the cache file into memory. Never throws — a missing/corrupt file yields
|
|
42
|
+
// an empty cache.
|
|
43
|
+
load(): void {
|
|
44
|
+
try {
|
|
45
|
+
const path = dnsCachePath()
|
|
46
|
+
if (!existsSync(path)) return
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
48
|
+
if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
|
|
49
|
+
for (const [k, v] of Object.entries(parsed.entries)) {
|
|
50
|
+
if (k && v && typeof v.checkedAt === "number") this.entries.set(k, v)
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore corrupt/unreadable cache — start empty */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(key: string): CachedDns | undefined {
|
|
58
|
+
return this.entries.get(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
isStale(key: string): boolean {
|
|
62
|
+
const e = this.entries.get(key)
|
|
63
|
+
return e == null || Date.now() - e.checkedAt > STALE_MS
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Upsert a lookup result and persist immediately (write-through).
|
|
67
|
+
async set(key: string, zone: ZoneHost | null): Promise<void> {
|
|
68
|
+
this.entries.set(key, { zone, checkedAt: Date.now() })
|
|
69
|
+
await this.persist()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A copy of all entries (for rendering without exposing the map).
|
|
73
|
+
snapshot(): Map<string, CachedDns> {
|
|
74
|
+
return new Map(this.entries)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private persist(): Promise<void> {
|
|
78
|
+
this.writeChain = this.writeChain.then(() => this.writeFile()).catch(() => {})
|
|
79
|
+
return this.writeChain
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async writeFile(): Promise<void> {
|
|
83
|
+
await mkdir(configDir(), { recursive: true })
|
|
84
|
+
const file: CacheFile = { version: CACHE_VERSION, entries: {} }
|
|
85
|
+
for (const [k, entry] of this.entries) file.entries[k] = entry
|
|
86
|
+
const path = dnsCachePath()
|
|
87
|
+
await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
|
|
88
|
+
try {
|
|
89
|
+
await chmod(path, 0o600)
|
|
90
|
+
} catch {
|
|
91
|
+
/* best-effort on filesystems without POSIX perms */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Minimal DNS query over UDP (node:dgram) — no `dig`, no deps. It exists to get
|
|
2
|
+
// the one thing node's resolver can't: the TTL of ANY record type (notably CNAME)
|
|
3
|
+
// as CONFIGURED. We query a zone's AUTHORITATIVE nameserver directly, so the answer
|
|
4
|
+
// carries the full, non-decremented TTL (a recursive resolver hands back a
|
|
5
|
+
// counted-down value, which is misleading for a migration). Same hand-rolled
|
|
6
|
+
// spirit as awsSigV4.ts — we implement just enough of the protocol.
|
|
7
|
+
//
|
|
8
|
+
// Scope: A / AAAA / CNAME rdata are parsed for real (the website-hosting records
|
|
9
|
+
// the DNS inventory shows); MX / TXT are parsed for display; anything else returns
|
|
10
|
+
// its type + TTL with a placeholder value. Name compression pointers are handled.
|
|
11
|
+
|
|
12
|
+
import { createSocket } from "node:dgram"
|
|
13
|
+
import { isIP } from "node:net"
|
|
14
|
+
import { Resolver } from "node:dns/promises"
|
|
15
|
+
|
|
16
|
+
export interface DnsAnswer {
|
|
17
|
+
name: string
|
|
18
|
+
type: string // "A" | "AAAA" | "CNAME" | "MX" | "TXT" | numeric string
|
|
19
|
+
ttl: number
|
|
20
|
+
value: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type QueryType = "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS"
|
|
24
|
+
|
|
25
|
+
const TYPE_NUM: Record<QueryType, number> = { A: 1, NS: 2, CNAME: 5, MX: 15, TXT: 16, AAAA: 28 }
|
|
26
|
+
const NUM_TYPE: Record<number, string> = { 1: "A", 2: "NS", 5: "CNAME", 6: "SOA", 15: "MX", 16: "TXT", 28: "AAAA" }
|
|
27
|
+
const TIMEOUT_MS = 4000
|
|
28
|
+
|
|
29
|
+
function encodeName(name: string): Buffer {
|
|
30
|
+
const parts = name.replace(/\.$/, "").split(".").filter(Boolean)
|
|
31
|
+
const bufs = parts.map((p) => {
|
|
32
|
+
const b = Buffer.from(p, "ascii")
|
|
33
|
+
return Buffer.concat([Buffer.from([b.length]), b])
|
|
34
|
+
})
|
|
35
|
+
return Buffer.concat([...bufs, Buffer.from([0])])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildQuery(name: string, type: number): { id: number; buf: Buffer } {
|
|
39
|
+
const id = Math.floor(Math.random() * 65536)
|
|
40
|
+
const header = Buffer.alloc(12)
|
|
41
|
+
header.writeUInt16BE(id, 0)
|
|
42
|
+
header.writeUInt16BE(0x0100, 2) // standard query, recursion desired
|
|
43
|
+
header.writeUInt16BE(1, 4) // QDCOUNT = 1
|
|
44
|
+
const tail = Buffer.alloc(4)
|
|
45
|
+
tail.writeUInt16BE(type, 0)
|
|
46
|
+
tail.writeUInt16BE(1, 2) // QCLASS = IN
|
|
47
|
+
return { id, buf: Buffer.concat([header, encodeName(name), tail]) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read a (possibly compressed) name starting at `offset`. `next` is where reading
|
|
51
|
+
// continues AFTER the name in the original stream (compression pointers don't
|
|
52
|
+
// advance it past the 2-byte pointer).
|
|
53
|
+
function readName(buf: Buffer, offset: number): { name: string; next: number } {
|
|
54
|
+
const labels: string[] = []
|
|
55
|
+
let i = offset
|
|
56
|
+
let jumped = false
|
|
57
|
+
let next = offset
|
|
58
|
+
let guard = 0
|
|
59
|
+
while (guard++ < 128) {
|
|
60
|
+
const len = buf[i]
|
|
61
|
+
if (len === 0) {
|
|
62
|
+
i++
|
|
63
|
+
if (!jumped) next = i
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
if ((len & 0xc0) === 0xc0) {
|
|
67
|
+
const ptr = ((len & 0x3f) << 8) | buf[i + 1]
|
|
68
|
+
if (!jumped) next = i + 2
|
|
69
|
+
i = ptr
|
|
70
|
+
jumped = true
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
labels.push(buf.toString("ascii", i + 1, i + 1 + len))
|
|
74
|
+
i += 1 + len
|
|
75
|
+
}
|
|
76
|
+
return { name: labels.join("."), next }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseAnswers(buf: Buffer): DnsAnswer[] {
|
|
80
|
+
const qd = buf.readUInt16BE(4)
|
|
81
|
+
const an = buf.readUInt16BE(6)
|
|
82
|
+
let off = 12
|
|
83
|
+
for (let q = 0; q < qd; q++) {
|
|
84
|
+
const r = readName(buf, off)
|
|
85
|
+
off = r.next + 4 // skip QTYPE + QCLASS
|
|
86
|
+
}
|
|
87
|
+
const answers: DnsAnswer[] = []
|
|
88
|
+
for (let a = 0; a < an && off < buf.length; a++) {
|
|
89
|
+
const r = readName(buf, off)
|
|
90
|
+
off = r.next
|
|
91
|
+
const type = buf.readUInt16BE(off)
|
|
92
|
+
off += 2 // TYPE
|
|
93
|
+
off += 2 // CLASS
|
|
94
|
+
const ttl = buf.readUInt32BE(off)
|
|
95
|
+
off += 4
|
|
96
|
+
const rdlen = buf.readUInt16BE(off)
|
|
97
|
+
off += 2
|
|
98
|
+
const rdStart = off
|
|
99
|
+
let value = ""
|
|
100
|
+
if (type === 1 && rdlen === 4) {
|
|
101
|
+
value = `${buf[off]}.${buf[off + 1]}.${buf[off + 2]}.${buf[off + 3]}`
|
|
102
|
+
} else if (type === 28 && rdlen === 16) {
|
|
103
|
+
const segs: string[] = []
|
|
104
|
+
for (let k = 0; k < 16; k += 2) segs.push(buf.readUInt16BE(off + k).toString(16))
|
|
105
|
+
value = segs.join(":")
|
|
106
|
+
} else if (type === 5) {
|
|
107
|
+
value = readName(buf, off).name
|
|
108
|
+
} else if (type === 15) {
|
|
109
|
+
value = `${buf.readUInt16BE(off)} ${readName(buf, off + 2).name}`
|
|
110
|
+
} else if (type === 16) {
|
|
111
|
+
let p = off
|
|
112
|
+
const chunks: string[] = []
|
|
113
|
+
while (p < off + rdlen) {
|
|
114
|
+
const l = buf[p]
|
|
115
|
+
chunks.push(buf.toString("ascii", p + 1, p + 1 + l))
|
|
116
|
+
p += 1 + l
|
|
117
|
+
}
|
|
118
|
+
value = chunks.join("")
|
|
119
|
+
} else {
|
|
120
|
+
value = `(${rdlen}b)`
|
|
121
|
+
}
|
|
122
|
+
off = rdStart + rdlen
|
|
123
|
+
answers.push({ name: r.name, type: NUM_TYPE[type] ?? String(type), ttl, value })
|
|
124
|
+
}
|
|
125
|
+
return answers
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolve a nameserver hostname to an IP (via public resolvers), or pass an IP
|
|
129
|
+
// through unchanged.
|
|
130
|
+
async function nsIp(nsHost: string): Promise<string | null> {
|
|
131
|
+
if (isIP(nsHost)) return nsHost
|
|
132
|
+
try {
|
|
133
|
+
const r = new Resolver({ timeout: TIMEOUT_MS, tries: 1 })
|
|
134
|
+
r.setServers(["1.1.1.1", "8.8.8.8"])
|
|
135
|
+
const a = await r.resolve4(nsHost)
|
|
136
|
+
return a[0] ?? null
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function queryOnce(serverIp: string, host: string, type: number): Promise<DnsAnswer[]> {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const { id, buf } = buildQuery(host, type)
|
|
145
|
+
const sock = createSocket(isIP(serverIp) === 6 ? "udp6" : "udp4")
|
|
146
|
+
const done = (err: Error | null, ans?: DnsAnswer[]) => {
|
|
147
|
+
clearTimeout(timer)
|
|
148
|
+
try {
|
|
149
|
+
sock.close()
|
|
150
|
+
} catch {
|
|
151
|
+
/* already closed */
|
|
152
|
+
}
|
|
153
|
+
err ? reject(err) : resolve(ans!)
|
|
154
|
+
}
|
|
155
|
+
const timer = setTimeout(() => done(new Error("DNS query timed out")), TIMEOUT_MS)
|
|
156
|
+
sock.on("message", (msg) => {
|
|
157
|
+
if (msg.length < 12 || msg.readUInt16BE(0) !== id) return done(new Error("DNS id mismatch"))
|
|
158
|
+
try {
|
|
159
|
+
done(null, parseAnswers(msg))
|
|
160
|
+
} catch (e) {
|
|
161
|
+
done(e as Error)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
sock.on("error", (e) => done(e))
|
|
165
|
+
sock.send(buf, 53, serverIp)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Query a record at the zone's authoritative nameservers (first that answers).
|
|
170
|
+
// Returns the full answer section (a CNAME chain may include several records), or
|
|
171
|
+
// null when none of the nameservers respond.
|
|
172
|
+
export async function queryAuthoritative(host: string, type: QueryType, nameservers: string[]): Promise<DnsAnswer[] | null> {
|
|
173
|
+
for (const ns of nameservers) {
|
|
174
|
+
const ip = await nsIp(ns)
|
|
175
|
+
if (!ip) continue
|
|
176
|
+
const ans = await queryOnce(ip, host, TYPE_NUM[type]).catch(() => null)
|
|
177
|
+
if (ans) return ans
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|