diff --git a/server/manifest/docker/Dockerfile b/server/manifest/docker/Dockerfile index c4828a98..060acba8 100644 --- a/server/manifest/docker/Dockerfile +++ b/server/manifest/docker/Dockerfile @@ -1,7 +1,52 @@ +# 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.26-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 . . + +# 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 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 + +# 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 + +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 && chmod +x /app/entrypoint.sh + +EXPOSE 12800 + +# 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..63e9c63c --- /dev/null +++ b/server/manifest/docker/entrypoint.sh @@ -0,0 +1,115 @@ +#!/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}" + +# 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" + 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