chore(deploy): run unprivileged, keep secrets out of the config, support tls - #17
Merged
Merged
Conversation
ekalinin
force-pushed
the
feat/distributed-watch
branch
from
August 17, 2026 19:26
3f2dbec to
f24c623
Compare
ekalinin
force-pushed
the
chore/deploy-hardening
branch
from
August 17, 2026 19:26
36712d3 to
ef48efb
Compare
…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
force-pushed
the
feat/distributed-watch
branch
from
August 18, 2026 09:47
f24c623 to
4c4e8a6
Compare
ekalinin
force-pushed
the
chore/deploy-hardening
branch
from
August 18, 2026 09:47
ef48efb to
359f8a9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5
Running as root
deploy/Dockerfilehad noUSERanddeployment.yamlnosecurityContext. 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]andseccompProfile: RuntimeDefault. A read-only root leaves nowhere to write, so/data/resultsand/tmpare mounted asemptyDir(swap the former for a PVC if results must outlive the pod; withdefault_storage: s3they 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, soPOD_NAMEfromdeployment.yamlnever 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 parsedyaml.Nodetree 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 - whatkubectl create secret --from-filestores - 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$VARis 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 asdeploy/k8s/secret.example.yaml: applied as is bykubectl apply -f deploy/k8s/, its two identical placeholder tokens are refused by startup validation and the pod ends up inCrashLoopBackOffwith an error that does not name the file. Copy it, fill it in, apply the copy - and a missing Secret then fails withsecret "dbbridge-secrets" not found, which does name what is missing.trusted_proxy_countin the ConfigMap is 0. The only thing in front of the pod here is a ClusterIP Service, which is L4 and adds noX-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 explicitserver.tls.allow_h2cor 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 -
serveris 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 noschemeand no volume carries a certificate. That is called out indeployment.yamland indeploy/README.md, with the alternative - terminate at an ingress and leaveserver.tlsunset.Dev stack
ResultRefwherever they like.:8081and the public Service does not carry it./metricsneeds theadminscope, 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 buildfollowed byid -ureturns 10001; the container starts under--read-onlywithdefault_storage: s3and answers/healthz; in a running compose stack the Redis port is unpublished, the MinIO bucket and objects answer 403 anonymously, Prometheus scrapes:8081on both replicas with statusup, and the two replicas register distinctdbbridge: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.