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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-26 20:28:03 +02:00
1 parent 07d20bd2b7
commit 72f8c85406
7 files changed
+466

No files matched your search

+10
View File
@@ -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
+2
View File
@@ -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));
+100
View File
@@ -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;
}
}
+2
View File
@@ -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() {
<Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/consistency" element={<Consistency user={user} />} />
<Route path="/domains" element={<Domains user={user} />} />
<Route path="/maintenance" element={<Maintenance user={user} />} />
+26
View File
@@ -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<void>(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }),
},
},
privacy: {
overview: () => request<PrivacyOverview>("/api/privacy"),
exportOwnData: async (): Promise<Blob> => {
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<ConsistencyReport>("/api/consistency"),
ignore: (key: string, reason?: string) =>
+2
View File
@@ -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: <IconPlugConnected size={20} /> },
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
{ to: "/privacy", label: "Privacy", icon: <IconShieldLock size={20} /> },
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
+324
View File
@@ -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<PrivacyOverview | null>(null);
const [error, setError] = useState<string | null>(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 (
<>
<h2 className="page-title mb-3">Privacy</h2>
<div className="card mb-3">
<div className="card-body">
Homelab Manager runs on your own server, and what it stores stays there unless it's listed under{" "}
<a href="#where-it-goes">Where data goes</a>. 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.
</div>
</div>
{error && <div className="alert alert-danger">{error}</div>}
{data && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">About you</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-primary" onClick={() => void download()} disabled={exporting}>
{exporting ? "Preparing…" : "Download my data"}
</button>
</div>
</div>
<div className="card-body">
<dl className="row mb-0">
<dt className="col-sm-3">Name</dt>
<dd className="col-sm-9">{data.me.user.name ?? "—"}</dd>
<dt className="col-sm-3">Email</dt>
<dd className="col-sm-9">{data.me.user.email ?? "—"}</dd>
<dt className="col-sm-3">Role</dt>
<dd className="col-sm-9">{data.me.user.role}</dd>
<dt className="col-sm-3">Sign-in ID</dt>
<dd className="col-sm-9 text-break">
<code>{data.me.user.subject}</code>
</dd>
<dt className="col-sm-3">First signed in</dt>
<dd className="col-sm-9">{formatDateTime(parseTime(data.me.user.createdAt))}</dd>
<dt className="col-sm-3">Last signed in</dt>
<dd className="col-sm-9">{data.me.user.lastLoginAt ? formatDateTime(parseTime(data.me.user.lastLoginAt)) : "—"}</dd>
<dt className="col-sm-3">Recorded actions</dt>
<dd className="col-sm-9">
{data.me.auditEntries} change{data.me.auditEntries === 1 ? "" : "s"} you made are in the audit log under your name
</dd>
</dl>
<div className="text-secondary small mt-2">
Your name, email and sign-in ID come from Authentik and are refreshed each time you sign in.
</div>
</div>
<div className="card-body border-top">
<div className="fw-bold mb-2">Your active sign-ins</div>
<div className="table-responsive">
<table className="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>From</th>
<th>Browser</th>
<th>Last active</th>
<th>Ends</th>
</tr>
</thead>
<tbody>
{data.me.sessions.map((s, i) => (
<tr key={i}>
<td>
{s.ip ?? "—"} {s.current && <span className="badge bg-green-lt text-green ms-1">this browser</span>}
</td>
<td className="text-secondary text-break">{s.userAgent ?? "—"}</td>
<td className="text-secondary">{formatAgo(s.lastAccess)}</td>
<td className="text-secondary">{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"}</td>
</tr>
))}
{data.me.sessions.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary">
No active sign-ins found.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="text-secondary small mt-2">
“Download my data” gives you these details and every audit-log entry made under your account as a file — only yours.
</div>
</div>
</div>
)}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">What is stored</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>What</th>
<th>Contains</th>
<th>Kept</th>
</tr>
</thead>
<tbody>
<tr>
<td>Accounts</td>
<td>Sign-in ID, email and name (from Authentik), role, first and last sign-in.</td>
<td>Until an administrator removes the account from the database — the app can change roles but has no delete-account function.</td>
</tr>
<tr>
<td>Sign-in sessions</td>
<td>
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.
</td>
<td>7 days after sign-in, or until you sign out or an administrator ends the session.</td>
</tr>
<tr>
<td>Audit log</td>
<td>Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.</td>
<td>
{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.
</td>
</tr>
<tr>
<td>Diagnostic log</td>
<td>Each 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.</td>
<td>
{retention?.enabled ? `Deleted after ${retention.retentionDays} days.` : "Indefinitely — automatic deletion is off."}
</td>
</tr>
<tr>
<td>Server reports</td>
<td>
From each agent: hostname, IP addresses, CPU, memory and disk usage, listening ports with the program using them,
and cron/systemd tasks <em>including their commands</em> — which can contain sensitive text.
</td>
<td>The latest report; removed with the server.</td>
</tr>
<tr>
<td>Secrets tracker</td>
<td>Names, types, descriptions, expiry dates, notes, and a host to check for certificates. Never the secret values themselves.</td>
<td>Until deleted.</td>
</tr>
<tr>
<td>Integration and DNS credentials</td>
<td>API tokens and passwords, encrypted (AES-256-GCM) with a key held in the server's environment, not in the database.</td>
<td>Until deleted. Settings → Backup exports include them, encrypted with a passphrase you choose.</td>
</tr>
<tr>
<td>Inventory</td>
<td>IP addresses, cached DNS records, domain registrations, port notes, tags and maintenance windows.</td>
<td>Until deleted.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="card mb-3" id="where-it-goes">
<div className="card-header">
<h3 className="card-title">Where data goes</h3>
</div>
<div className="card-body">
<ul className="mb-0">
<li className="mb-2">
<strong>Authentik</strong> — you sign in there. The app receives your name, email and sign-in ID; it never sees your password.
</li>
<li className="mb-2">
<strong>Your integrations</strong>
{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…).
</li>
<li className="mb-2">
<strong>DNS providers</strong>
{outbound && outbound.dnsProviderTypes.length > 0 ? ` (${outbound.dnsProviderTypes.join(", ")})` : ""} — zones and records are read
from, and changed at, the provider.
</li>
<li className="mb-2">
<strong>Notifications</strong> —{" "}
{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.
</>
)}
</li>
<li className="mb-2">
<strong>Domain registries</strong> — 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.
</li>
<li className="mb-2">
<strong>Certificate checks</strong> — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with
a host to check, the app connects to that host to read its certificate.
</li>
<li className="mb-2">
<strong>Servers and agents</strong> — {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.
</li>
<li>
<strong>Nothing else.</strong> No telemetry, no update checks, no crash reports, no third-party analytics.
</li>
</ul>
</div>
</div>
<div className="row row-cards mb-3">
<div className="col-lg-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">In your browser</h3>
</div>
<div className="card-body">
<ul className="mb-0">
<li className="mb-2">
One <strong>session cookie</strong>: 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.
</li>
<li className="mb-2">
One <strong>saved preference</strong> in this browser: whether you chose light or dark mode.
</li>
<li>If you install the app to your device, it stores no pages or data for offline use.</li>
</ul>
</div>
</div>
</div>
<div className="col-lg-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Who can see what</h3>
</div>
<div className="card-body">
<ul className="mb-0">
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
<li className="mb-2">Operators and admins: also the audit log — who did what.</li>
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
</ul>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">Removing or limiting data</h3>
</div>
<div className="card-body">
<ul className="mb-0">
<li className="mb-2">
<strong>Sign out</strong> to end your session now. Administrators can also end anyone's on the <Link to="/sessions">Sessions</Link> page.
</li>
<li className="mb-2">
<strong>Audit and diagnostic logs</strong> can be deleted automatically after a set number of days under Settings → Logs
{retention ? ` — currently ${retention.enabled ? `on, ${retention.retentionDays} days` : "off"}` : ""}.
</li>
<li className="mb-2">
<strong>Your account</strong> 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.
</li>
<li>
<strong>A copy of your data</strong> is the “Download my data” button above.
</li>
</ul>
</div>
</div>
</>
);
}