11 — Platform and Cloud¶
ONYX — Online Storage System — Platform. This document specifies the platform layer that sits on top of the core appliance (docs/design/01–10): how the system presents itself as a managed, cloud-connected infrastructure platform — SSO identity, edge reverse proxy with wildcard TLS, Dockerized deployment, virtualization, container management, object storage with hybrid cloud, AI-assisted administration, and the automated release pipeline.
It is the spec behind setup.sh, docker-compose.yml,
scripts/npm-proxy-hosts.py, scripts/provision-authentik.sh,
.github/workflows/, and the platform daemons (onyx-snapd, onyx-backupd,
onyx-vmm, onyx-appd, onyx-ai, onyx-objectstore).
1. Platform identity¶
- Product name: ONYX — Online Storage System — Platform
- Repository slug:
onyx-oss-platform - Primary domain:
onyx.innotel.us
1.1 Service subdomains¶
The console is served at the primary domain itself; every other public surface is a subdomain of it. All of them are routed by Nginx Proxy Manager (NPM), behind one wildcard TLS certificate that also names the apex:
| Host | Service | Container | Notes |
|---|---|---|---|
onyx.innotel.us |
Web console (Unity theme) | onyx-web |
static frontend; talks to api + auth |
api.onyx.innotel.us |
HTTP gateway | onyx-api |
/api/v1/*, /healthz; also serves admin routes |
auth.onyx.innotel.us |
Identity / SSO | authentik-server |
OIDC provider for every other surface |
storage.onyx.innotel.us |
S3-compatible object storage | onyx-objectstore |
GET/PUT/DELETE buckets+objects; hybrid-cloud tiering |
backup.onyx.innotel.us |
Backup service | onyx-backupd |
JSON API: jobs, schedules, restore, intelligence |
admin.onyx.innotel.us |
Admin console | onyx-api (admin routes) |
separate hostname, same gateway; access via Authentik |
The mapping is data, not code: setup.sh writes it into the environment
(NPM_SUBDOMAINS) and scripts/npm-proxy-hosts.py turns it into NPM proxy
hosts.
2. Identity: Authentik¶
Authentik is the platform IdP (docs/design/08 moves to it as the primary provider; local accounts remain as a fallback for offline operation).
- Deployed as containers:
postgres(state),redis(cache),authentik-server,authentik-worker(see §5). - Bootstrap:
AUTHENTIK_BOOTSTRAP_TOKEN+AUTHENTIK_BOOTSTRAP_EMAILseed the first superuser;scripts/provision-authentik.shwaits for readiness, then creates the ONYX Platform OAuth2 provider and application: - authorization/redirect:
https://onyx.innotel.us/*,https://admin.onyx.innotel.us/*, loopback for local dev; - client id/secret are written back into
.env(AUTHENTIK_CLIENT_ID/AUTHENTIK_CLIENT_SECRET) for downstream services. - Every host trusts
auth.onyx.innotel.us; NPM protects theadminhost with Authentik's forward-auth where enabled. The retiredapphost is a 301 to the console, so links written before the move keep working. - Passkeys: the authentication flow carries a WebAuthn stage in authentication mode, so enrolled devices confirm sign-in with a platform authenticator (docs/design/11 §10).
2.1 User sync with the Onyx console¶
Identity lives in Authentik; Onyx stores only the role and the per-share grants. The Users page presents one row per person by joining the two, so the two stores cannot drift apart:
GET /api/v1/usersreads Authentik's accounts (AUTHENTIK_URL+AUTHENTIK_TOKEN, falling back toAUTHENTIK_BOOTSTRAP_TOKEN) and merges them with the local mappings. An Authentik account with no mapping gets one on first sight (roleuser, status fromis_active) — that is the automatic mapping, and it is persisted, so the next read is stable rather than a second wave of writes. Each row reportssource(onyx/authentik/authentik+onyx),in_authentikand the provider's ownactive.POST /api/v1/userscreates the account in Authentik first and stores the mapping only afterwards: a mapping for somebody who cannot sign in would be a lie the console tells. No password is ever set through the API — Authentik's recovery flow and passkeys are the only way one is minted.DELETE /api/v1/users/{id}removes the Onyx mapping and disables the Authentik account, so its history and group memberships survive;?purge=trueis the deliberate erase-everywhere path.- When
AUTHENTIK_URL/token are unset the page says so instead of quietly showing a local-only list (authentik.reasonin the response).
3. Edge: Nginx Proxy Manager¶
NPM is the only ingress. Everything else binds loopback inside the compose
network (127.0.0.1 on the host for the native install, per docs/design/07).
3.1 API provisioning (scripts/npm-proxy-hosts.py)¶
setup.sh integrates this script (the requirement "npm-proxy-hosts.py into
setup.sh"): it is the single source of truth for the NPM state.
- Login —
POST /api/tokenswithNPM_EMAIL/NPM_PASSWORD(env). - Wildcard certificate —
POST /api/nginx/certificatesrequesting a Let's Encrypt certificate for["*.onyx.innotel.us", "onyx.innotel.us"]withmeta.dns_challenge = true, providerrfc2136(RFC 2136 dynamic DNS update), carrying the TSIG key: DNS_NAMESERVER(e.g.ns.innotel.us:53)TSIG_KEY_NAME,TSIG_KEY_SECRET,TSIG_KEY_ALGORITHM(e.g.hmac-sha256)- Idempotent: if a cert for the wildcard already exists (matched by domain), it is reused/renewed rather than duplicated.
- Proxy hosts — for each
NPM_SUBDOMAINSentry,GET /api/nginx/proxy-hosts→ if absent,POST /api/nginx/proxy-hostswith the forward target from the compose network (container DNS name + port); if present, update it (PUT). WebSocket-enabled forapp/api/admin. - Report — prints the final URL table.
Auth to the NPM API is HTTP Basic; all requests over NPM_BASE_URL
(default http://127.0.0.1:81).
3.2 Wildcard certificates¶
- Issuer: Let's Encrypt, via NPM.
- Challenge: DNS-01 using the
rfc2136provider — certbot performsnsupdateagainst the authoritative server using the TSIG key, adding a_acme-challengeTXT record, then removing it after validation. This is the same TSIG pattern used by the other innotelinc platform projects. - Because the challenge is DNS-based, no inbound port 80/443 is needed to issue the certificate — only to serve traffic.
4. Dockerized deployment¶
Every daemon has a Dockerfile under docker/ and ships in
docker-compose.yml. Two runtimes coexist:
- Native appliance (bare metal / OSTree image): systemd units in
deploy/systemd/, installed byscripts/onyx-install— unchanged core. - Containerized platform (this document):
docker compose up -dfromsetup.sh, all services on an internal network, ingress via NPM only.
The Go daemons use a shared multi-stage pattern (builder golang:1.27 →
scratch/alpine runtime, static binaries); the Rust daemons build with
rust:1-slim and copy the static binary out. Images are published to GHCR:
ghcr.io/innotelinc/onyx-oss-platform/onyx-core
ghcr.io/innotelinc/onyx-oss-platform/onyx-api
ghcr.io/innotelinc/onyx-oss-platform/onyx-shared
ghcr.io/innotelinc/onyx-oss-platform/onyx-storaged
ghcr.io/innotelinc/onyx-oss-platform/onyx-privd
ghcr.io/innotelinc/onyx-oss-platform/onyx-snapd
ghcr.io/innotelinc/onyx-oss-platform/onyx-backupd
ghcr.io/innotelinc/onyx-oss-platform/onyx-vmm
ghcr.io/innotelinc/onyx-oss-platform/onyx-appd
ghcr.io/innotelinc/onyx-oss-platform/onyx-ai
ghcr.io/innotelinc/onyx-oss-platform/onyx-objectstore
Within the compose network, gRPC over unix sockets is replaced by gRPC over
the shared network (same contracts in proto/); state volumes map to
/var/lib/onyx/<service> and the data pool mounts into the storaged/privd
containers.
5. Stack inventory (compose)¶
| Container | Image | Purpose |
|---|---|---|
onyx-privd |
ghcr.io/innotelinc/onyx-oss-platform/onyx-privd | root privilege helper (host-mapped, --privileged-free: only needed capabilities) |
onyx-storaged |
ghcr.io/.../onyx-storaged | Btrfs discovery, hotplug, SMART |
onyx-shared |
ghcr.io/.../onyx-shared | share config rendering |
onyx-core |
ghcr.io/.../onyx-core | control plane orchestrator |
onyx-api |
ghcr.io/.../onyx-api | HTTP gateway (exposed only to NPM) |
onyx-snapd |
ghcr.io/.../onyx-snapd | snapshots |
onyx-backupd |
ghcr.io/.../onyx-backupd | backups + intelligence |
onyx-vmm |
ghcr.io/.../onyx-vmm | virtualization |
onyx-appd |
ghcr.io/.../onyx-appd | container/app management |
onyx-ai |
ghcr.io/.../onyx-ai | AI Storage Advisor |
onyx-objectstore |
ghcr.io/.../onyx-objectstore | S3-compatible storage |
onyx-web |
ghcr.io/.../onyx-web | static SPA (v0.2) |
postgres |
postgres:16 | Authentik state |
redis |
redis:7 | Authentik cache/queue |
authentik-server |
ghcr.io/goauthentik/server | SSO |
authentik-worker |
ghcr.io/goauthentik/server | background jobs |
nginx-proxy-manager |
jc21/nginx-proxy-manager | ingress (ports 80/443/81) |
6. Platform daemons¶
All of these are gRPC services generated from proto/onyx/v1/, following the
service conventions of docs/design/04. The v0.4 "Jade" surfaces (§6.3, §6.4,
§6.6) are implemented; onyx-ai (§6.5) is the v0.5 milestone, and its inputs
are already assembled by onyx-api.
6.1 onyx-snapd — snapshots¶
Btrfs snapshot lifecycle on top of the fixed subvolume layout
(@data/@apps/@backups/@snapshots, docs/design/05):
CreateSnapshot(subvolume, name, optional readonly),ListSnapshots,DeleteSnapshot,RollbackSnapshot(snapshot → subvolume, requires unmounted target).- Scheduling hooks for
onyx-backupd(snapshot-before-backup).
6.2 onyx-backupd — backups + Backup Intelligence¶
- Jobs: source (share/snapshot/pool) → target (local, NFS, remote ONYX,
S3-compatible cloud via
onyx-objectstore); schedules (cron); retention policies;RestoreBackup. - Backup Intelligence:
GetBackupReportaggregates job history, success/failure trends, data-change velocity per source, and recovery-time/recovery-point estimates. Heuristics run locally;onyx-aican enrich the same report with natural-language findings.
6.3 onyx-vmm — virtualization¶
- VM inventory (
ListVMs), lifecycle (CreateVM,StartVM,StopVM,DeleteVM), resources (vCPU/RAM/disk), and media attachment. - Implemented on libvirt/QEMU: inventory and install records persist in SQLite,
disk images are qcow2 files on the pool (
/mnt/onyx/<pool>/@apps/vms), and the domains are defined and driven throughvirsh— never CGo bindings, so the service runs unprivileged in thelibvirtgroup. Creation defines a machine stopped; deletion only removes the disk image whendelete_diskis set. The lifecycle rule is enforced against the hypervisor's live state, not the recorded one, so a guest that shut itself down is still startable and a still-running machine cannot be deleted.
6.4 onyx-appd — container management¶
- App catalog (
ListApps,InstallApp,UninstallApp), container lifecycle (StartContainer,StopContainer,RestartContainer), and compose manifests from the signed app store (docs/design/09). - Implemented with persistent install records (SQLite) and a container
runtime seam: the Docker API drives real containers where a socket is
available, and the same policy is testable without one. Installing renders the
app's manifest with the per-installation config the caller supplied (ports,
paths), so two installations of one app do not collide. Uninstall stops the
containers;
purge_datadeletes the app's volumes andforceis required to remove an app whose containers are still running. - Deployment: the composed stack grants onyx-appd the host's Docker socket (and
only that group) and binds the pool at the same path the host uses, so a
manifest's bind mount resolves to pool storage on both sides. App projects are
namespaced
onyx-app-<app>so an app can never take the platform stack down with adown. - The app sandbox (docs/design/09 §6) is enforced here: a catalog manifest that
requests host privilege, host namespaces, an added-back capability, no resource
limits, or a bind mount outside the storage root is refused at startup, and an
install whose path setting points outside the pool is refused with
InvalidArgumentbefore anything is written.
6.5 onyx-ai — AI Storage Advisor + Backup Intelligence¶
AnalyzeStorage: pool/device telemetry (fromonyx-storaged) → recommendations (free-space runway, snapshot cadence, scrub health, expansion guidance). Deterministic heuristics are computed in-process and are always the answer unless a model improves on them.- The AI plane, as ONYX consumes it (
§4.5of the build-plane convergence plan): ONYX does not own inference. The platform runs ONE OmniRoute (Group 2, on the gateway host192.168.1.46, reached at:20128through the SSO proxy in front of it — its own:20128answers on that host's loopback and bridge alone) andonyx-aigetsOMNIROUTE_BASE_URL+OMNIROUTE_API_KEY— a gateway key, never an upstream provider key. The key may be avault://reference, resolved at startup like the S3 credentials; a reference that cannot be resolved stops the service. The documented BYO-key hook (AI_PROVIDER,AI_API_KEY,AI_MODEL— local or remote) stays as the escape hatch; the gateway wins when both are set. AnalyzeBackups: consumesonyx-backupd's report (6.2) and produces findings + priorities.- Privacy: no telemetry leaves the box unless a model plane is configured, and then only the findings already computed — severity, code, summary, detail, action — are sent, with the local sentence as the fallback whenever the call fails (docs/design/07 §12).
6.6 onyx-objectstore — object storage + hybrid cloud¶
- S3-compatible API surface (
storage.onyx.innotel.us):ListBuckets,PutObject,GetObject,DeleteObject,DeleteBucket— served over HTTPS by NPM; static credentials from env (S3_ACCESS_KEY/S3_SECRET_KEY). Both are required: with no access key the endpoint refuses every request (403AccessDenied) instead of serving anonymous access, because that port is published for the ingress.--s3-allow-anonymousis the explicit opt-out for a development run on loopback. - Bucket storage on the pool (Btrfs subvolume per bucket → snapshots/scrub come free).
- Hybrid cloud: per-bucket lifecycle policies —
LOCAL,CLOUD(primary in an external S3 provider), orTIERED(local hot tier, cloud cold tier, with sync/eviction rules). The cloud side is pluggable (AWS S3, Backblaze B2, other S3-compatible endpoints). - The transport is rclone against the shared remote catalog
(
/etc/rclone/rclone.conf, the same file the Shares page writes andonyx-backupdreads), so a provider is configured once for tiering, clones and backups.HYBRID_CLOUD_ENDPOINT/credentials remain the direct-endpoint form of the same thing for a deployment that does not use the catalog. - A bucket's
cloud_targetis a remote (b2-archive:orb2-archive:offsite), or an absolute directory on the pool for testing tiering without a cloud account; each bucket is stored underonyx-objectstore/<bucket>inside it. A bare name with no:is refused rather than read as a relative directory. - Per-tier semantics:
CLOUDacknowledges a write only once the cloud holds it (and keeps no local copy);TIEREDacknowledges from the hot copy and mirrors on sync.SyncBucketuploads, then runsrclone checkover the whole bucket and only evicts when that verification passes, releasing local copies older thanevict_after_days. A read of an evicted object is served from the cloud and refetched into the cache, so eviction never removes access.
7. Release pipeline¶
.github/workflows/ implements CI/CD:
ci.yml— on push/PR:make bootstrap(cached),make check(vet + tests),make build, plus ane2ejob that builds the stack and runs the flows below against it. Validation only; nothing is published.
make check exercises the code; scripts/e2e-stack.sh (make e2e) exercises
the deployment — it drives a running compose stack through the three flows
that span every layer (pool creation on a real disk with POOL_DEVICE=, or on a
loop device backed by a file the harness creates and removes with
POOL_IMAGE=, an app install, and a tiered bucket's sync → verify → evict →
refetch over the S3 API) and asserts what only a real stack can show: a pool
root the unprivileged daemons can write, containers that are running rather than
crash-looping, an evicted object that comes back on read and is cached again
with no download left behind. A unit test cannot see a mount namespace, a file
mode or a missing helper binary; this can, and both bugs it was written for — an
un-clearable disk and a storage root nobody could write into — were invisible to
the test suite. The e2e job is the same script, so a regression reaches CI
instead of an operator's disk.
- release.yml — one publish pipeline for both event kinds:
- main push → build + publish :latest images;
- tag v* push → build + publish :latest + :<tag> images, then create
the GitHub Release with tarball artifacts;
- workflow_dispatch → manual re-publish (latest).
The workflow is concurrency-guarded (group keyed by commit SHA,
cancel-in-progress), so a tag pushed on a commit that just triggered the
main latest build cancels the duplicate instead of racing it on the
first-run compile and the GHCR push. Buildx uses the gha cache, so
subsequent builds reuse compiled layers instead of compiling cold.
The release job (artifacts) depends on the image job, so the GitHub Release only appears after every image is published.
8. Operational flow (setup.sh)¶
./setup.sh
1. validate: docker + compose present, env loaded
2. cp .env.example .env if missing (then require editing secrets)
3. docker compose up -d --build (onyx stack + Authentik + NPM)
4. wait for NPM API + Authentik readiness
5. scripts/provision-authentik.sh (bootstrap superuser, ONYX OIDC app, passkey stage)
6. device trust — per DEVICE_TRUST:
off → nothing to do (passkeys still on)
local → scripts/provision-device-trust.sh (ensure the CA)
cerulean → POST the mTLS gate request to the Cerulean API
7. NPM provisioning — per DEVICE_TRUST/NPM_MODE:
local edge → scripts/npm-proxy-hosts.py (TSIG wildcard + hosts + mTLS gate)
cerulean edge → skipped; the edge is Cerulean-managed, cert gates are
requested in step 6
8. print URL table (console-at-apex/api/auth/storage/backup/admin)
Idempotent end to end: re-runs converge (cert reuse, proxy-host update, provider re-check).
9. Security posture¶
- Single ingress (NPM) on 80/443; everything else loopback-only inside the compose network (extends docs/design/07 to the containerized deployment).
- Wildcard cert keys live in NPM's data volume; TSIG secret only in
.env(never committed —.envis git-ignored,.env.examplehas placeholders). onyx-privdremains the only privilege boundary; containers run as the same unprivileged per-service users where possible.- Authentik is the SSO choke point: forward-auth on
app/admin, OIDC for the API, service-to-service traffic stays on the internal network. - Device trust is opt-in (
DEVICE_TRUST=offby default): when enabled, passkeys confirm identity in Authentik while mTLS client certificates gate the edge —localCA + local NPM for standalone deploys, or the Cerulean control plane (dashboard issuance, remote NPM) for managed fleets (docs/design/11 §10).
10. Device trust (passkeys + mTLS client certificates)¶
Management surfaces (app, admin) can be gated by two factors that bind a
login to a device, not just a password. Device trust is optional and
selected by one switch, DEVICE_TRUST=off|local|cerulean (default off):
- Passkeys (WebAuthn) — Authentik is the IdP, so platform
authenticators (Touch ID, Windows Hello, Android/iOS, hardware keys) are
enabled at the source: the default
default-authentication-identificationflow gets a WebAuthn stage in authentication mode, so every login is confirmed by the device's passkey once one is registered. Users register passkeys from Authentik's settings;provision-authentik.shflips the flow stage on automatically. - TLS client certificates (mTLS) — the edge requires a client
certificate from a trusted device CA before the request even reaches
Authentik's proxy. A device without a trusted certificate cannot start an
OIDC flow, which removes credential-stuffing and stolen-password classes
of attack for the admin surface entirely. Who runs the CA and where the
gate is applied depends on
DEVICE_TRUST:
| Mode | Device CA | Certificate issuance | mTLS enforcement | For |
|---|---|---|---|---|
off (default) |
— | — | — | devices stay optional |
local |
ONYX host (/etc/onyx/pki, provision-device-trust.sh) |
CLI helper → .p12 for MDM import |
local NPM (ssl_verify_client) |
standalone deploys |
cerulean |
Cerulean (remote MDM/control plane) | Cerulean dashboard (approve, issue, revoke) | requested from Cerulean via its API | fleets behind Cerulean |
The result: on an enrolled device, sign-in is one passkey tap. On any other device, the TLS handshake fails before a password is ever asked for.
10.1 local mode — device CA (scripts/provision-device-trust.sh)¶
A minimal internal PKI maintained under /etc/onyx/pki (host bind mount,
ONYX_PKI_DIR to override; private keys are mode 0600, never leave the
host):
ca.crt / ca.key ECDSA P-256 self-signed device CA (10 years)
serial openssl tracking file
issued/<name>.crt/.key per-device client certs (default 365 days)
scripts/provision-device-trust.sh(no args) creates the CA if missing — idempotent, called fromsetup.shonly inlocalmode.scripts/provision-device-trust.sh issue laptop [days]issues (or renews) a client certificate with CN<name>plus theONYX Device Trust/device=<name>O/OU identifiers, for distribution via your MDM (Apple Configurator /.mobileconfig, Intune, Fleet, or a manual import into the OS/browser trust store).
10.2 local mode — edge enforcement (npm-proxy-hosts.py)¶
For every subdomain listed in DEVICE_TRUST_SUBDOMAINS (default
app admin), the proxy host's advanced config is extended with the Nginx
mTLS directives — merged idempotently with any existing advanced config:
ssl_client_certificate /etc/letsencrypt/onyx-device-ca/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
The CA is mounted into the NPM container from the host PKI dir
(docker-compose.yml); ssl_verify_client on makes NPM return 400 with
No required SSL certificate was sent for devices without an enrolled
certificate — before any auth flow starts. Nginx passes the verified
subject to upstreams in X-SSL-Client-Subject-DN, which Authentik forward
auth can use for audit trail enrichment.
10.3 cerulean mode — remote control plane¶
When the platform is managed by Cerulean (the innotelinc fleet control plane; also owns the remote NPM and the open-source MDM underneath), ONYX owns policy, Cerulean owns enforcement:
- Issuance lives in the Cerulean dashboard: an operator approves a
device, Cerulean's MDM pushes the client certificate, and the device CA
pool is managed entirely remotely — ONYX never stores CA keys in this
mode (
provision-device-trust.shis not run). - ONYX requests the gate by calling Cerulean's device-trust API
(
CERULEAN_API_URL+CERULEAN_API_TOKENin.env):
POST {CERULEAN_API_URL}/api/v1/fleet/{FLEET_ID}/device-trust
Authorization: Bearer ${CERULEAN_API_TOKEN}
{
"domain": "onyx.innotel.us",
"subdomains": ["app", "admin"], # = DEVICE_TRUST_SUBDOMAINS
"enforcement": "mtls",
"client_ca": "cerulean-managed"
}
Cerulean applies the equivalent ssl_verify_client configuration on its
remote NPM (or drops the mTLS requirement on revoke) and reports the
enrolled device list back to the dashboard.
- setup.sh skips local NPM provisioning — with a remote edge there is
no local NPM to configure (§8).
- Complement, not replacement: passkeys remain Authentik-side in every
mode; Cerulean enforces the edge gate, Authentik enforces the identity
gate, and a device needs both factors to reach app/admin.
The Cerulean contract above is an interface, not a client: this repo ships
the request schema and the setup.sh hook, while the server side (and the
open-source MDM it drives — e.g. Fleet, MicroMDM, or Headwind) belongs to
Cerulean.
Dashboard read path: onyx-api exposes GET /api/v1/devices/trust so
the ONYX dashboard can show enrolled devices without talking to Cerulean
itself. Mode-aware:
DEVICE_TRUST |
Response |
|---|---|
off |
200 {"mode":"off","enabled":false,"devices":[]} |
local |
200 with enabled:true and a note that issuance is local (scripts/provision-device-trust.sh) |
cerulean |
proxy of GET {CERULEAN_API_URL}/api/v1/fleet/{FLEET_ID}/devices (query params pass through, bearer auth added server-side); upstream failures map to 502 device_trust_upstream (retryable), missing config to 503 device_trust_not_configured |
Projection per device: name, common_name, status, issued_at,
expires_at — plus fleet_id and count at the top level. Issuance and
revocation stay dashboard-side; the endpoint is read-only.
10.4 Trade-offs and operations¶
- Strictness:
ssl_verify_client onwas chosen overoptionalso the gate is real; enrollment is a deliberate act (local: issue cert → install on device;cerulean: approve in dashboard → MDM pushes). - Revocation:
local— delete the issued cert or rotate the CA and re-provision;cerulean— revoke from the dashboard, the edge drops the device on the next sync. - Failure mode:
localCA loss is recoverable by regenerating and re-enrolling the (small) fleet;ceruleanmode has no local key material to lose. - S3/backup surfaces are exempt: machine clients (S3 keys, backup agents) authenticate with credentials, not device certificates; only interactive surfaces are device-gated.