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:
1 parent
017014586f
commit
ca0fa817f8
18 files changed
+2401
-4
No files matched your search
@@ -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} />} />
|
||||
|
||||
@@ -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 } = {},
|
||||
|
||||
@@ -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} /> },
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user