Docs › Privacy

Privacy: your data stays yours

SpatiumDDI collects no telemetry, no usage analytics, no crash reports, and no account or registration data. There is no phone-home, no license server, no sign-up, and no project-controlled endpoint the software talks to. Everything you put in it — zones, records, leases, subnets, credentials, query logs, audit history — lives in your PostgreSQL on your infrastructure, and the maintainers have no way to see it and do not want it. The documentation site and the web UI use no analytics or tracking scripts.

The software makes no outbound connection you did not configure, with one exception: a daily anonymous check of GitHub for a newer release (an unauthenticated GET; GitHub sees your IP address and nothing about your install). Turn it off under Settings → Application → Updates → Check for GitHub Releases, or run fully air-gapped — every feature works with no internet access at all. Optional features that do reach third parties (Fingerbank device profiling, the Operator Copilot’s LLM provider, Let’s Encrypt, blocklist feeds, cloud DNS / integration mirrors, the whois and RBL tools) are off until you configure them, and the table below lists exactly what each one sends and to whom. There is no telemetry endpoint to opt out of, because there is no telemetry endpoint. That is a design constraint, not a current state: CLAUDE.md non-negotiable #17 forbids adding one, and a CI test (backend/tests/test_outbound_hosts_documented.py) fails the build when a hostname appears in the backend or the agents that is not documented on this page.


