Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function App() {
const [zone, setZone] = useState<Zone>(emptyZone);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
const [selectedPath, setSelectedPath] = useState<string[]>([]);
const { parseErrors, importedName, importFile, exportZone, reset } = useZoneFile();
const { parseErrors, importedName, isResolving, importFile, importFromDns, exportZone, reset } = useZoneFile();
const dns = useDnsCheck();

const issues = useMemo(() => validateZone(zone), [zone]);
Expand Down Expand Up @@ -72,6 +72,15 @@ export function App() {
}
};

const handleImportFromDns = async (resolverId: string, domain: string) => {
dns.reset();
const next = await importFromDns(resolverId, domain);
if (next) {
setZone(next);
setSelectedPath([]);
}
};

const handleNew = () => {
setZone(emptyZone());
reset();
Expand Down Expand Up @@ -127,7 +136,9 @@ export function App() {
<Topbar
canExport={errorCount === 0}
isChecking={dns.isChecking}
isResolving={isResolving}
onImport={handleImport}
onImportFromDns={handleImportFromDns}
onExport={() => exportZone(zone)}
onNew={handleNew}
onAddRecord={handleAddRecord}
Expand Down
128 changes: 128 additions & 0 deletions src/components/ImportMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { DEFAULT_RESOLVERS } from "../dns/resolvers";

interface ImportMenuProps {
onImportFile: (file: File) => void;
onImportFromDns: (resolverId: string, domain: string) => void;
isResolving: boolean;
}

export function ImportMenu({ onImportFile, onImportFromDns, isResolving }: ImportMenuProps) {
const [open, setOpen] = useState(false);
const [resolverId, setResolverId] = useState<string | null>(null);
const [domain, setDomain] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open && resolverId === null) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setResolverId(null);
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setResolverId(null);
}
};
window.addEventListener("mousedown", handler);
window.addEventListener("keydown", keyHandler);
return () => {
window.removeEventListener("mousedown", handler);
window.removeEventListener("keydown", keyHandler);
};
}, [open, resolverId]);

const handlePickResolver = (id: string) => {
setOpen(false);
setResolverId(id);
setDomain("");
};

const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = domain.trim();
if (!trimmed || !resolverId) return;
onImportFromDns(resolverId, trimmed);
setResolverId(null);
setDomain("");
};

const activeResolver = resolverId
? DEFAULT_RESOLVERS.find((r) => r.id === resolverId)
: null;

return (
<div className="import-menu" ref={containerRef}>
<button
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
disabled={isResolving}
>
{isResolving ? "Resolving…" : "Import…"}
</button>
<input
ref={fileInput}
type="file"
accept=".zone,.db,.txt,text/plain"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImportFile(f);
e.target.value = "";
setOpen(false);
}}
/>

{open && (
<div className="import-dropdown" role="menu">
<button type="button" role="menuitem" onClick={() => fileInput.current?.click()}>
From file…
</button>
<div className="dropdown-divider" />
<div className="dropdown-label">From public DNS</div>
{DEFAULT_RESOLVERS.map((r) => (
<button
key={r.id}
type="button"
role="menuitem"
onClick={() => handlePickResolver(r.id)}
>
<span>{r.name}</span>
<span className="region">{r.region}</span>
</button>
))}
</div>
)}

{activeResolver && (
<form className="dns-import-form" onSubmit={handleSubmit}>
<span className="hint">Resolve from {activeResolver.name}:</span>
<input
autoFocus
type="text"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="example.com"
/>
<button type="submit" className="primary" disabled={!domain.trim()}>
Resolve
</button>
<button
type="button"
onClick={() => {
setResolverId(null);
setDomain("");
}}
>
Cancel
</button>
</form>
)}
</div>
);
}
23 changes: 9 additions & 14 deletions src/components/Topbar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useRef } from "react";
import { GitHubLink } from "./GitHubLink";
import { ImportMenu } from "./ImportMenu";
import { Logo } from "./Logo";

