Harden production Docker image and verify runtime permissions

Exclude local secrets and mutable data from image builds, restore Go checksum verification, remove runtime capability tooling and setuid/setgid permissions, and preserve non-root media and demo startup behavior. Add native image smoke checks and document runtime constraints.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Cédric Verstraeten
2026-11-04 20:42:21 +01:00
co-authored by Copilot App
parent 8016b3ba93
commit 024bb0abf9
6 changed files with 206 additions and 38 deletions
+27
View File
@@ -0,0 +1,27 @@
# Only application sources are needed by the production build.
**
!Dockerfile
!machinery/
!machinery/**
!ui/
!ui/**
**/.git
**/.env
**/.env.*
**/node_modules
**/.DS_Store
machinery/main
machinery/vendor
machinery/go.work
machinery/go.work.sum
machinery/www
machinery/data/**
!machinery/data/config/
!machinery/data/config/config.json
!machinery/data/config/user.json
!machinery/data/test-480p.mp4
ui/build
ui/coverage
ui/.npmrc
ui/.yarnrc
+2
View File
@@ -27,6 +27,8 @@ jobs:
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Test production image
run: sh deployments/docker/test-image.sh ${{ matrix.architecture }}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Upload artifact
+20 -36
View File
@@ -14,12 +14,11 @@ ARG TARGETARCH
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
RUN apt-get update && apt-get install -y --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
@@ -37,7 +36,6 @@ RUN go mod download
# Copy the rest of the source after dependencies are primed.
COPY machinery ./
RUN rm -rf .env
##################
# Build Machinery
@@ -52,6 +50,7 @@ RUN RESOLVED_VERSION="${VERSION:-0.0.0}" && \
mv version /agent && \
mv data /agent && \
mkdir -p /agent/data/cloud && \
mkdir -p /agent/data/tus && \
mkdir -p /agent/data/snapshots && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
@@ -96,42 +95,27 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
FROM ${RUNTIME_IMAGE}
############################
# Protect by non-root user.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
rm -rf /var/lib/apt/lists/* && \
groupadd --system kerberosio && \
useradd --system --gid kerberosio --groups video --create-home agent && \
chmod 0755 /home/agent
#################################
# Copy files from previous images
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
COPY --chown=0:0 --from=build-machinery /dist/agent/ /home/agent/
COPY --chown=0:0 --from=build-ui /dist/agent/ /home/agent/
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN /home/agent/main version
#######################
# Make template config
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
###########################
# Set permissions correctly
RUN chown -R agent:kerberosio /home/agent/data
RUN chown -R agent:kerberosio /home/agent/www
###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
# Keep media support and the health probe, but not privilege-management tools.
# UID/GID match the existing trixie image; only mutable application data is owned by agent.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
groupadd --system --gid 999 kerberosio && \
useradd --system --uid 999 --gid kerberosio --groups video \
--home-dir /home/agent --no-create-home agent && \
chmod 0755 /home/agent /home/agent/main && \
cp /home/agent/data/config/config.json /home/agent/data/config.template.json && \
chown -R agent:kerberosio /home/agent/data /home/agent/www && \
setcap 'cap_net_bind_service=+ep' /home/agent/main && \
apt-get purge -y --auto-remove libcap2-bin && \
rm -rf /var/lib/apt/lists/* && \
find / -xdev -type f -perm /6000 -exec chmod a-s {} + && \
/home/agent/main version
###################
# Run non-root user
@@ -146,7 +130,7 @@ EXPOSE 80
######################################
# Check if agent is still running
HEALTHCHECK CMD curl --fail "http://localhost:${AGENT_PORT:-80}/health" || exit 1
HEALTHCHECK --timeout=10s CMD curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://localhost:${AGENT_PORT:-80}/health" || exit 1
###################################################
# Leeeeettttt'ssss goooooo!!!
+7 -2
View File
@@ -228,8 +228,13 @@ You attach a volume to your container by leveraging the `-v` option. To mount yo
More examples for each deployment and automation tool [can be found in the deployment section](https://github.com/kerberos-io/agent/tree/master/deployments). Be sure to verify the permissions of the directory/volume you are attaching. More information in [this issue](https://github.com/kerberos-io/agent/issues/80).
chmod -R 755 kerberos-agent/
chown 100:101 kerberos-agent/ -R
The current image runs as UID `999`, GID `999`. Give that user ownership of
the mounted directories (older image tags may use different IDs):
sudo chown -R 999:999 agent/config agent/recordings
See [container permissions and hardening](deployments/docker/README.md#container-permissions-and-hardening)
for reduced-capability deployment and migration guidance.
## Configure with environment variables
+71
View File
@@ -16,6 +16,77 @@ Feel free to use another port if your host system already has a workload running
docker run -p 8082:80 --name mycamera -d kerberos/agent:latest
### Container permissions and hardening
The production image runs as `agent` (UID `999`, primary GID `999`), with the
supplementary `video` group retained. The binary and home directory are owned by
root; within `/home/agent`, only `data/` and `www/` are writable by the Agent. `www/` must remain writable
because demo mode replaces `env.js` at startup. Existing `/home/agent/data/...`
mount paths, the default port `80`, and the startup command are unchanged.
FFmpeg is required for AAC-to-PCMU audio transcoding, curl for the health check,
and `/bin/sh` for remote-access sessions. They are intentionally retained. The
capability-management tools and setuid/setgid permissions on bundled utilities
are removed. The Agent binary retains only the file capability
`NET_BIND_SERVICE`, needed for port `80` on hosts with privileged low ports.
For an RTSP-only deployment, you can drop the other container capabilities:
```sh
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
-p 80:80 --name mycamera -d kerberos/agent:latest
```
Do not use `--privileged` for ordinary IP cameras. Local cameras still use the
separate RTSP sidecar; any device access belongs to that sidecar. Host networking
remains supported. `no-new-privileges` prevents gaining the binary's file
capability: if enabling it, use an unprivileged internal port (`AGENT_PORT=8082`)
and ensure the container's `net.ipv4.ip_unprivileged_port_start` permits that
port. Do not assume port `80` works with that option on every host.
Bind mounts must be writable by the image's UID/GID; the container does not
change host ownership at startup. Before migrating an older volume, check the
user of the exact old and new image with `docker run --rm --entrypoint id IMAGE`
and adjust only the mounted directories as necessary. For example:
```sh
sudo chown -R 999:999 agent/config agent/recordings
```
A read-only root filesystem is not enabled by default: both `data/` and `www/`
need writable storage (and FFmpeg or shell sessions may need `/tmp`).
Change the default application credentials before exposing the service.
### Building and checking the image
Build from a clean checkout: the default configuration files under
`machinery/data/config/` are included in the image, so do not replace them with
production credentials before building. `.dockerignore` excludes local `.env`
files, recordings, upload checkpoints, dependency directories, and unrelated
repository files from the build context. Go module downloads use Go's default
checksum database verification.
```sh
docker build --pull -t kerberos/agent:local .
sh deployments/docker/test-image.sh kerberos/agent:local
```
The smoke check uses temporary containers and a Docker volume, cleans them up,
and checks permissions, FFmpeg transcoding, volume initialization/writes,
default and custom HTTP ports, health probes, and demo-mode startup. It enforces
privileged low ports to check the file capability rather than relying on
Docker's usual low-port exemption, and checks `no-new-privileges` on port `8082`. PR builds
run it on native AMD64 and ARM64 runners. It does not replace testing against a
real camera or checking all supported ARM variants. Base image tags remain
updateable; use reviewed digest-pinned base-image arguments (`GO_IMAGE` and
`RUNTIME_IMAGE`) when a reproducible build is required, and rebuild regularly
for security updates.
Architecture selections and base image tags are unchanged by this hardening.
The current upstream `golang:1.25-trixie` manifest has no `linux/arm/v6` variant,
so the existing ARMv6 nightly target cannot be validated with that default base.
ARMv6 needs a separately reviewed compatible builder/runtime combination; do
not assume an ARMv7 image runs on ARMv6 hardware.
### Attach a volume
By default your Kerberos agent will store all its configuration and recordings inside the container. It might be interesting to store both configuration and your recordings outside the container, on your local disk. This helps persisting your storage even after you decide to wipe out your Kerberos agent.
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
set -eu
image=${1:?Usage: sh deployments/docker/test-image.sh IMAGE}
container=
volume=
cleanup() {
if [ -n "$container" ]; then docker rm -f "$container" >/dev/null; fi
if [ -n "$volume" ]; then docker volume rm "$volume" >/dev/null; fi
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
docker run --rm --entrypoint /bin/sh "$image" -ec '
test "$(id -u)" = 999
test "$(id -g)" = 999
test ! -w /home/agent
test ! -w /home/agent/main
test -w /home/agent/data/config
test -w /home/agent/data/recordings
test -w /home/agent/data/tus
test -w /home/agent/www
test -z "$(find / -xdev -type f -perm /6000 -print 2>/dev/null)"
! command -v setcap
! command -v capsh
test ! -e /home/agent/.env
test -z "$(find /home/agent/data/tus -type f -print)"
./main -action version
ffmpeg -nostdin -v error -f lavfi -i sine=frequency=1000:sample_rate=8000 \
-t 0.1 -c:a aac -f adts /tmp/test.aac
ffmpeg -nostdin -v error -i /tmp/test.aac -ar 8000 -ac 1 \
-c:a pcm_mulaw -f mulaw /tmp/test.pcm
test -s /tmp/test.pcm
'
wait_healthy() {
attempt=0
while [ "$attempt" -lt 60 ]; do
status=$(docker inspect --format '{{.State.Health.Status}}' "$container")
if [ "$status" = healthy ]; then return; fi
if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
docker logs "$container"
docker inspect --format '{{json .State.Health}}' "$container"
echo "Agent did not become healthy" >&2
return 1
}
# Exercise the default port, Docker volume initialization, and reduced capabilities.
volume=$(docker volume create)
container=$(docker run -d --cap-drop ALL --cap-add NET_BIND_SERVICE \
--sysctl net.ipv4.ip_unprivileged_port_start=1024 \
--health-interval=1s --health-start-period=0s \
--mount "type=volume,source=$volume,target=/home/agent/data" "$image")
wait_healthy
docker exec "$container" sh -ec '
test -r data/config/config.json
touch data/config/.permission-test data/recordings/.permission-test data/tus/.permission-test
rm data/config/.permission-test data/recordings/.permission-test data/tus/.permission-test
'
docker stop --time 10 "$container" >/dev/null
docker rm "$container" >/dev/null
container=
# Demo mode must still be able to replace env.js; probes must bypass proxy settings.
container=$(docker run -d --cap-drop ALL --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges=true \
--sysctl net.ipv4.ip_unprivileged_port_start=1024 \
--health-interval=1s --health-start-period=0s \
-e AGENT_PORT=8082 -e AGENT_MODE=demo \
-e http_proxy=http://127.0.0.1:9 -e HTTP_PROXY=http://127.0.0.1:9 \
-e no_proxy= -e NO_PROXY= "$image")
wait_healthy
docker exec "$container" sh -ec 'test -f www/env.js; test ! -e www/env.demo.js'
echo "Image permissions, media transcoding, volume writes, default/custom ports, and demo startup passed."