Track domain registration expiry, with daily reminders

New Domains page listing when each domain registration expires, read from
the registry. Domains behind the DNS zones already synced are picked up
automatically; others can be added by hand. You're reminded daily from N
days before expiry (Settings > Notifications, default 30) until it's
renewed, and told when an expiry date hasn't been refreshable for several
days so a stale date isn't trusted silently.

RDAP alone would not have covered this homelab: .se, .nu, .io, .eu and .de
are not in IANA's RDAP bootstrap. Lookups therefore try RDAP where the TLD
publishes a server and fall back to WHOIS on port 43, found via IANA's
own referral, parsing the expiry line out of the free-text answer. Only
the expiry date and registrar are read or stored. Verified live against
the real registries: .se and .nu via WHOIS, .com/.org/.dev via RDAP.

Behaviour worth knowing:
- A DNS zone that is a subdomain (lab.example.se) resolves to the
  registration that actually expires by trying the name and then its
  parents, so no public-suffix list is needed. Zones already covered by a
  tracked domain are not looked up again.
- "Couldn't ask" is never confused with "not registered": network errors,
  rate limits and garbled answers are errors, and a transient error at any
  level stops the walk from concluding the domain doesn't exist.
- A failed refresh keeps the last known expiry and records why, rather
  than blanking a date that's still relied on.
- Zones that don't resolve to a real registration (.lan, .local, unregistered
  names) simply get no row. Zone-derived rows disappear when their zone
  does; manual rows stay. Zone-derived rows can't be deleted by hand.
- Registries that don't publish an expiry (.de, .eu) are tracked with a
  note instead of a date.
- Input like "example.com/path" is refused rather than silently reduced
  to its host.
- Runs on the daily secret-expiry schedule and reminder time, on demand
  (Check all now / per domain), and once at startup if nothing has been
  read in a day. Never blocks startup, one lookup at a time with a pause.
  The warning window lives with the other thresholds in settings.

New table domains (migration 0011); two settings fields (toggle and
warning days).

