diff --git a/README.md b/README.md index 18544df..a1fbca7 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,16 @@ which can be filtered by one or several tags (the filter is in the URL, so a tag on a server's page links to everything sharing it), and they're searchable from the global search box. +**Privacy** — a page every signed-in user can open that says what this +installation stores (accounts, sign-in sessions with their IP and browser, the audit +and diagnostic logs, server reports, the secrets tracker, credentials), where data +goes (Authentik, your integrations and DNS providers, the notification channels +that are switched on, domain registries), what's kept in the browser, who can see +what, and how to limit or remove data. Retention, integrations and channels are +read live from the installation; channel addresses are shown to admins only. Each +user sees their own account and sign-ins there and can download their own data +(account, sign-ins, audit-log entries) as a JSON file. + **Consistency** — a report of where IPAM, DNS and your servers disagree about an address: the same address on two servers, a DNS record named after a server that points somewhere it isn't, an IPAM entry labelled with a server's name at diff --git a/server/src/index.ts b/server/src/index.ts index 4ff9f51..29bc161 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -26,6 +26,7 @@ import { sessionsRouter } from "./routes/sessions.js"; import { maintenanceRouter } from "./routes/maintenance.js"; import { domainsRouter } from "./routes/domains.js"; import { consistencyRouter } from "./routes/consistency.js"; +import { privacyRouter } from "./routes/privacy.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -96,6 +97,7 @@ app.use("/api/sessions", sessionsRouter); app.use("/api/maintenance", maintenanceRouter); app.use("/api/domains", domainsRouter); app.use("/api/consistency", consistencyRouter); +app.use("/api/privacy", privacyRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/privacy.ts b/server/src/routes/privacy.ts new file mode 100644 index 0000000..fe3b525 --- /dev/null +++ b/server/src/routes/privacy.ts @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { count, eq, isNotNull } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { auditLog, dnsProviders, domains, integrations, secrets, servers, users } from "../db/schema.js"; +import { requireAuth } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { getSettings } from "../services/settingsStore.js"; +import { listSessions } from "../services/sessionStore.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const privacyRouter = Router(); +privacyRouter.use(requireAuth); + +function hostOf(url: string): string | null { + try { + return new URL(url).host || null; + } catch { + return null; + } +} + +/** The signed-in user's own sessions. The session id and the stored ID token are deliberately not passed on. */ +async function ownSessions(sub: string, currentSessionId: string) { + return (await listSessions()) + .filter((s) => s.sub === sub) + .map((s) => ({ ip: s.ip, userAgent: s.userAgent, lastAccess: s.lastAccess, expiresAt: s.expiresAt, current: s.id === currentSessionId })); +} + +privacyRouter.get("/", asyncHandler(async (req, res) => { + const me = req.currentUser!; + const isAdmin = me.role === "admin"; + const settings = await getSettings(); + + const [{ n: auditEntries }] = await db.select({ n: count() }).from(auditLog).where(eq(auditLog.actorUserId, me.id)); + const integrationRows = await db.select({ type: integrations.type }).from(integrations).where(eq(integrations.enabled, true)); + const integrationTypes = [...new Set(integrationRows.map((r) => r.type))].sort(); + const dnsRows = await db.select({ type: dnsProviders.providerType }).from(dnsProviders).where(eq(dnsProviders.enabled, true)); + const dnsTypes = [...new Set(dnsRows.map((r) => r.type))].sort(); + const [{ n: domainCount }] = await db.select({ n: count() }).from(domains); + const [{ n: tlsChecks }] = await db.select({ n: count() }).from(secrets).where(isNotNull(secrets.checkHost)); + const [{ n: serverCount }] = await db.select({ n: count() }).from(servers); + const [{ n: agentCount }] = await db.select({ n: count() }).from(servers).where(isNotNull(servers.lastSeenAt)); + + // Where a notification goes is infrastructure detail — every signed-in user is told a channel is on, only admins see the address. + const channel = (enabled: boolean, host: string | null) => ({ enabled, host: isAdmin ? host : null }); + + res.json({ + me: { + user: { email: me.email, name: me.name, role: me.role, subject: me.oidcSub, createdAt: me.createdAt, lastLoginAt: me.lastLoginAt }, + auditEntries, + sessions: await ownSessions(me.oidcSub, req.sessionID), + }, + retention: settings.logRetention, + outbound: { + integrationTypes, + dnsProviderTypes: dnsTypes, + domainsTracked: domainCount, + tlsCertificateChecks: tlsChecks, + servers: serverCount, + serversWithAgent: agentCount, + channels: { + gotify: channel(settings.gotify.enabled, hostOf(settings.gotify.url)), + ntfy: channel(settings.ntfy.enabled, hostOf(settings.ntfy.url)), + smtp: channel(settings.smtp.enabled, settings.smtp.host || null), + webhook: channel(settings.webhook.enabled, hostOf(settings.webhook.url)), + }, + }, + }); +})); + +// Everything the app holds that is specifically about the signed-in user, as a file. Only their own — never another user's. +privacyRouter.get("/export", asyncHandler(async (req, res) => { + const me = req.currentUser!; + const [row] = await db.select().from(users).where(eq(users.id, me.id)).limit(1); + const entries = await db + .select({ createdAt: auditLog.createdAt, category: auditLog.category, action: auditLog.action, targetType: auditLog.targetType, targetId: auditLog.targetId, detail: auditLog.detail }) + .from(auditLog) + .where(eq(auditLog.actorUserId, me.id)) + .orderBy(auditLog.id); + + await recordAudit({ actor: me, category: "privacy", action: "export_own_data", targetType: "user", targetId: me.id }); + + const body = { + exportedAt: new Date().toISOString(), + note: "Everything Homelab Manager holds that is specifically about you. Other people's data, server data and shared inventory are not included.", + account: { email: row.email, name: row.name, role: row.role, subject: row.oidcSub, createdAt: row.createdAt, lastLoginAt: row.lastLoginAt }, + sessions: await ownSessions(me.oidcSub, req.sessionID), + auditLog: entries.map((e) => ({ ...e, detail: e.detail ? safeJson(e.detail) : null })), + }; + res.setHeader("Content-Disposition", 'attachment; filename="homelab-manager-my-data.json"'); + res.json(body); +})); + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index bd17fd3..8f93ae2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,7 @@ import Generator from "./pages/Generator"; import Maintenance from "./pages/Maintenance"; import Domains from "./pages/Domains"; import Consistency from "./pages/Consistency"; +import Privacy from "./pages/Privacy"; import Settings from "./pages/Settings"; import NotificationSettings from "./pages/settings/NotificationSettings"; import BadgeSettings from "./pages/settings/BadgeSettings"; @@ -91,6 +92,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3c4ab63..38bc6a0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -552,6 +552,24 @@ export interface ConsistencyReport { generatedAt: string; } +export interface PrivacyOverview { + me: { + user: { email: string | null; name: string | null; role: UserRole; subject: string; createdAt: string; lastLoginAt: string | null }; + auditEntries: number; + sessions: { ip: string | null; userAgent: string | null; lastAccess: string; expiresAt: string | null; current: boolean }[]; + }; + retention: { enabled: boolean; retentionDays: number; intervalHours: number }; + outbound: { + integrationTypes: string[]; + dnsProviderTypes: string[]; + domainsTracked: number; + tlsCertificateChecks: number; + servers: number; + serversWithAgent: number; + channels: Record<"gotify" | "ntfy" | "smtp" | "webhook", { enabled: boolean; host: string | null }>; + }; +} + export interface DockhandContainer { id: string; name: string; @@ -916,6 +934,14 @@ export const api = { remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), }, }, + privacy: { + overview: () => request("/api/privacy"), + exportOwnData: async (): Promise => { + const res = await fetch("/api/privacy/export", { credentials: "same-origin" }); + if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text().catch(() => "")}`); + return res.blob(); + }, + }, consistency: { report: () => request("/api/consistency"), ignore: (key: string, reason?: string) => diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 200e332..fd06df4 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -25,6 +25,7 @@ import { IconTool, IconWorldWww, IconListCheck, + IconShieldLock, } from "@tabler/icons-react"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { formatRemaining } from "../utils/duration"; @@ -55,6 +56,7 @@ const NAV_ITEMS: NavItem[] = [ { to: "/integrations", label: "Integrations", icon: }, { to: "/generator", label: "Generator", icon: }, { to: "/maintenance", label: "Maintenance", icon: }, + { to: "/privacy", label: "Privacy", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, diff --git a/web/src/pages/Privacy.tsx b/web/src/pages/Privacy.tsx new file mode 100644 index 0000000..0f63d31 --- /dev/null +++ b/web/src/pages/Privacy.tsx @@ -0,0 +1,324 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type PrivacyOverview } from "../api/client"; +import { formatDateTime } from "../utils/date"; +import { formatAgo } from "../utils/duration"; +import { readableError } from "../utils/errors"; + +const CHANNEL_LABELS = { gotify: "Gotify", ntfy: "ntfy", smtp: "email (SMTP)", webhook: "webhook" } as const; + +/** SQLite timestamps ("2026-09-26 04:07:21") are UTC without a zone marker. */ +function parseTime(value: string): Date { + return new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`); +} + +export default function Privacy() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + api.privacy + .overview() + .then(setData) + .catch((err) => setError(readableError(err))); + }, []); + + async function download() { + setExporting(true); + setError(null); + try { + const blob = await api.privacy.exportOwnData(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "homelab-manager-my-data.json"; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(readableError(err)); + } finally { + setExporting(false); + } + } + + const retention = data?.retention; + const outbound = data?.outbound; + const enabledChannels = outbound ? (Object.keys(CHANNEL_LABELS) as (keyof typeof CHANNEL_LABELS)[]).filter((c) => outbound.channels[c].enabled) : []; + + return ( + <> +

Privacy

+
+
+ Homelab Manager runs on your own server, and what it stores stays there unless it's listed under{" "} + Where data goes. It has no analytics or telemetry, loads no third-party scripts, fonts or + trackers, and sets no tracking or advertising cookies. This page describes what it actually does and shows live values + for this installation. +
+
+ {error &&
{error}
} + + {data && ( +
+
+

About you

+
+ +
+
+
+
+
Name
+
{data.me.user.name ?? "—"}
+
Email
+
{data.me.user.email ?? "—"}
+
Role
+
{data.me.user.role}
+
Sign-in ID
+
+ {data.me.user.subject} +
+
First signed in
+
{formatDateTime(parseTime(data.me.user.createdAt))}
+
Last signed in
+
{data.me.user.lastLoginAt ? formatDateTime(parseTime(data.me.user.lastLoginAt)) : "—"}
+
Recorded actions
+
+ {data.me.auditEntries} change{data.me.auditEntries === 1 ? "" : "s"} you made are in the audit log under your name +
+
+
+ Your name, email and sign-in ID come from Authentik and are refreshed each time you sign in. +
+
+
+
Your active sign-ins
+
+ + + + + + + + + + + {data.me.sessions.map((s, i) => ( + + + + + + + ))} + {data.me.sessions.length === 0 && ( + + + + )} + +
FromBrowserLast activeEnds
+ {s.ip ?? "—"} {s.current && this browser} + {s.userAgent ?? "—"}{formatAgo(s.lastAccess)}{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"}
+ No active sign-ins found. +
+
+
+ “Download my data” gives you these details and every audit-log entry made under your account as a file — only yours. +
+
+
+ )} + +
+
+

What is stored

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhatContainsKept
AccountsSign-in ID, email and name (from Authentik), role, first and last sign-in.Until an administrator removes the account from the database — the app can change roles but has no delete-account function.
Sign-in sessions + Your sign-in ID, email and name, the ID token Authentik issued (needed to sign you out there), and the IP address and + browser name from the sign-in. + 7 days after sign-in, or until you sign out or an administrator ends the session.
Audit logWho changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change. + {retention?.enabled + ? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).` + : "Indefinitely — automatic deletion is off (Settings → Logs)."}{" "} + The name stays in old entries even if the account is removed. +
Diagnostic logEach call to a DNS provider or integration: which service, what operation, success or failure, how long it took, any error text. It isn't about people. + {retention?.enabled ? `Deleted after ${retention.retentionDays} days.` : "Indefinitely — automatic deletion is off."} +
Server reports + From each agent: hostname, IP addresses, CPU, memory and disk usage, listening ports with the program using them, + and cron/systemd tasks including their commands — which can contain sensitive text. + The latest report; removed with the server.
Secrets trackerNames, types, descriptions, expiry dates, notes, and a host to check for certificates. Never the secret values themselves.Until deleted.
Integration and DNS credentialsAPI tokens and passwords, encrypted (AES-256-GCM) with a key held in the server's environment, not in the database.Until deleted. Settings → Backup exports include them, encrypted with a passphrase you choose.
InventoryIP addresses, cached DNS records, domain registrations, port notes, tags and maintenance windows.Until deleted.
+
+
+ +
+
+

Where data goes

+
+
+
    +
  • + Authentik — you sign in there. The app receives your name, email and sign-in ID; it never sees your password. +
  • +
  • + Your integrations + {outbound && outbound.integrationTypes.length > 0 ? ` (${outbound.integrationTypes.join(", ")})` : ""} — API calls to systems + you configured, to read status and, when someone with permission asks, to act (start a VM, run a template…). +
  • +
  • + DNS providers + {outbound && outbound.dnsProviderTypes.length > 0 ? ` (${outbound.dnsProviderTypes.join(", ")})` : ""} — zones and records are read + from, and changed at, the provider. +
  • +
  • + Notifications —{" "} + {enabledChannels.length === 0 ? ( + "no channel is switched on, so no alerts leave the server." + ) : ( + <> + alerts are sent to{" "} + {enabledChannels + .map((c) => `${CHANNEL_LABELS[c]}${outbound!.channels[c].host ? ` (${outbound!.channels[c].host})` : ""}`) + .join(", ")} + . They can mention server names, addresses, domain names and secret names. If a channel points at a public service such as + ntfy.sh, that service sees the text. + + )} +
  • +
  • + Domain registries — the {outbound?.domainsTracked ?? 0} domain name{outbound?.domainsTracked === 1 ? "" : "s"} tracked + on the Domains page are sent to IANA and each registry's RDAP or WHOIS server to read the expiry date. Nothing but the names. +
  • +
  • + Certificate checks — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with + a host to check, the app connects to that host to read its certificate. +
  • +
  • + Servers and agents — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push + their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one + and only against private addresses. +
  • +
  • + Nothing else. No telemetry, no update checks, no crash reports, no third-party analytics. +
  • +
+
+
+ +
+
+
+
+

In your browser

+
+
+
    +
  • + One session cookie: scripts on the page can't read it, it's restricted from cross-site requests, it's marked secure over + HTTPS, and it lasts 7 days. +
  • +
  • + One saved preference in this browser: whether you chose light or dark mode. +
  • +
  • If you install the app to your device, it stores no pages or data for offline use.
  • +
+
+
+
+
+
+
+

Who can see what

+
+
+
    +
  • Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.
  • +
  • Operators and admins: also the audit log — who did what.
  • +
  • Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.
  • +
+
+
+
+
+ +
+
+

Removing or limiting data

+
+
+
    +
  • + Sign out to end your session now. Administrators can also end anyone's on the Sessions page. +
  • +
  • + Audit and diagnostic logs can be deleted automatically after a set number of days under Settings → Logs + {retention ? ` — currently ${retention.enabled ? `on, ${retention.retentionDays} days` : "off"}` : ""}. +
  • +
  • + Your account can only be removed by an administrator deleting it from the database; the app doesn't do that + itself. Old audit entries keep your name until the log is purged. +
  • +
  • + A copy of your data is the “Download my data” button above. +
  • +
+
+
+ + ); +}