From 72f8c85406d0d405e9ecc260cab71aeaa96b5667 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 26 Sep 2026 20:28:03 +0200 Subject: [PATCH] Add a Privacy page: what is stored, where it goes, and your own data A page every signed-in user can open. It states what the installation stores (accounts, sign-in sessions, audit and diagnostic logs, server reports, the secrets tracker, credentials, inventory) and for how long, where data goes (Authentik, integrations, DNS providers, notification channels, domain registries, certificate checks, port scans), what lives in the browser, who can see what, and how to limit or remove data. Written from what the code actually does, including the uncomfortable parts: the session file keeps the user's Authentik ID token plus the IP and browser from sign-in; audit entries keep a name snapshot after an account is gone; the app has no delete-account function; cron commands in agent reports can contain sensitive text. It also says what isn't there -- no telemetry, update checks, third-party scripts, fonts or tracking cookies -- which was checked against the web build and the server's outbound calls before being asserted. Live values rather than boilerplate: log retention (and whether it's on), which integration and DNS provider types are enabled, how many domains, certificate checks and reporting servers, and which notification channels are on. Channel addresses are shown to admins only, and only the host -- never a path, query string or token -- since a webhook URL can embed a key. Each user also sees their own account and active sign-ins, and can "Download my data": their account, their sign-ins and the audit-log entries made under their account, as JSON. Only their own -- never another user's -- and without session ids or ID tokens. The export is itself audit-logged, so a later export shows it. Verified with 23 backend checks (own-vs-others isolation for audit counts, sessions and export; no session ids, ID tokens or channel secrets in any response; admin-vs-viewer channel visibility; live counts; audit of the export; auth) using an isolated session directory so real sessions are never read, and in a browser against the real router, including the download. Real dev database and session files untouched. Not legal text: this is a transparency page for the people using the app, not a privacy policy or a GDPR compliance document. Co-Authored-By: Claude Sonnet 5 --- README.md | 10 ++ server/src/index.ts | 2 + server/src/routes/privacy.ts | 100 +++++++++++ web/src/App.tsx | 2 + web/src/api/client.ts | 26 +++ web/src/layout/AppShell.tsx | 2 + web/src/pages/Privacy.tsx | 324 +++++++++++++++++++++++++++++++++++ 7 files changed, 466 insertions(+) create mode 100644 server/src/routes/privacy.ts create mode 100644 web/src/pages/Privacy.tsx 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. +
  • +
+
+
+ + ); +}