Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions server/manifest/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]

# 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"]
115 changes: 115 additions & 0 deletions server/manifest/docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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