A focused security review of phases 1-4 surfaced one MEDIUM finding (confidence 9/10): in client mode (--upstream set) the cache layer forwards the configured bearer to upstream on every incoming request without authenticating the local caller, AND --addr defaulted to :8443 (all interfaces). Together those mean a CLI user running `zddc-server --upstream https://master --bearer-file ~/token` on a laptop on hotel/cafe Wi-Fi exposes an open-proxy confused-deputy: any attacker on the same L2 connects to https://<laptop-ip>:8443, accepts the self-signed cert, issues GETs (or PUTs/DELETEs that queue in the outbox), and the cache laundries each request through upstream with the engineer's bearer. The full cached subtree leaks. Two layers of defense in config.Load: 1. Loopback default in client mode. When cfg.Upstream is set and neither --addr nor ZDDC_ADDR was passed explicitly, --addr downgrades to "127.0.0.1:8443" (vs ":8443" in master mode). CLI users on a laptop get safe-by-default. Operators who want a non-loopback bind opt in explicitly. 2. Refuse non-loopback bind + bearer-file without acknowledgement. When cfg.Upstream is set, BearerFile is non-empty, the chosen addr is non-loopback, AND --insecure-direct is not set, the load fails with an error that names the bind, the threat (open-proxy confused-deputy laundering bearer credentials), and the acknowledgement flag. The helm zddc-server-cache/ chart already sets ZDDC_INSECURE_DIRECT=1 and relies on Kubernetes-namespaced pod networking for the gating, so the chart path is unaffected. The guard is bearer-file-conditional because proxy mode without a bearer doesn't have a credential to launder, and refusing it would needlessly block proxy-without-auth deployments. Tests in internal/config/config_test.go lock down all four cases: - --upstream with no explicit --addr → 127.0.0.1:8443 - --upstream + non-loopback --addr + --bearer-file (no IDirect) → refuse - --upstream + non-loopback --addr + --bearer-file + --insecure-direct → ok - --upstream + non-loopback --addr + NO bearer → ok (no credential to leak) Doc updates: zddc/README.md client-mode "Flags" section gets a WARNING block describing the loopback default + insecure-direct escape hatch. AGENTS.md ZDDC_UPSTREAM row mentions the addr downgrade. ARCHITECTURE.md gains a "Confused-deputy guard at startup" subsection under "Master + proxy/cache/mirror" with the two-layer defense rationale. helm/zddc-server-cache/values.yaml.example adds an inline note next to addr: ":8080" explaining why the chart sets ZDDC_INSECURE_DIRECT=1 and what the consequence is of removing either side of the gating. Master mode is unaffected — the client-mode validation block is gated by `if cfg.Upstream != ""`. All existing tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| zddc-server-cache | ||
| zddc-server-dev | ||
| zddc-server-prod | ||
| README.md | ||
Helm charts
Three example charts for deploying zddc-server on Kubernetes.
All compile zddc-server from source via an init container — no
container image needs to be pulled from a registry, and no binary needs
to be built ahead of time. The init container clones the repo at a
configured git ref and runs go build; the main container is plain
alpine + the freshly built static binary.
Charts
| Chart | When to use |
|---|---|
zddc-server-prod/ |
Production master. Pin zddc.gitRef to a stable tag (zddc-server-vX.Y.Z). Slower probe cadence; image-pull policy IfNotPresent. Mounts the data PVC directly RW at ZDDC_ROOT. The token system is enabled automatically (tokens persist on the data PVC at <ZDDC_ROOT>/.zddc.d/tokens/); operators visit /.tokens to issue them. |
zddc-server-dev/ |
Development / soak master. Tracks main by default; helm upgrade triggers a pod recreate so each rollout pulls the latest commit. Faster probes; debug-level logging (request headers logged — sensitive). Wraps the data PVC in OverlayFS (lower = PVC mounted RO, upper = ephemeral emptyDir) so dev-side writes never mutate the underlying store. Use this shape when the dev replica points at the same data as prod. |
zddc-server-cache/ |
Downstream client (proxy / cache / mirror) of an upstream master. Set zddc.upstream.url + zddc.upstream.mode; the binary skips master-side machinery and forwards all requests to the master, persisting responses under the cache PVC (in cache or mirror modes). Bearer auth via a separately-created Kubernetes Secret. Use cases: corporate-master → DR-mirror, vendor-scoped mirror in a vendor's own cluster, regional edge cache, dev environment that mirrors prod read-only. Mirror mode adds an access-triggered subtree walker. |
The prod and dev chart values are nearly identical; the differences
are encoded as defaults in each chart's values.yaml.example. The
dev chart's overlay-isolation layer is a structural difference, not a
values-level toggle — see zddc-server-dev/templates/deployment.yaml
for the privileged init container and the data-readonly /
overlay-scratch / data volume sandwich.
The cache chart shares the same source-build pattern but adds
client-mode env wiring (ZDDC_UPSTREAM, ZDDC_MODE, ZDDC_BEARER_FILE,
ZDDC_NO_AUTH, ZDDC_SKIP_TLS_VERIFY, mirror-mode subtree config),
a Recreate strategy (single-instance — multiple replicas would race
the cache directory), and TCP-socket probes (HTTP probes against /
would fail when both upstream is down AND the cache is empty).
Quick start
# Pre-requisite: a PersistentVolumeClaim for ZDDC_ROOT data
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: zddc-root
spec:
accessModes: [ReadWriteMany] # or RWO if single replica is fine
resources: { requests: { storage: 100Gi } }
storageClassName: your-shared-fs # NFS, CephFS, SMB, etc.
EOF
# Production install
cp helm/zddc-server-prod/values.yaml.example my-prod-values.yaml
$EDITOR my-prod-values.yaml # set zddc.gitRef, hostnames, etc.
helm install zddc-server-prod helm/zddc-server-prod/ -f my-prod-values.yaml
# Dev install (tracks main HEAD)
cp helm/zddc-server-dev/values.yaml.example my-dev-values.yaml
$EDITOR my-dev-values.yaml
helm install zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml
# Trigger a rebuild from latest main HEAD (dev chart)
helm upgrade zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml
# Cache install (downstream client of an upstream master)
#
# 1) Issue a bearer token on the master at https://<master>/.tokens
# 2) Create the Secret (do NOT put the token in values.yaml):
kubectl create secret generic zddc-cache-bearer \
--from-literal=token=<paste-token-here>
# 3) Create a cache PVC (separate from the master's data PVC; can
# be smaller — sized to the working set you expect to mirror):
kubectl apply -f - <<'PVC'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: zddc-cache }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 50Gi } }
storageClassName: your-block-storage
PVC
# 4) Install the chart, pointing at your master:
cp helm/zddc-server-cache/values.yaml.example my-cache-values.yaml
$EDITOR my-cache-values.yaml # set zddc.upstream.url, mode, etc.
helm install zddc-server-cache helm/zddc-server-cache/ -f my-cache-values.yaml
What the chart does and doesn't do
Does:
- Clones the configured
zddc.gitRepoatzddc.gitRefin an init container, builds the Go binary, copies it to a sharedemptyDir, and starts the main container against that binary. - Wires the
ZDDC_*environment-variable contract (root path, addr, email header, CORS allowlist, log level, index path). - Mounts a caller-supplied PersistentVolumeClaim at
ZDDC_ROOT(prod chart) or as the OverlayFS lowerdir behind a mergedZDDC_ROOT(dev chart). - Optionally creates an Ingress (
ingress.enabled: true).
Does not:
- Create the PVC. Operators provision storage themselves; the chart only references it by name.
- Manage TLS for the pod. zddc-server runs in plain HTTP mode behind
whatever ingress / authenticating reverse proxy the cluster already
has.
ZDDC_TLS_CERT=noneandZDDC_INSECURE_DIRECT=1are hardcoded in the templates because the chart is opinionated about the TLS-terminated-upstream deployment shape. - Authenticate users. zddc-server reads the user's email from a header
set by the upstream proxy (
X-Auth-Request-Emailby default). The chart does not deploy oauth2-proxy / nginx-auth-request / Pomerium / etc. — bring your own. - Manage secrets.
values.yaml.examplecontains no secrets and never should. ACL email lists belong in.zddcfiles inside the data volume; image-pull credentials and TLS certs (if you enable ingress TLS) reference Kubernetes secrets you've created separately.
Why build from source instead of using a registry image
Three reasons:
- Reproducibility. The init container's logs show exactly which git ref was built. There's no opaque "what did I deploy" question that a registry tag can introduce.
- One distribution channel. Codeberg release-asset binaries already exist for direct downloads; the chart compiles its own binary from the same source git ref so there's nothing extra to maintain (no separate image registry, no image-promotion pipeline).
- Smaller blast radius. A compromised build image affects only pods that pull during the compromise window. A compromised registry image stays compromised across rollbacks until the digest is rotated.
The cost: every pod start takes 30-60s to clone + go build instead
of pulling a pre-baked image. Acceptable for both chart audiences
(production rollouts are infrequent; dev rollouts trade build time
for tracking-main convenience).
Linting
helm lint helm/zddc-server-prod/
helm lint helm/zddc-server-dev/
helm lint helm/zddc-server-cache/
# Render to inspect (uses default values from values.yaml.example):
helm template test-prod helm/zddc-server-prod/ \
--values helm/zddc-server-prod/values.yaml.example
helm template test-cache helm/zddc-server-cache/ \
--values helm/zddc-server-cache/values.yaml.example