Scanopy¶
Network-documentation app in the network namespace. Auto-generates
L2/L3/workload/application diagrams by continuously scanning the infrastructure.
Purpose¶
- Rust network-documentation tool (AGPL-3.0) split into two workloads plus a database:
- server: Rust API + bundled Svelte UI, port
60072(ghcr.io/scanopy/scanopy/server). - daemon: Rust scanner (SNMP / LLDP / ARP), port
60073(ghcr.io/scanopy/scanopy/daemon). - postgres: upstream compose bundles its own Postgres; here it uses the shared CNPG cluster.
- Deployed via two HelmReleases (
scanopyserver,scanopy-daemonscanner) that share oneapp-templateOCIRepository, both referencingchartRef.name: scanopy. - Internal-only app: exposed on
envoy-internalatscanopy.${SECRET_DOMAIN}.
Design decisions¶
- Daemon networking: privileged
hostNetworkDeployment (single replica). One scanner total for the flat LAN /24; a per-node DaemonSet was rejected because all nodes share the same /24 and would scan it three times, and two hostNetwork pods would contend for host port60073during a rollout.SCANOPY_NAME: scanopy-codelooksis a fixed static identity (previously derived fromspec.nodeNamewhen the daemon was a DaemonSet).SCANOPY_INTERFACES=enp1s0np0restricts L2 scanning to the physical LAN NIC, preventing the hostNetwork daemon from auto-scanning Cilium pod/service CIDRs and saturating node conntrack.SCANOPY_CONCURRENT_SCANS=5caps simultaneous host scans. RequiresdnsPolicy: ClusterFirstWithHostNet. Multus was rejected: the only existing NAD is a macvlan on the same primary NIC, so it adds nothing over hostNetwork without authoring VLAN-tagged NADs. - Database: shared CNPG
postgres18-rw.database.svc. Cluster standard (metabase, netbox). Apostgres-initinitContainer provisions the DB + user; the Flux KustomizationdependsOncloudnative-pg-clusterindatabase. - DB TLS:
?sslmode=require. CNPG presents a valid cert-manager cert (postgres18-tls, real SANs + issuer DN), so the chain verifies. Matches NetBox. Thesslmode=disableused by Metabase is a JVM-only workaround (Java's strict TLS verifier) and does not apply to Scanopy's Rust client. Hardening path isverify-fullwith a mountedca.crt+sslrootcert; never drop todisable. - Auth: built-in password login. No OIDC/SSO in v1; first run creates the admin in the UI.
- SMTP: internal relay at
smtp2graph.infrastructure.svc.cluster.local:25(unauthenticated; sends via Graph as itsscanopy@alias). - SNMP community via ExternalSecret, mounted read-only at the neutral path
/run/secrets/snmp-community. The community string lives only in 1Password (repo is PUBLIC); the credential is then configured in the UI pointing at that file. - Docker-socket scan source dropped. Talos runs containerd with no Docker socket, so it is
disabled explicitly with
SCANOPY_ENABLE_LOCAL_DOCKER_SOCKET=false. - Components:
volsync(VOLSYNC_CAPACITY: 5Gi) backing the server/dataPVC. (Gatus monitoring is automatic via the gatus-sidecar chart, which auto-discovers the HTTPRoute; the oldgatus/guardedDNS-check component is gone.) No PodMonitor: Scanopy exposes no Prometheus metrics.
Deploy gotchas¶
- Daemon pairing is shared state. Every daemon and the server read
scanopy-secret(viaenvFrom), sharingSCANOPY_DAEMON_API_KEYandSCANOPY_NETWORK_IDso each per-node daemon registers against the server overhttp://scanopy.network.svc.cluster.local:60072. Daemon registration may require a "network" object to exist first: create the admin + network in the UI. - ExternalSecret key coverage.
scanopy-secretmust carryINIT_POSTGRES_*,SCANOPY_DATABASE_URL,SCANOPY_DAEMON_API_KEY,SCANOPY_NETWORK_ID;scanopy-snmp-secretmust carrycommunity. These are not validated by render-time CI: grep the rendered manifests for everysecretRef/secretKeyRefand projecteditems[].keybefore applying. The CNPG superuser fields (POSTGRES_SUPER_USER/POSTGRES_SUPER_PASS) come from the existingcloudnative-pg1Password item; ifPOSTGRES_SUPER_USERis absent, hardcodeINIT_POSTGRES_SUPER_USER: postgres. - Secrets must live in the
Talos1Password vault (the ClusterSecretStore only readsTalos). Create thescanopyitem via theopCLI, never the UI;SCANOPY_NETWORK_IDis a lowercase UUID, andSNMP_COMMUNITYis the one value supplied by hand. volsynccomponent lives inks.yamlspec.componentsonly, not also inapp/kustomization.yaml, or it double-applies. The PVC it creates is named${APP}(scanopy), referenced by the server'spersistence.data.existingClaim.- hostNetwork port
60073is bound on every node's host IP. Confirm nothing else uses it. - Daemon hostPath
/var/lib/scanopy-daemonholds daemon config; it persists across Talos reboots (/var/libis writable + persistent). - Privileged is allowed: the cluster enforces no restricted PodSecurity and the
networknamespace has no PSA labels, so the privileged hostNetwork daemon admits cleanly. - Image digests are pinned for both server and daemon (kept in lockstep by Renovate) and the
postgres-initinitContainer.
Operational notes¶
- Check Kustomization + both HelmReleases reach
Ready=True:
- Verify both ExternalSecrets are
SecretSynced=Trueand their target Secrets exist:
kubectl -n network get externalsecret scanopy scanopy-snmp
kubectl -n network get secret scanopy-secret scanopy-snmp-secret
- Confirm DB provisioning + TLS connect from the server pod:
kubectl -n network logs deploy/scanopy -c init-db
kubectl -n network logs deploy/scanopy -c app | grep -iE 'tls|ssl|database|connect'
- Confirm the daemon pod is running and registered:
kubectl -n network get pods -l app.kubernetes.io/name=scanopy -o wide
kubectl -n network logs deploy/scanopy-daemon | grep -iE 'register|network|server'
- After deploy, in the UI: create an SNMP credential pointing at
/run/secrets/snmp-communityand confirm a switch/AP/router is discovered; trigger a test email (e.g. a user invite) and confirm it relays via the internal SMTP relay. - The server container runs with no restrictive
securityContext(writes/data, reads/app/static); harden torunAsNonRoot/readOnlyRootFilesystemonly after confirming it stays up. - Follow-ups out of scope for v1: OIDC/SSO and Prometheus metrics.