Skip to content

chore(deploy): run unprivileged, keep secrets out of the config, support tls - #17

Merged
ekalinin merged 1 commit into
feat/distributed-watchfrom
chore/deploy-hardening
Aug 20, 2026
Merged

chore(deploy): run unprivileged, keep secrets out of the config, support tls#17
ekalinin merged 1 commit into
feat/distributed-watchfrom
chore/deploy-hardening

Conversation

@ekalinin

@ekalinin ekalinin commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Closes #5

Stacked on #16.

Running as root

deploy/Dockerfile had no USER and deployment.yaml no securityContext. The process ran as root, which turns any file-handling defect from "writes into the results directory" into "writes anywhere in the container".

It now runs as UID 10001 with runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: [ALL] and seccompProfile: RuntimeDefault. A read-only root leaves nowhere to write, so /data/results and /tmp are mounted as emptyDir (swap the former for a PVC if results must outlive the pod; with default_storage: s3 they do not).

Identical instance IDs across replicas - #5

dbbridge-$(POD_NAME) in the ConfigMap stayed a literal: environment variables are not substituted inside a ConfigMap, and the config loader did no expansion either, so POD_NAME from deployment.yaml never reached the application. Both replicas reported the same instance ID and considered themselves the same owner, which makes leases, cross-instance cancellation and owner-loss detection meaningless.

The loader now expands ${VAR} from the environment. The expansion walks the parsed yaml.Node tree and substitutes into scalars only, so the value is data and never markup: substituting into the raw file text meant a password with a quote or a backslash failed the parse with an error naming neither the variable nor the real line, and one with a trailing newline - what kubectl create secret --from-file stores - silently became a different string. A ${VAR} inside a comment is left alone for the same reason. An unset variable is a startup error rather than an empty string, because substituting nothing would reproduce exactly the same identical-ID bug. A bare $VAR is deliberately left alone, so DSNs and passwords may contain a dollar sign.

Single-node and non-Kubernetes deployments are unaffected: a config with no ${...} references behaves exactly as before.

Secrets

The same mechanism moves the Redis password, the S3 credentials and the API tokens out of the ConfigMap and into a Secret. It ships as deploy/k8s/secret.example.yaml: applied as is by kubectl apply -f deploy/k8s/, its two identical placeholder tokens are refused by startup validation and the pod ends up in CrashLoopBackOff with an error that does not name the file. Copy it, fill it in, apply the copy - and a missing Secret then fails with secret "dbbridge-secrets" not found, which does name what is missing.

trusted_proxy_count in the ConfigMap is 0. The only thing in front of the pod here is a ClusterIP Service, which is L4 and adds no X-Forwarded-For; a non-zero count there would let any caller pick its own client address, and with it its own rate-limit bucket once #19 lands.

TLS

TLS is configurable for all three listeners via server.tls.{cert_file,key_file}. Without a certificate gRPC still comes up as cleartext HTTP/2, but that now requires an explicit server.tls.allow_h2c or it logs a warning on every start.

The pair is loaded before the listeners start, so a wrong path fails the process with a clear message rather than killing one listener goroutine after the rest are up, past the point where deferred cleanups still run. It is re-read when either file's modification time changes, so a certificate renewed by cert-manager is picked up without a restart - server is reported as ignored by a reload and the listeners are not rebuilt, so otherwise the pod served the old certificate until something restarted it and served nothing once it expired. A new pair that does not load leaves the previous one serving.

Enabling TLS turns it on for all three listeners at once, and the shipped manifests do not account for that: the probes are plain httpGet, the Prometheus annotation has no scheme and no volume carries a certificate. That is called out in deployment.yaml and in deploy/README.md, with the alternative - terminate at an ingress and leave server.tls unset.

Dev stack

  • Redis no longer publishes its port and requires a password. The MetaStore holds query records and lease keys; anyone who can write to it can point a ResultRef wherever they like.
  • The MinIO bucket is no longer made anonymously readable, which had published every SQL result to anyone who could guess an object key.
  • Metrics moved to the admin listener, so Prometheus scrapes :8081 and the public Service does not carry it. /metrics needs the admin scope, so the scrape config carries a token.
  • automountServiceAccountToken: false: the application never talks to the cluster API.

Verification

go test -race ./..., golangci-lint run ./... - clean, including tests for the expansion, an unset variable producing an error, a bare $ being left intact, a reference in a comment being ignored, secrets containing a quote, a backslash, a trailing newline, a YAML-injection attempt and values that look like numbers or booleans surviving byte for byte, a numeric value still decoding as a number, the TLS pair validation, and certificate rotation.

Checked by hand for #5: docker build followed by id -u returns 10001; the container starts under --read-only with default_storage: s3 and answers /healthz; in a running compose stack the Redis port is unpublished, the MinIO bucket and objects answer 403 anonymously, Prometheus scrapes :8081 on both replicas with status up, and the two replicas register distinct dbbridge:instance:* keys. Cross-instance ownership was exercised end to end: a query owned by one replica was stopped through the other and its result read back from it.

…ort tls

The container had no USER and the pod no securityContext, so the process ran
as root: any file-handling defect became "write anywhere in the container".
It now runs as UID 10001 with a read-only root filesystem, all capabilities
dropped, no privilege escalation and the RuntimeDefault seccomp profile, with
emptyDir volumes for the result directory and /tmp because a read-only root
leaves nowhere else to write.

The config loader had no variable substitution, so `dbbridge-$(POD_NAME)` in
the ConfigMap stayed a literal and both replicas reported the same owner,
which makes leases, remote cancellation and owner-loss detection meaningless.
`${VAR}` is now expanded from the environment and an unset variable is a
startup error rather than an empty string. A bare `$VAR` is left alone, so
DSNs and passwords may contain a dollar sign.

That same mechanism moves the Redis password, the S3 credentials and the API
tokens out of the ConfigMap and into a Secret.

TLS is configurable for all three listeners; without a certificate gRPC still
runs as cleartext HTTP/2, but that now has to be acknowledged with
server.tls.allow_h2c or it logs a warning on every start.

The dev stack no longer publishes Redis without a password, and the MinIO
bucket is no longer made anonymously readable, which had published every
query result to anyone who could guess an object key. Metrics move to the
admin listener, so Prometheus scrapes port 8081 and the public Service does
not carry it.
@ekalinin
ekalinin force-pushed the feat/distributed-watch branch from f24c623 to 4c4e8a6 Compare August 18, 2026 09:47
@ekalinin
ekalinin force-pushed the chore/deploy-hardening branch from ef48efb to 359f8a9 Compare August 18, 2026 09:47
@ekalinin
ekalinin merged commit 794ba91 into feat/distributed-watch Aug 20, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant