Entries were stored in UTC without a zone marker and the Audit and Diagnostic Log pages read them as local time, so every entry showed shifted by the viewer's UTC offset (two hours early in Sweden). A shared parseDbTimestamp() now reads them as UTC, and replaces the inline workaround the Consistency page had. The Audit Log never displayed an entry's details at all, so adding an admin link showed only "server #1". Link entries now carry the server name and the label/URL, and a new Details column shows them, along with things like a port scan's address and range. Entries made within the same second are now ordered by id instead of arbitrarily. A domain's "Check now" was the one user-triggered action that wasn't audited; it is now. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
90 lines
3.9 KiB
TypeScript
90 lines
3.9 KiB
TypeScript
import { Router } from "express";
|
|
import { eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { db } from "../db/client.js";
|
|
import { domains } from "../db/schema.js";
|
|
import { requireAuth, requireRole } from "../auth/middleware.js";
|
|
import { recordAudit } from "../services/audit.js";
|
|
import { addManualDomain, checkDomain, domainStatus, isDomainCheckRunning, refreshAllDomains, type DomainRow } from "../services/domainMonitor.js";
|
|
import { getSettings } from "../services/settingsStore.js";
|
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
|
|
|
export const domainsRouter = Router();
|
|
domainsRouter.use(requireAuth);
|
|
|
|
function present(row: DomainRow, warnDays: number) {
|
|
return { ...row, ...domainStatus(row, warnDays) };
|
|
}
|
|
|
|
async function listPresented() {
|
|
const { healthChecks } = await getSettings();
|
|
const rows = await db.select().from(domains).orderBy(domains.name);
|
|
return { domains: rows.map((r) => present(r, healthChecks.domainWarnDays)), warnDays: healthChecks.domainWarnDays, checking: isDomainCheckRunning() };
|
|
}
|
|
|
|
domainsRouter.get("/", asyncHandler(async (_req, res) => {
|
|
res.json(await listPresented());
|
|
}));
|
|
|
|
const addSchema = z.object({ name: z.string().min(1).max(253) });
|
|
|
|
domainsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
|
|
const parsed = addSchema.safeParse(req.body);
|
|
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Enter a domain name.", details: parsed.error.flatten() });
|
|
|
|
const result = await addManualDomain(parsed.data.name);
|
|
if (!result.ok) return res.status(result.status).json({ error: "cannot_add", message: result.message });
|
|
|
|
await recordAudit({
|
|
actor: req.currentUser!,
|
|
category: "domain",
|
|
action: "add",
|
|
targetType: "domain",
|
|
targetId: result.row.id,
|
|
detail: { name: result.row.name, expiresAt: result.row.expiresAt },
|
|
});
|
|
const { healthChecks } = await getSettings();
|
|
res.status(201).json({ domain: present(result.row, healthChecks.domainWarnDays), resolvedFrom: result.resolvedFrom });
|
|
}));
|
|
|
|
// Registered before "/:id/..." so "refresh" isn't taken for an id.
|
|
domainsRouter.post("/refresh", requireRole("operator"), asyncHandler(async (req, res) => {
|
|
const started = await refreshAllDomains();
|
|
if (!started) return res.status(409).json({ error: "already_running", message: "A domain check is already running." });
|
|
await recordAudit({ actor: req.currentUser!, category: "domain", action: "refresh_all", targetType: "domain", detail: {} });
|
|
res.json(await listPresented());
|
|
}));
|
|
|
|
domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
|
const row = await checkDomain(id);
|
|
if (!row) return res.status(404).json({ error: "not_found" });
|
|
await recordAudit({
|
|
actor: req.currentUser!,
|
|
category: "domain",
|
|
action: "check",
|
|
targetType: "domain",
|
|
targetId: id,
|
|
detail: { name: row.name, error: row.lastCheckError },
|
|
});
|
|
const { healthChecks } = await getSettings();
|
|
res.json({ domain: present(row, healthChecks.domainWarnDays) });
|
|
}));
|
|
|
|
domainsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
|
const [row] = await db.select().from(domains).where(eq(domains.id, id)).limit(1);
|
|
if (!row) return res.status(404).json({ error: "not_found" });
|
|
if (row.origin === "zone") {
|
|
return res.status(409).json({
|
|
error: "zone_domain",
|
|
message: `${row.name} is tracked because a DNS zone for it is configured. It goes away by itself when that zone is removed.`,
|
|
});
|
|
}
|
|
await db.delete(domains).where(eq(domains.id, id));
|
|
await recordAudit({ actor: req.currentUser!, category: "domain", action: "remove", targetType: "domain", targetId: id, detail: { name: row.name } });
|
|
res.status(204).end();
|
|
}));
|