1. What is never collected

   
Usage analytics / product telemetry None. No counters, no feature-usage pings, no “anonymous statistics” toggle, because there is nothing behind it.
Crash / error reporting None. Errors go to your structured logs and the in-app diagnostics table. No Sentry, no Bugsnag, no third-party error sink.
Accounts / registration / licensing None. SpatiumDDI has no account system of its own, no activation, no entitlement check, and no trial timer. Apache 2.0, run it.
Your DDI data Never leaves your PostgreSQL unless you configure something that sends it (an integration to your own controller, a backup target you own, an alert webhook you point somewhere).
Web UI tracking The frontend loads no external script, font, image, or CDN asset. frontend/index.html pulls exactly one file: the app bundle from your own server. (Until #1353 the MFA enrolment screen fetched its QR code from api.qrserver.com, sending the TOTP secret with it; see §8.) One exception, on one page: the API reference at /api/redoc shows ReDoc’s “API docs by Redocly” footer logo, an image your browser fetches from cdn.redoc.ly when you open that page, carrying nothing but your IP address and the usual browser headers (the Content-Security-Policy allows it there and nowhere else). Blocked or air-gapped, the page loses only the logo.
Docs site tracking www.spatiumddi.com is a static Jekyll site with no analytics tag, no tracker pixel, and no third-party font or CDN include.

2. The one connection that is on by default

Daily GitHub release check — GET https://api.github.com/repos/spatiumnorth/spatiumddi/releases/latest

Fired once a day by Celery Beat (backend/app/tasks/update_check.py), so the UI can tell you a newer release exists. It is:

It is on by default because it is genuinely empty and because an operator who does not know a security fix shipped is worse off than one whose firewall logged a daily GET to GitHub. If you would rather it did not happen, one toggle stops it:

Settings → Application → Updates → Check for GitHub Releases (or PlatformSettings.github_release_check_enabled = false). With it off, the task returns immediately and opens no socket. Nothing else in the product degrades — you simply do not get the “update available” pill.

3. Every outbound connection, in full

This table is normative. A hostname that appears in backend/app or in an agent package under agent/ — or a public IP address in an agent package — and not here fails CI (see §8).

3.1 Enabled by default

Connection What is sent Turn it off with
api.github.com — daily release check Anonymous GET. GitHub sees your IP + User-Agent; nothing about the install. Settings → Application → Updates → Check for GitHub Releases

That is the whole list.

3.2 On demand — only when an operator clicks

Connection Feature What is sent
api.github.com The Releases view / Appliance → Fleet → Slot images (services/appliance/releases.py) Anonymous GET for the release list and appliance-image asset names.
data.iana.org Settings → DNS → TLD Registry → Refresh now (#986) Nothing. An unauthenticated GET of one public file, data.iana.org/TLD/tlds-alpha-by-domain.txt.
The RDAP hosts of §3.3 (data.iana.org, the RIR hosts, the TLD registries’ own servers) The Refresh now button on a Domain or an ASN (#85) The domain name or ASN being looked up. RDAP is a public registry query, so the registry learns what you asked about. This is the manual twin of the scheduled refresh in §3.3, which is not on-demand.
crt.sh TLS certificate → Certificate Transparency cross-reference The hostname being checked. Explicitly on-demand, never run by the scheduled probe, and the matching copilot tool ships disabled.
RBL / DNSBL zones (zen.spamhaus.org, bl.spamcop.net, …) Tools → RBL check A DNS query encoding the IP being checked, to the list operator’s resolver.
Public resolvers Tools → DNS propagation check The name being queried, to each resolver in the chosen set.
Whois / RDAP servers Tools → whois The name or IP being looked up.

Everything in this section is a per-click action with a visible result; nothing here runs on a schedule. (The RDAP lookups behind a Domain or ASN do also run on a schedule, once you have created such a row. They are listed in §3.3 for that reason.)

3.3 Off until you configure them

Connection Feature Default What is sent
RBL / DNSBL zones Scheduled DNSBL monitoring off — dnsbl_monitoring_enabled=false, and every catalogue list ships disabled, so both a master switch and a per-list opt-in are required A DNS query encoding each monitored IP, to the list operator’s resolver.
standards-oui.ieee.org MAC vendor database refresh oui_lookup_enabled=false Nothing — a CSV download.
api.fingerbank.org DHCP device profiling off; requires your Fingerbank API key Sends observed fingerprints: DHCP option 55, option 60, and the client MAC address. The most identifying outbound call in the product — read docs/features/DHCP.md before enabling it.
api.openai.com, generativelanguage.googleapis.com, Azure OpenAI (<your-resource>.openai.azure.com), Anthropic Operator Copilot off; no provider is configured out of the box Sends prompts and tool results — which can include hostnames, subnets and record data — to whichever provider you point it at. Can be fully on-prem: Ollama, vLLM and LocalAI all work through the openai_compat driver, in which case nothing leaves your network.
acme-v02.api.letsencrypt.org, acme-staging-v02.api.letsencrypt.org, or any ACME directory you name Embedded ACME client (#438) off A standard ACME order: the domains you are requesting a certificate for. Those names end up in public Certificate Transparency logs — that is how public CAs work, not something SpatiumDDI adds.
Blocklist feed hosts (Hagezi, OISD, AdGuard, urlhaus, phishing.army, … — the catalogue is backend/app/data/dns_blocklist_catalog.json) RPZ blocking lists off per list Nothing — an HTTPS download of the list, once per refresh interval, per list you subscribed to.
api.cloudflare.com, api.hetzner.cloud, api.linode.com, api.vultr.com, api.digitalocean.com, plus the AWS / Azure / Google SDK endpoints Agentless cloud DNS drivers off; one server row per provider Zone and record operations, with your credentials, to your tenant.
api.meraki.com, api.netbird.io, api.tailscale.com, login.tailscale.com, api.ui.com Vendor-hosted integration mirrors (Meraki, NetBird, Tailscale, UniFi cloud) off; one target row each Read-only API calls with your credentials to your own tenant. Self-hosted integrations (Proxmox, OPNsense, PAN-OS, FortiGate, Kubernetes, Docker, …) reach only the address you type.
data.iana.org/rdap/dns.json, then the RDAP server the IANA bootstrap names for the domain’s TLD (for example rdap.verisign.com for .com; the host depends on the TLD, so it is not enumerable here) Scheduled domain registration refresh (#87). Celery Beat fires refresh_due_domains every hour; each Domain row is re-queried once its next_check_at has elapsed, by default every 24 h (domain_whois_interval_hours, 1 to 168). Creating a Domain row also queues an immediate lookup. Armed by data, and switchable. The first Domain row you create starts a daily off-prem query for that row. Settings → Features → Domains (network.domains) is the switch. It is off on a fresh install, so nothing is sent until you turn it on to create the first Domain row; on an install upgraded with Domain rows already present it resolves on (no row is seeded; an explicit setting always wins), so registration tracking keeps working. Off, the Domains pages and API answer 404, and the hourly sweep and the lookup queued at create time make no RDAP call at all. A name with no registry (reserved, reverse, or undelegated per the IANA list) is marked n/a and not queried. A fresh install makes no such call. The domain name. The bootstrap fetch (cached 6 h) sends nothing.
rdap.arin.net, rdap.db.ripe.net, rdap.apnic.net, rdap.lacnic.net, rdap.afrinic.net Scheduled ASN registration refresh (#85). Beat fires refresh_due_asns every hour; each public ASN row is re-queried once due, by default every 24 h (asn_whois_interval_hours, minimum 1). The RIR is chosen from the bundled IANA delegation table, so this path does not contact data.iana.org. Private ASNs are skipped. Armed by data. ASN rows can only be created through the network.asn module, which ships off, so a fresh install has no rows. The sweep does nothing while network.asn is off (#2279), so switching the module off stops it even when rows exist. The AS number.
rpki.cloudflare.com (default) or rpki-validator.ripe.net (rpki_roa_source) Scheduled RPKI ROA refresh. Beat fires refresh_due_roas every hour; every public ASN row without ROAs yet, or whose ROAs are due, is reconciled against the source’s global ROA dump (about 100 MB, streamed once per sweep). Default cadence is 4 h (rpki_roa_refresh_interval_hours, 1 to 168). Same as the ASN refresh: no rows, no call on a fresh install, and the sweep does nothing while network.asn is off (#2279). Nothing. A GET of the public dump; ASN numbers are matched locally.
stat.ripe.net, www.peeringdb.com, ris-live.ripe.net, rpki.cloudflare.com, rpki-validator.ripe.net BGP Looking Glass enrichment and prefix-hijack monitoring (bgp_monitoring_enabled); the two RPKI hosts are also reached by the scheduled ROA refresh above off — the network.asn feature module; its scheduled ROA refresh and hijack poll do nothing while it is off (#2279), and the hijack poll also needs bgp_monitoring_enabled; the RIS Live stream is a separate process behind BGP_RIS_LIVE_ENABLED (default false) The ASN or prefix being enriched.
Your alert / notification endpoints (Slack webhook, generic webhook, SMTP relay) Alerting off until a channel is configured The alert payload, to the URL or relay you entered.
Your audit-forward endpoint Audit forwarding off Audit rows, to the URL you entered.
Your backup destination (S3, Azure Blob, GCS, WebDAV, SMB, FTP, SCP, NFS, or any HTTPS receiver you name) Backup targets off; local volume by default The encrypted backup archive, to the destination you configured. The https_put kind sends it to a URL you type — an Artifactory or Nexus repository, a presigned S3 URL, an internal receiver — so it goes exactly where you point it and nowhere else; the URL is checked against the SSRF guard and redirects are not followed, so the archive cannot be re-sent to a third address. The nfs kind connects to the server and export you name, over AUTH_SYS, which is unauthenticated (see SYSTEM_ADMIN.md).

3.4 Appliance host (OS-level, not the application)

The OS appliance is a Debian host, and a Debian host talks to the network the way Debian hosts do:

Connection Default Change it under
pool.ntp.org — time sync on The disk installer’s Time source screen (or the ntp_servers preseed key) sets it at install; Appliance → Fleet → Services → NTP changes it afterwards. Point it at an internal server or a unicast peer. Leaving it blank at install means no time source at all (#1002): the installer comments out Debian’s own pool directive and the /run/chrony-dhcp sourcedir, and seeds the platform’s server list empty so the control plane’s first config push does not put the pool back. chronyd then runs with no sources and reports Not synchronised, which is what an air-gapped site with a trusted RTC is asking for. Clearing the server list under Appliance → Fleet → Services → NTP does the same thing on an appliance that is already running
github.com — SSH public keys, only if you ask off The disk installer’s SSH public key screen: typing a bare username there fetches https://github.com/<user>.keys. Typing a URL fetches that URL instead, and picking “Paste” or “No key” fetches nothing. One GET, no request body, nothing about the install is sent
The control-plane URL you type — an /api/v1/version probe off (Additional-node installs only) The disk installer probes the URL you entered before it wipes the disk, so a typo is caught while it is still correctable. One GET to your own control plane
Your default gateway — one ICMP echo on (pre-flight screen) The installer’s pre-flight check pings the LAN gateway to show whether it answers. LAN-local; there is deliberately no internet-reachability probe
Debian APT mirrors (deb.debian.org, security.debian.org) host default Appliance → Fleet → Services → APT sources — managed repositories, an internal mirror, or a proxy
ghcr.io — container image pulls at slot upgrade only during an upgrade Not needed at all if you upgrade from an uploaded slot image; the ISO already carries every image it runs

None of this is SpatiumDDI-specific traffic and none of it carries your DDI data.

3.5 DNS and DHCP servers SpatiumDDI runs

The connections above are made by the control plane or the appliance host. The DNS and DHCP service containers otherwise reach the control plane and the addresses you configure on them (forwarders, failover peers, DDNS targets) — plus whatever answering your clients’ queries needs, since they are DNS servers: a recursive BIND9 group with no forwarders resolves from the root servers, as any recursive resolver does. SpatiumDDI picks no upstream for them.

PowerDNS ALIAS records resolve their targets at query time through the group’s own forwarders, over plain DNS (port 53): the list you set under the group’s server options, which a PowerDNS group otherwise does not use. With no forwarders, or with forwarders over TLS, HTTPS or QUIC (which PowerDNS’s resolver= cannot speak, and which SpatiumDDI will not downgrade to plaintext), ALIAS expansion is off and the API refuses a new ALIAS record. Builds before #1353 rendered resolver=1.1.1.1,8.8.8.8 into every PowerDNS server instead, sending ALIAS targets to Cloudflare and Google; an upgrade replaces it. A PowerDNS group that already serves ALIAS records and has no forwarders stops expanding them (A and AAAA queries for those names get no answer) until you set forwarders.

PowerDNS’s own security-status polling (a TXT query for auth-<version>.security-status.secpoll.powerdns.com, which names the version you run) is turned off: SpatiumDDI renders an empty security-poll-suffix. Builds before #1353 sent it from every PowerDNS server at startup and periodically; the setting takes effect the next time the PowerDNS container starts, which an upgrade does. The optional dnsdist front (Docker Compose only) has the same feature, also on by default, querying dnsdist-<version>.security-status.secpoll.powerdns.com through the container’s system resolver; its entrypoint now writes an empty setSecurityPollSuffix(""), which turns it off from the next restart.

4. Air-gapped operation

Every feature works with all of the above blocked. Builds before #1353 were the exception: a PowerDNS server group needed a route to 1.1.1.1 / 8.8.8.8 to start (§3.5). This is not a claim about the happy path — it is non-negotiable #5 in the project’s own build rules: DNS and DHCP service containers cache their last-known-good config locally and keep serving when the control plane is unreachable, and by the same logic nothing in the control plane requires an internet round-trip to function.

Concretely, on an air-gapped install:

5. The support bundle

POST /system/support-bundle (#875) produces the diagnostics archive you would attach to a bug report. It is generated locally and uploaded nowhere — the response is a download; SpatiumDDI has no endpoint to send it to.

Because a bug report on a public repository is public, the bundle is scrubbed in two tiers:

The decode map — the only thing that can reverse the pseudonymisation — is a separate endpoint and is never included in the archive. It does not leave your install unless you deliberately send it to someone.

6. The mobile app

The SpatiumDDI mobile app lives in its own repository and carries the same promise: it speaks only to your control plane’s REST API, at the address you enrol it against. It has no backend of its own, no analytics SDK, and no account. Enrolment is a QR code minted inside your authenticated session (#906), carrying your control plane’s address, an API token you created, and the TLS certificate fingerprint the client pins.

7. Who can see what, inside your install

Privacy from the outside world is one half; the other half is that SpatiumDDI is built to be delegated without handing over everything:

8. Keeping this page true

A privacy statement rots the first time somebody adds a convenience fetch. Three guards keep this one honest:

The guard covers Python source under backend/app and the agent packages; the agents’ tests/ directories are not shipped and are not scanned. It looks for IP addresses in the agents only: in the backend they are almost all example addresses in tool descriptions, and the hardcoded PowerDNS resolvers in §3.5, which this page once missed, are IP addresses in agent code, which a hostname scan cannot see. Its hostname scan reads hosts out of http:// and https:// URLs, so a hostname written bare (a resolver name, a tls:// endpoint) is not seen either. The things it cannot see, and which therefore need a human: hostname literals in the appliance’s shell scripts under appliance/mkosi.extra/usr/local/bin/ — the guard reads Python only, so §3.4’s rows were written by hand and the next curl added to the installer will pass CI with nothing to catch it; the same for the agent images’ entrypoints and config files under agent/*/images/; connections a bundled daemon (BIND9, PowerDNS, dnsdist, Technitium, Kea, GoBGP) makes on its own built-in defaults, which appear in no SpatiumDDI source at all; hosts assembled at runtime from operator input (which is the point — those are your endpoints); public IP addresses in the backend; and the feed catalogues in backend/app/data/, whose entries are all opt-in downloads covered by the blocklist row above.

If you find a connection this page does not describe, that is a bug — please open an issue.

Appendix — hostnames in the source that are not connections

The guard in §8 matches text, so these appear in backend/app or an agent package and are listed here to keep the check honest. None of them is contacted.

Documentation and homepage links (shown in the UI or written in a comment, never fetched): github.com (issue links in agent docstrings — the installer’s SSH-key fetch in §3.4 is a separate use), www.spatiumddi.com (the ACME client’s User-Agent string, as RFC 8555 asks for), fingerbank.org, aistudio.google.com (the “get an API key” link in an error message), bacnet.org, kea.readthedocs.io, adaptivecards.io (the $schema identifier in a Teams-format webhook payload — a schema URI, not a URL that is dereferenced), www.opengis.net (the GML namespace in the E911 PIDF-LO renderer — likewise an XML namespace identifier, written into the document we emit and never fetched; the accompanying urn:ogc:def:crs:EPSG::4326 CRS reference is a URN precisely because it names a coordinate system rather than locating a resource), and the DNSBL catalogue’s homepage fields: www.spamhaus.org, www.spamcop.net, www.barracudacentral.org, www.uceprotect.net, www.sorbs.net, psbl.org.

Placeholders in examples, defaults and error messages — these are where you type your own address: app.example.com, ddi.example.com, dns.example.com, ipam.example.com, netbox.example.com, nc.example.com, nextcloud.example, nexus.example (the example receiver URL on the https_put backup destination’s form — an address you replace with your own Artifactory, Nexus or internal receiver), my-resource.openai.azure.com, pdns.internal, tdns.internal, api.meraki.cn (named in a docstring as the regional shard a China-based operator would enter).

In the Web UI (frontend/src, checked by the second guard in §8), none of them loaded by the browser: links in help text (en.wikipedia.org, the timezone list; docs.docker.com; app.netbird.io and unifi.ui.com, where to find an API key); example values in placeholders (automation.example.com, boot.example, cloud.example.org, collector.example.com, example.com, idp.example.com, k8s.example.com, login.example.com, netbird.example.com, netbox.internal, proxy.internal, and the URL shapes of a public calendar feed and of chat webhooks: calendar.google.com, hooks.slack.com, discord.com); and two form presets the control plane contacts only once you save a provider with them, as your own endpoint: login.microsoftonline.com (the Entra ID OIDC discovery URL) and host.docker.internal (a local Ollama for the Copilot).

In-cluster addresses, which never leave the node: spatium-control-spatiumddi-api.spatium.svc.cluster.local — the Kubernetes service name an appliance supervisor uses to reach the control plane it is already part of.


Questions about anything on this page are welcome as a GitHub issue or discussion. If a future release adds an outbound connection, it will be in the table above and in the CHANGELOG before it is in your install.