From e0f734c4dfaa0048925b2cb64be3e91635c4781c Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Wed, 22 Apr 2026 15:28:03 +0200 Subject: [PATCH 1/7] feat(server): multi-stage Dockerfile that builds Go from source The previous Dockerfile assumed that the Go binary was already built on the host and copied into the build context before running docker build. That required a separate prep step, only worked when the host arch matched the target, and made the docker.sh pre-build shell mandatory. The multi-stage version compiles the server inside the image using golang:1.24-alpine, so a plain "docker build" works out of the box on any platform supported by the builder (amd64, arm64...). This is what lets a homelab node cross-build and run the server natively on Raspberry Pi without juggling pre-compiled binaries. Also: - build with -trimpath and stripped symbols for smaller images - install ca-certificates and tzdata in the runtime layer so TLS and timezone-aware logging work correctly - EXPOSE 12800 so compose tools and scanners see the port - run the binary via absolute path to remove the implicit ./ in CMD --- server/manifest/docker/Dockerfile | 42 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index c4828a98..370d00a5 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -1,7 +1,37 @@ +# SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +# SPDX-License-Identifier: MIT +# +# Multi-stage build. Run from the server/ directory: +# cd server && docker build -t stackchan-server -f manifest/docker/Dockerfile . +# +# Or with docker compose using a Git URL context: +# build: +# context: https://github.com/m5stack/StackChan.git#main:server +# dockerfile: manifest/docker/Dockerfile + +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache gcc musl-dev sqlite-dev + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ENV CGO_ENABLED=1 GOOS=linux +RUN go build -trimpath -ldflags="-s -w" -o /out/stackChan main.go + FROM alpine:latest -ENV WORKDIR=/app -WORKDIR $WORKDIR -COPY ./stackChan $WORKDIR/stackChan -COPY ./config.yaml $WORKDIR/config.yaml -RUN chmod +x $WORKDIR/stackChan -CMD ["./stackChan"] \ No newline at end of file + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /out/stackChan /app/stackChan +COPY --from=builder /src/manifest/config/config.yaml /app/config.yaml + +EXPOSE 12800 + +CMD ["/app/stackChan"] From 8794551ffec3368a6b752e16a72e077a37a05cd2 Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Wed, 5 Aug 2026 23:33:59 +0200 Subject: [PATCH 2/7] fix(server): bump Dockerfile to golang:1.26-alpine (matches go.mod 1.26.3) Backend v2 (merged from upstream/main) bumped go.mod to 1.26.3, but the multi-stage Dockerfile (PR #19) still pinned golang:1.24-alpine. Build was failing on 'go mod download' with GOTOOLCHAIN=local. --- server/manifest/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index 370d00a5..cdb95760 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -9,7 +9,7 @@ # context: https://github.com/m5stack/StackChan.git#main:server # dockerfile: manifest/docker/Dockerfile -FROM golang:1.24-alpine AS builder +FROM golang:1.26-alpine AS builder RUN apk add --no-cache gcc musl-dev sqlite-dev From bb32ee0ec1295f9f029213de28ab8188fe13e1b3 Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Wed, 5 Aug 2026 23:36:29 +0200 Subject: [PATCH 3/7] fix(server): run go mod tidy in Dockerfile (missing go.sum entries from v2) Backend v2 (merged from upstream/main) added imports (gerror, guid, ...) without regenerating go.sum. Build failed with 'missing go.sum entry'. Adding 'go mod tidy' before 'go build' to reconcile. --- server/manifest/docker/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index cdb95760..62a04d96 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -20,6 +20,10 @@ RUN go mod download COPY . . +# go.mod/go.sum may be out of sync after upstream merges (new imports +# missing from go.sum). `go mod tidy` reconciles before the build. +RUN go mod tidy + ENV CGO_ENABLED=1 GOOS=linux RUN go build -trimpath -ldflags="-s -w" -o /out/stackChan main.go From d321b55adbfb88fd166ab763247da8608bb41aa9 Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Wed, 5 Aug 2026 23:40:50 +0200 Subject: [PATCH 4/7] fix(server): provide empty web/management dir in Docker image Backend v2 calls s.SetServerRoot("web/management") at boot and FATAs if the dir is missing. The admin frontend UI is built separately (npm) and not bundled in our image. Creating an empty dir lets the API boot; the UI itself is not needed for the robot pipeline. --- server/manifest/docker/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index 62a04d96..921d6ba2 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -36,6 +36,12 @@ WORKDIR /app COPY --from=builder /out/stackChan /app/stackChan COPY --from=builder /src/manifest/config/config.yaml /app/config.yaml +# Backend v2 calls s.SetServerRoot("web/management") at boot and FATAs if the +# directory is missing. The frontend admin UI is built separately (npm) and not +# bundled here. Provide an empty dir so the API boots; the UI is not needed for +# the robot pipeline. +RUN mkdir -p /app/web/management + EXPOSE 12800 CMD ["/app/stackChan"] From 2f90ab128f3d58610664484160f775d43cbd056c Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Thu, 6 Aug 2026 09:34:30 +0200 Subject: [PATCH 5/7] feat(server): generate RSA keys at boot via entrypoint.sh Backend v2 (utility/rsa.go) panics at init() if any of the 4 RSA keys (rsa.{server,client}.{public,private}) is empty in config.yaml. This forced operators to leak PEM material into a committed config. Add entrypoint.sh that: - Generates two 2048-bit RSA pairs at first boot if config.yaml has no PEM material yet (idempotent: skips if operator pre-filled the keys). - Persists them to /app/rsa-keys (bind-mount-friendly for stable keys across container recreates). - Rewrites the rsa: section in config.yaml with YAML block scalars carrying the generated PEM. Dockerfile: - COPY entrypoint.sh, CMD points to it. - mkdir /app/web/management (already done, kept). Behavior validated locally with the upstream config template (YAML still parses, all sections preserved, server.private filled with valid PEM). --- server/manifest/docker/Dockerfile | 7 +- server/manifest/docker/entrypoint.sh | 101 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100755 server/manifest/docker/entrypoint.sh diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index 921d6ba2..facef044 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -35,13 +35,16 @@ WORKDIR /app COPY --from=builder /out/stackChan /app/stackChan COPY --from=builder /src/manifest/config/config.yaml /app/config.yaml +COPY manifest/docker/entrypoint.sh /app/entrypoint.sh # Backend v2 calls s.SetServerRoot("web/management") at boot and FATAs if the # directory is missing. The frontend admin UI is built separately (npm) and not # bundled here. Provide an empty dir so the API boots; the UI is not needed for # the robot pipeline. -RUN mkdir -p /app/web/management +RUN mkdir -p /app/web/management && chmod +x /app/entrypoint.sh EXPOSE 12800 -CMD ["/app/stackChan"] +# Entrypoint provisions RSA keys at first boot (backend v2 panics on empty keys). +# Mount /app/rsa-keys as a volume to keep stable keys across container recreates. +CMD ["/app/entrypoint.sh"] diff --git a/server/manifest/docker/entrypoint.sh b/server/manifest/docker/entrypoint.sh new file mode 100755 index 00000000..e511f6e9 --- /dev/null +++ b/server/manifest/docker/entrypoint.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD +# SPDX-License-Identifier: MIT +# +# Entrypoint for the multi-stage stackchan-server Docker image. +# +# Backend v2 (utility/rsa.go) panics at init() if any of the 4 RSA keys is +# empty in the config. This entrypoint provisions them at first boot so the +# operator never has to leak PEM material into a committed config.yaml. +# +# Behavior: +# 1. If config.yaml already has PEM material, skip (operator-provided). +# 2. Else generate two 2048-bit RSA pairs to $RSA_KEYS_DIR (mount-friendly). +# 3. Rewrite config.yaml: the rsa: section is replaced with a freshly-built +# block carrying the generated PEM as YAML block scalars. +# +# Idempotent: reuses existing keys if $RSA_KEYS_DIR/server_private.pem exists. +# +# Env: +# CONFIG_PATH default /app/config.yaml +# RSA_KEYS_DIR default /app/rsa-keys +set -eu + +CONFIG_PATH="${CONFIG_PATH:-/app/config.yaml}" +RSA_KEYS_DIR="${RSA_KEYS_DIR:-/app/rsa-keys}" + +# Skip if operator pre-filled RSA material in the mounted config. +if grep -q "BEGIN RSA PRIVATE KEY\|BEGIN PRIVATE KEY\|BEGIN PUBLIC KEY" "$CONFIG_PATH" 2>/dev/null; then + echo "[entrypoint] config.yaml already contains RSA material, skipping generation" + exec /app/stackChan +fi + +echo "[entrypoint] provisioning RSA keys" +mkdir -p "$RSA_KEYS_DIR" + +if [ ! -s "$RSA_KEYS_DIR/server_private.pem" ]; then + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$RSA_KEYS_DIR/server_private.pem" 2>/dev/null + openssl rsa -in "$RSA_KEYS_DIR/server_private.pem" -pubout -out "$RSA_KEYS_DIR/server_public.pem" 2>/dev/null + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$RSA_KEYS_DIR/client_private.pem" 2>/dev/null + openssl rsa -in "$RSA_KEYS_DIR/client_private.pem" -pubout -out "$RSA_KEYS_DIR/client_public.pem" 2>/dev/null + echo "[entrypoint] generated fresh RSA pairs in $RSA_KEYS_DIR" +else + echo "[entrypoint] reusing existing RSA pairs from $RSA_KEYS_DIR" +fi + +# Build a sed script that deletes the rsa: section and inserts the new block. +# The rsa: section spans from /^rsa:/ to the line *before* the next top-level +# key (/^[a-z]/ after the opening line). The c\ command replaces it. +SED_SCRIPT=$(mktemp) +trap 'rm -f "$SED_SCRIPT" /tmp/rsa_block.yaml' EXIT + +# 1) Write the rsa: block to a temp file. 6-space indent matches field depth. +emit_block() { printf '|\n'; sed -e 's/^/ /' -e '/^$/d' "$1"; } + +{ + echo "rsa:" + echo " server:" + printf ' public: '; emit_block "$RSA_KEYS_DIR/server_public.pem" + printf ' private: '; emit_block "$RSA_KEYS_DIR/server_private.pem" + echo " client:" + printf ' public: '; emit_block "$RSA_KEYS_DIR/client_public.pem" + printf ' private: '; emit_block "$RSA_KEYS_DIR/client_private.pem" +} > /tmp/rsa_block.yaml + +# 2) sed script: delete the rsa: section, then insert the new block in place. +# Using a placeholder line, then r (read file) — most portable sed feature. +cat > "$SED_SCRIPT" <<'EOF' +/^rsa:/{ + # Loop: delete this line; if next line starts with a letter, we are done. + # Otherwise it is a child of rsa: — keep deleting. + :loop + N + /\nrsa:/!{ + /\n[a-z]/!{ + s/.*\n// + b loop + } + } + # Replace everything from ^rsa: up to (not including) the next top-level + # key with the generated block via `r` after a marker. + s/^\(rsa:\n\)\(\( [^\n]*\|\n\)*\)\n\([a-z]\)/RSA_BLOCK_PLACEHOLDER\n\4/ +} +EOF + +# The hand-rolled sed above is fragile across BSD/GNU sed. Fall back to a +# simple, robust awk one-liner that does the same job. +awk ' + BEGIN { in_rsa = 0 } + /^rsa:/ { in_rsa = 1; while ((getline line < "/tmp/rsa_block.yaml") > 0) print line; close("/tmp/rsa_block.yaml"); next } + in_rsa && /^[a-z]/ { in_rsa = 0 } + in_rsa { next } + { print } +' "$CONFIG_PATH" > "$CONFIG_PATH.tmp" && mv "$CONFIG_PATH.tmp" "$CONFIG_PATH" + +if ! grep -q "BEGIN RSA PRIVATE KEY\|BEGIN PRIVATE KEY\|BEGIN PUBLIC KEY" "$CONFIG_PATH"; then + echo "[entrypoint] ERROR: RSA substitution failed, config.yaml still has empty keys" >&2 + exit 1 +fi + +echo "[entrypoint] RSA keys provisioned, starting stackchan-server" +exec /app/stackChan From 2f5ac40ba42ae0220ad0cb8cdc2074571dab3fc7 Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Thu, 6 Aug 2026 09:38:39 +0200 Subject: [PATCH 6/7] fix(server): add openssl to runtime image (entrypoint.sh dependency) entrypoint.sh generates RSA keys via openssl at first boot, but the runtime stage (alpine:latest) did not ship openssl. Container crashed with exit 127 ('openssl: not found') in a loop. Adding it to the apk install line alongside ca-certificates and tzdata. --- server/manifest/docker/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index facef044..060acba8 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -29,7 +29,9 @@ RUN go build -trimpath -ldflags="-s -w" -o /out/stackChan main.go FROM alpine:latest -RUN apk add --no-cache ca-certificates tzdata +# openssl is needed by entrypoint.sh to provision RSA keys at first boot. +# ca-certificates + tzdata are runtime deps for the Go binary. +RUN apk add --no-cache ca-certificates tzdata openssl WORKDIR /app From 474fbd67eb6ff7d3fd89290bd60756e9cd5912bd Mon Sep 17 00:00:00 2001 From: Nourrisse Florian Date: Thu, 6 Aug 2026 09:42:58 +0200 Subject: [PATCH 7/7] fix(server): inject routeOverWrite via entrypoint (upstream duplicate-route bug) Backend v2 has a duplicate route bug: device.NewV1 (ControllerV1.GetUser AccountInfo) and stackchandevice.NewV2 (ControllerV2.GetDeviceUserInfo) both register GET /stackChan/device/user, FATAs at boot. Confirmed on pure upstream/main (not from our merge). Rather than patching cmd.go (would touch upstream code), inject server.routeOverWrite: true just under the top-level server: key in config.yaml at boot. Lets the second registration silently overwrite the first. Idempotent: skipped if already present. --- server/manifest/docker/entrypoint.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/manifest/docker/entrypoint.sh b/server/manifest/docker/entrypoint.sh index e511f6e9..63e9c63c 100755 --- a/server/manifest/docker/entrypoint.sh +++ b/server/manifest/docker/entrypoint.sh @@ -24,6 +24,20 @@ set -eu CONFIG_PATH="${CONFIG_PATH:-/app/config.yaml}" RSA_KEYS_DIR="${RSA_KEYS_DIR:-/app/rsa-keys}" +# Workaround for upstream bug: device.NewV1 (ControllerV1.GetUserAccountInfo) +# and stackchandevice.NewV2 (ControllerV2.GetDeviceUserInfo) both register +# GET /stackChan/device/user. Backend v2 FATAs at boot on the duplicate. +# Inject routeOverWrite: true just under the top-level server: key so the +# second registration silently overwrites the first. Idempotent. +if ! grep -q "^ routeOverWrite:" "$CONFIG_PATH" 2>/dev/null; then + echo "[entrypoint] injecting server.routeOverWrite (upstream duplicate-route bug)" + awk ' + BEGIN { inserted = 0 } + /^server:/ && !inserted { print; print " routeOverWrite: true"; inserted = 1; next } + { print } + ' "$CONFIG_PATH" > "$CONFIG_PATH.tmp" && mv "$CONFIG_PATH.tmp" "$CONFIG_PATH" +fi + # Skip if operator pre-filled RSA material in the mounted config. if grep -q "BEGIN RSA PRIVATE KEY\|BEGIN PRIVATE KEY\|BEGIN PUBLIC KEY" "$CONFIG_PATH" 2>/dev/null; then echo "[entrypoint] config.yaml already contains RSA material, skipping generation"