Verified with 90 checks against fake RDAP/WHOIS backends (name
normalization, date formats, WHOIS parsing including rate-limit and
no-expiry answers, bootstrap and referral caching, stale-cache fallback,
parent walking, add/sync/check/refresh, concurrency guard, alert
selection and stale detection, the daily notification and its toggle,
role rules) plus a live smoke test against real registries and a browser
check of the page against the real router. Real dev database mtime
untouched. Not checked: a screenshot of the finished page (the capture
timed out); structure, sorting, errors and the viewer view were verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-26 03:31:02 +02:00
1 parent 017014586f
commit ca0fa817f8
18 files changed
+2401 -4

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ import Proxmox from "./pages/Proxmox";
import Synology from "./pages/Synology";
import Generator from "./pages/Generator";
import Maintenance from "./pages/Maintenance";
import Domains from "./pages/Domains";
import Settings from "./pages/Settings";
import NotificationSettings from "./pages/settings/NotificationSettings";
import BadgeSettings from "./pages/settings/BadgeSettings";
@@ -89,6 +90,7 @@ export default function App() {
<Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} />
<Route path="/domains" element={<Domains user={user} />} />
<Route path="/maintenance" element={<Maintenance user={user} />} />
<Route path="/docker" element={<Docker user={user} />} />
<Route path="/tailscale" element={<Tailscale user={user} />} />
+31
View File
@@ -184,6 +184,7 @@ export interface NotificationEvents {
proxmoxBackupCheck: boolean;
healthAlerts: boolean;
automationAlerts: boolean;
domainExpiryCheck: boolean;
secretCheckTime: string;
timezone: string;
integrationFailureAlerts: boolean;
@@ -208,6 +209,7 @@ export interface LogRetentionSettings {
export interface HealthCheckSettings {
serverOfflineMinutes: number;
diskUsagePercent: number;
domainWarnDays: number;
}
export interface QuietHoursSettings {
@@ -490,6 +492,27 @@ export interface GiteaRepo {
latestRun: GiteaWorkflowRun | null;
}
export interface DomainRecord {
id: number;
name: string;
origin: "manual" | "zone";
expiresAt: string | null;
registrar: string | null;
lookupSource: "rdap" | "whois" | null;
lastCheckedAt: string | null;
lastCheckedOkAt: string | null;
lastCheckError: string | null;
createdAt: string;
status: "ok" | "expiring" | "expired" | "unknown";
daysLeft: number | null;
}
export interface DomainList {
domains: DomainRecord[];
warnDays: number;
checking: boolean;
}
export interface DockhandContainer {
id: string;
name: string;
@@ -854,6 +877,14 @@ export const api = {
remove: (id: number, portId: number) => request<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
},
},
domains: {
list: () => request<DomainList>("/api/domains"),
add: (name: string) =>
request<{ domain: DomainRecord; resolvedFrom: string | null }>("/api/domains", { method: "POST", body: JSON.stringify({ name }) }),
refresh: () => request<DomainList>("/api/domains/refresh", { method: "POST" }),
check: (id: number) => request<{ domain: DomainRecord }>(`/api/domains/${id}/check`, { method: "POST" }),
remove: (id: number) => request<void>(`/api/domains/${id}`, { method: "DELETE" }),
},
tasks: {
list: (
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
+2
View File
@@ -23,6 +23,7 @@ import {
IconMoon,
IconWand,
IconTool,
IconWorldWww,
} from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration";
@@ -44,6 +45,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
{ to: "/domains", label: "Domains", icon: <IconWorldWww size={20} /> },
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
+246
View File
@@ -0,0 +1,246 @@
import { useEffect, useState } from "react";
import { api, type CurrentUser, type DomainList, type DomainRecord } from "../api/client";
import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date";
import { formatAgo } from "../utils/duration";
import { readableError } from "../utils/errors";
import { useSortable } from "../hooks/useSortable";
import { usePagination } from "../hooks/usePagination";
import SortableTh from "../components/SortableTh";
import Pagination from "../components/Pagination";
function statusBadge(d: DomainRecord) {
switch (d.status) {
case "expired":
return <span className="badge bg-red-lt text-red">Expired {Math.abs(d.daysLeft ?? 0)} d ago</span>;
case "expiring":
return <span className="badge bg-yellow-lt text-yellow">{d.daysLeft} d left</span>;
case "ok":
return <span className="badge bg-green-lt text-green">{d.daysLeft} d left</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">Unknown</span>;
}
}
export default function Domains({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [data, setData] = useState<DomainList | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [name, setName] = useState("");
const [adding, setAdding] = useState(false);
const [refreshingAll, setRefreshingAll] = useState(false);
const [checkingId, setCheckingId] = useState<number | null>(null);
useEffect(() => {
api.domains
.list()
.then(setData)
.catch((err) => setError(readableError(err)));
}, []);
const { sorted, sortKey, sortDir, requestSort } = useSortable(data?.domains, "expiresAt");
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
async function add(e: React.FormEvent) {
e.preventDefault();
setError(null);
setNotice(null);
setAdding(true);
try {
const res = await api.domains.add(name.trim());
setName("");
if (res.resolvedFrom) setNotice(`${res.resolvedFrom} is part of ${res.domain.name}, so that's the registration being tracked.`);
setData(await api.domains.list());
} catch (err) {
setError(readableError(err));
} finally {
setAdding(false);
}
}
async function refreshAll() {
setError(null);
setNotice(null);
setRefreshingAll(true);
try {
setData(await api.domains.refresh());
} catch (err) {
setError(readableError(err));
} finally {
setRefreshingAll(false);
}
}
async function check(d: DomainRecord) {
setError(null);
setNotice(null);
setCheckingId(d.id);
try {
await api.domains.check(d.id);
setData(await api.domains.list());
} catch (err) {
setError(readableError(err));
} finally {
setCheckingId(null);
}
}
async function remove(d: DomainRecord) {
if (!confirm(`Stop tracking ${d.name}?`)) return;
setError(null);
try {
await api.domains.remove(d.id);
setData(await api.domains.list());
} catch (err) {
setError(readableError(err));
}
}
function exportCsv() {
if (!sorted) return;
downloadCsv(
"domains.csv",
["Domain", "Expires", "Days left", "Registrar", "Source", "Origin", "Last checked", "Problem"],
sorted.map((d) => [
d.name,
d.expiresAt ?? "",
d.daysLeft ?? "",
d.registrar ?? "",
d.lookupSource ?? "",
d.origin === "zone" ? "DNS zone" : "manual",
d.lastCheckedAt ? formatDateTime(new Date(d.lastCheckedAt)) : "",
d.lastCheckError ?? "",
]),
);
}
return (
<>
<div className="d-flex align-items-center mb-3">
<h2 className="page-title mb-0">Domains</h2>
{canEdit && (
<button className="btn btn-outline-secondary ms-auto" onClick={refreshAll} disabled={refreshingAll}>
{refreshingAll ? "Checking…" : "Check all now"}
</button>
)}
</div>
<div className="text-secondary mb-3">
When each domain registration expires, read from the registry itself. Domains for the DNS zones you've added show up
here automatically; add any others by hand.
</div>
{error && <div className="alert alert-danger">{error}</div>}
{notice && <div className="alert alert-info">{notice}</div>}
{canEdit && (
<form onSubmit={add} className="card mb-3">
<div className="card-body row g-2 align-items-end">
<div className="col-md-6">
<label className="form-label">Track another domain</label>
<input
className="form-control"
required
placeholder="e.g. example.se"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="col-md-3">
<button type="submit" className="btn btn-primary" disabled={adding || !name.trim()}>
{adding ? "Looking it up…" : "Add domain"}
</button>
</div>
</div>
</form>
)}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Registrations</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<DomainRecord> label="Domain" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DomainRecord> label="Expires" sortKeyName="expiresAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DomainRecord> label="Registrar" sortKeyName="registrar" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DomainRecord> label="Last checked" sortKeyName="lastCheckedAt" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{pageItems?.map((d) => (
<tr key={d.id}>
<td>
{d.name}
<span className="badge bg-secondary-lt text-secondary ms-2">{d.origin === "zone" ? "DNS zone" : "Manual"}</span>
</td>
<td>
{d.expiresAt ? (
<>
{statusBadge(d)} <span className="text-secondary small ms-1">{d.expiresAt}</span>
</>
) : (
<span className="text-secondary">—</span>
)}
</td>
<td className="text-secondary">
{d.registrar ?? "—"}
{d.lookupSource && <span className="small ms-1">· {d.lookupSource.toUpperCase()}</span>}
</td>
<td>
{d.lastCheckedAt ? (
<span className="text-secondary" title={formatDateTime(new Date(d.lastCheckedAt))}>
{formatAgo(d.lastCheckedAt)}
</span>
) : (
<span className="text-secondary">not yet</span>
)}
{d.lastCheckError && <div className={`small ${d.expiresAt ? "text-warning" : "text-secondary"}`}>{d.lastCheckError}</div>}
</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm btn-outline-secondary" onClick={() => check(d)} disabled={checkingId === d.id}>
{checkingId === d.id ? "Checking…" : "Check now"}
</button>
{d.origin === "manual" && (
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(d)}>
Remove
</button>
)}
</div>
</td>
)}
</tr>
))}
{data && data.domains.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No domains tracked yet. Ones for your DNS zones appear after the next daily check
{canEdit ? " — or use “Check all now”" : ""}.
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
{data && (
<div className="text-secondary small">
Expiry is read daily from the registry — over RDAP where the TLD offers it, otherwise from its WHOIS server (.se and
.nu, for example). You're reminded every day from {data.warnDays} days before a domain expires until it's renewed. A
registry that doesn't publish expiry dates (.de, .eu) can be tracked but has no date to warn about.
</div>
)}
</>
);
}
@@ -48,13 +48,14 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
proxmoxBackupCheck: true,
healthAlerts: true,
automationAlerts: true,
domainExpiryCheck: true,
secretCheckTime: "08:00",
timezone: "UTC",
integrationFailureAlerts: true,
integrationFailureThreshold: 3,
};
const DEFAULT_QUIET_HOURS: QuietHoursSettings = { enabled: false, start: "22:00", end: "07:00" };
const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90 };
const DEFAULT_HEALTH_CHECKS: HealthCheckSettings = { serverOfflineMinutes: 60, diskUsagePercent: 90, domainWarnDays: 30 };
type TestResult = { ok: boolean; message: string } | null;
@@ -527,6 +528,7 @@ export default function NotificationSettings() {
{ key: "dockerUpdateCheck" as const, label: "Docker image update available" },
{ key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed or a guest has no coverage" },
{ key: "healthAlerts" as const, label: "Server offline, disk nearly full, or Synology volume/disk problem" },
{ key: "domainExpiryCheck" as const, label: "Domain registration expiring or expired (daily reminder)" },
{ key: "automationAlerts" as const, label: "Semaphore template or Gitea workflow run failed" },
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
].map(({ key, label }) => (
@@ -593,10 +595,30 @@ export default function NotificationSettings() {
</div>
<div className="form-hint">Servers, Proxmox storage, Synology volumes. Checked every 15 min; a recovery notice follows.</div>
</div>
<div className="col-6">
<label className="form-label">Domain expiry warning</label>
<div className="input-group">
<input
type="number"
className="form-control"
min={1}
max={365}
value={healthChecks.domainWarnDays}
disabled={!notifications.domainExpiryCheck}
onChange={(e) => setHealthChecks((h) => ({ ...h, domainWarnDays: Number(e.target.value) }))}
/>
<span className="input-group-text">days before</span>
</div>
<div className="form-hint">Reminded daily from then until it's renewed.</div>
</div>
</div>
{(() => {
const dailyChecksEnabled =
notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck;
notifications.secretCheck ||
notifications.tailscaleKeyCheck ||
notifications.dockerUpdateCheck ||
notifications.proxmoxBackupCheck ||
notifications.domainExpiryCheck;
return (
<div className="row g-2 mt-2">
<div className="col-6">