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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. 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
+ }