interface TopbarProps {
canExport: boolean;
isChecking: boolean;
isResolving: boolean;
onImport: (file: File) => void;
onImportFromDns: (resolverId: string, domain: string) => void;
onExport: () => void;
onNew: () => void;
onAddRecord: () => void;
Expand All @@ -16,33 +18,26 @@ interface TopbarProps {
export function Topbar({
canExport,
isChecking,
isResolving,
onImport,
onImportFromDns,
onExport,
onNew,
onAddRecord,
onCheck,
onCancelCheck,
}: TopbarProps) {
const fileInput = useRef<HTMLInputElement>(null);

return (
<header className="topbar">
<div className="brand">
<Logo />
<h1>Codabind</h1>
</div>
<div className="actions">
<button onClick={() => fileInput.current?.click()}>Import…</button>
<input
ref={fileInput}
type="file"
accept=".zone,.db,.txt,text/plain"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImport(f);
e.target.value = "";
}}
<ImportMenu
onImportFile={onImport}
onImportFromDns={onImportFromDns}
isResolving={isResolving}
/>
<button
onClick={onExport}
Expand Down
152 changes: 152 additions & 0 deletions src/dns/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { RecordType, Zone, ZoneRecord } from "../zone/types";
import { query } from "./query";
import { DEFAULT_RESOLVERS } from "./resolvers";
import type { DohAnswer } from "./types";

const APEX_TYPES: RecordType[] = ["SOA", "NS", "A", "AAAA", "MX", "TXT", "CAA"];
const SUBDOMAIN_PROBES: Array<{ name: string; types: RecordType[] }> = [
{ name: "www", types: ["A", "AAAA", "CNAME"] },
{ name: "_dmarc", types: ["TXT"] },
{ name: "mail", types: ["A", "AAAA", "CNAME"] },
{ name: "autodiscover", types: ["CNAME"] },
];

export interface DnsImportResult {
zone: Zone;
errors: string[];
}

export async function importFromDns(
resolverId: string,
rawDomain: string,
signal?: AbortSignal,
): Promise<DnsImportResult> {
const resolver = DEFAULT_RESOLVERS.find((r) => r.id === resolverId);
if (!resolver) {
return { zone: emptyZoneFor(rawDomain), errors: [`Unknown resolver: ${resolverId}`] };
}

const cleanDomain = rawDomain.trim().replace(/^\.+|\.+$/g, "").toLowerCase();
if (!cleanDomain) {
return { zone: emptyZoneFor(""), errors: ["Domain is required"] };
}

const origin = `${cleanDomain}.`;
const errors: string[] = [];
const records: ZoneRecord[] = [];

// Apex records — single name "@", multiple RR types
await Promise.all(
APEX_TYPES.map(async (type) => {
const resp = await query(resolver, origin, type, signal);
if (resp.status === "error") {
errors.push(`${type} @ ${cleanDomain}: ${resp.errorMessage ?? "fetch failed"}`);
return;
}
for (const ans of resp.answers) {
const rec = answerToRecord("@", ans, type);
if (rec) records.push(rec);
}
}),
);

// Best-effort subdomain probes
await Promise.all(
SUBDOMAIN_PROBES.flatMap(({ name, types }) =>
types.map(async (type) => {
const fqdn = `${name}.${origin}`;
const resp = await query(resolver, fqdn, type, signal);
if (resp.status !== "ok") return;
for (const ans of resp.answers) {
const rec = answerToRecord(name, ans, type);
if (rec) records.push(rec);
}
}),
),
);

const ttl = pickZoneTtl(records);
return { zone: { origin, ttl, records }, errors };
}

function answerToRecord(name: string, ans: DohAnswer, type: RecordType): ZoneRecord | null {
const id = crypto.randomUUID();
const ttl = String(ans.TTL);
const cls = "IN";
const parts = ans.data.split(/\s+/);

switch (type) {
case "A":
case "AAAA":
return { id, name, ttl, class: cls, record: { type, data: { address: ans.data } } };
case "NS":
case "CNAME":
case "PTR":
return { id, name, ttl, class: cls, record: { type, data: { target: ans.data } } };
case "MX":
return {
id, name, ttl, class: cls,
record: { type: "MX", data: { priority: parts[0] ?? "10", target: parts.slice(1).join(" ") } },
};
case "TXT":
return { id, name, ttl, class: cls, record: { type: "TXT", data: { text: stripOuterQuotes(ans.data) } } };
case "SOA":
return {
id, name, ttl, class: cls,
record: {
type: "SOA",
data: {
mname: parts[0] ?? "",
rname: parts[1] ?? "",
serial: parts[2] ?? "",
refresh: parts[3] ?? "",
retry: parts[4] ?? "",
expire: parts[5] ?? "",
minimum: parts[6] ?? "",
},
},
};
case "SRV":
return {
id, name, ttl, class: cls,
record: {
type: "SRV",
data: {
priority: parts[0] ?? "",
weight: parts[1] ?? "",
port: parts[2] ?? "",
target: parts.slice(3).join(" "),
},
},
};
case "CAA":
return {
id, name, ttl, class: cls,
record: {
type: "CAA",
data: {
flags: parts[0] ?? "0",
tag: parts[1] ?? "issue",
value: stripOuterQuotes(parts.slice(2).join(" ")),
},
},
};
}
}

function pickZoneTtl(records: ZoneRecord[]): string {
const soa = records.find((r) => r.record.type === "SOA");
return soa?.ttl || "3600";
}

function emptyZoneFor(domain: string): Zone {
const cleaned = domain.trim().replace(/^\.+|\.+$/g, "");
return { origin: cleaned ? `${cleaned}.` : "", ttl: "3600", records: [] };
}

function stripOuterQuotes(s: string): string {
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
return s;
}
Loading
Loading