From 1539f067f9e2eec9bd10800df9b638e1d45fd0a7 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 10 Oct 2026 09:13:50 +0330 Subject: [PATCH] Add private Docker UI relay for Nginx Proxy Manager --- Dockerfile | 4 ++- cmd/ui-proxy/main.go | 46 +++++++++++++++++++++++++++++++ cmd/ui-proxy/main_test.go | 58 +++++++++++++++++++++++++++++++++++++++ docs/BACKEND-SERVER.md | 16 +++++++++++ server/compose.yaml | 26 ++++++++++++++++++ server/deploy.sh | 16 +++++++---- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 cmd/ui-proxy/main.go create mode 100644 cmd/ui-proxy/main_test.go diff --git a/Dockerfile b/Dockerfile index 433e0ce..8f674a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/test-mqtt5 ./cmd/test-mqtt5 \ - && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/device-sim ./cmd/device-sim + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/device-sim ./cmd/device-sim \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ui-proxy ./cmd/ui-proxy FROM build AS test RUN go test ./... && go vet ./... @@ -14,5 +15,6 @@ RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app \ && apk add --no-cache ca-certificates COPY --from=build /out/test-mqtt5 /test-mqtt5 COPY --from=build /out/device-sim /device-sim +COPY --from=build /out/ui-proxy /ui-proxy USER app:app ENTRYPOINT ["/test-mqtt5"] diff --git a/cmd/ui-proxy/main.go b/cmd/ui-proxy/main.go new file mode 100644 index 0000000..e6bc855 --- /dev/null +++ b/cmd/ui-proxy/main.go @@ -0,0 +1,46 @@ +// ui-proxy exposes the read-only loopback viewer to the private Docker gateway. +package main + +import ( + "flag" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "time" +) + +func handler(target *url.URL) http.Handler { + proxy := httputil.NewSingleHostReverseProxy(target) + original := proxy.Director + proxy.Director = func(r *http.Request) { + original(r) + r.Host = target.Host + // The viewer is read-only behind NPM; do not forward the public browser origin + // into the loopback-only origin guard. Mutating methods are rejected below. + r.Header.Del("Origin") + } + proxy.FlushInterval = -1 // stream SSE immediately + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "read-only viewer", http.StatusMethodNotAllowed) + return + } + proxy.ServeHTTP(w, r) + }) +} + +func main() { + address := flag.String("listen", "172.20.0.1:18082", "Private Docker gateway listener") + flag.Parse() + host, _, err := net.SplitHostPort(*address) + ip := net.ParseIP(host) + if err != nil || ip == nil || !ip.IsPrivate() { + log.Fatal("listen must use an explicit private IP and port") + } + target, _ := url.Parse("http://127.0.0.1:18080") + server := &http.Server{Addr: *address, Handler: handler(target), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 8 << 10} + log.Fatal(server.ListenAndServe()) +} diff --git a/cmd/ui-proxy/main_test.go b/cmd/ui-proxy/main_test.go new file mode 100644 index 0000000..1563858 --- /dev/null +++ b/cmd/ui-proxy/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestRejectWrites(t *testing.T) { + for _, method := range []string{"POST", "PUT", "DELETE"} { + w := httptest.NewRecorder() + target, _ := url.Parse("http://127.0.0.1:18080") + handler(target).ServeHTTP(w, httptest.NewRequest(method, "http://shop.my-mm.ir/api/snapshot", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatal("write method forwarded") + } + } +} + +func TestForwardPublicHostOriginAndStream(t *testing.T) { + release := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host == "shop.my-mm.ir" || r.Header.Get("Origin") != "" { + t.Error("public host/origin leaked into local guard") + } + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte("data: test\n\n")) + w.(http.Flusher).Flush() + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + relay := httptest.NewServer(handler(target)) + defer relay.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", relay.URL+"/api/events", nil) + req.Host = "shop.my-mm.ir" + req.Header.Set("Origin", "https://shop.my-mm.ir") + response, err := http.DefaultClient.Do(req) + if err != nil { + close(release) + t.Fatal(err) + } + defer response.Body.Close() + line, err := bufio.NewReader(response.Body).ReadString('\n') + close(release) + if err != nil || line != "data: test\n" { + t.Fatal("stream buffered or missing") + } +} diff --git a/docs/BACKEND-SERVER.md b/docs/BACKEND-SERVER.md index c5da8d9..8f8a124 100644 --- a/docs/BACKEND-SERVER.md +++ b/docs/BACKEND-SERVER.md @@ -55,3 +55,19 @@ rtk python3 scripts/check_receipts.py --credentials esp32-test-kit/connection.tx ``` The script allows bounded waiting for UI evidence (default15 seconds), reports transient HTTP failures and still fails if matching evidence never appears. The old tunnel timeout was not reproduced; no historical root cause is claimed. Broker restart/persistence verification remains a separate Sprint5 gate. + +## Nginx Proxy Manager setup for shop.my-mm.ir + +Docker `ui-proxy` listens only on the private Docker gateway `172.20.0.1:18082` and forwards read-only requests to the backend's loopback viewer. NPM already has a network with this gateway. The public server IP has no new UI listener. The relay rewrites Host/Origin for the private viewer and streams SSE without buffering. Both backend and relay must pass deployment health checks; rollback removes only orphan services in this backend Compose project. + +In NPM, add a Proxy Host: + +- Domain Names: `shop.my-mm.ir` +- Scheme: `http` +- Forward Hostname/IP: `172.20.0.1` +- Forward Port: `18082` +- Cache Assets: off +- Advanced: `proxy_buffering off;` and `proxy_read_timeout 3600s;` +- SSL: request a new Let's Encrypt certificate for the domain, then enable Force SSL. + +DNS already points to109.122.252.129. NPM owns domain/TLS settings; this task does not modify its routes or certificates. If its Docker network is recreated with a different gateway, update the relay listener/healthcheck and NPM upstream together. Keep the backend itself at127.0.0.1:18080. diff --git a/server/compose.yaml b/server/compose.yaml index 6557bfb..35e46b4 100644 --- a/server/compose.yaml +++ b/server/compose.yaml @@ -55,3 +55,29 @@ services: options: max-size: 10m max-file: "3" + + ui-proxy: + image: mqtt5-test-backend:${IMAGE_TAG:?Set IMAGE_TAG} + entrypoint: ["/ui-proxy"] + command: ["--listen", "172.20.0.1:18082"] + network_mode: host + user: "10001:10001" + init: true + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://172.20.0.1:18082/healthz >/dev/null"] + interval: 10s + timeout: 4s + retries: 6 + start_period: 15s + logging: + driver: local + options: + max-size: 10m + max-file: "3" diff --git a/server/deploy.sh b/server/deploy.sh index 6f7a24e..daae1f8 100644 --- a/server/deploy.sh +++ b/server/deploy.sh @@ -21,10 +21,10 @@ rollback() { if [ "$HAD_PREVIOUS" = true ]; then cp "$RUNTIME/.env.previous" "$RUNTIME/.env" cp "$RUNTIME/compose.previous.yaml" "$RUNTIME/compose.yaml" - docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate backend + docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate --remove-orphans echo "Previous backend release restored; inspect its health." else - docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" stop backend || true + docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" stop backend ui-proxy || true echo "First deployment failed; backend stopped." fi } @@ -35,11 +35,15 @@ if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env.nex fi mv "$RUNTIME/compose.next.yaml" "$RUNTIME/compose.yaml" mv "$RUNTIME/.env.next" "$RUNTIME/.env" -if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate backend; then rollback; exit 1; fi -CID=$(docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" ps -q backend) +if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" up -d --force-recreate --remove-orphans; then rollback; exit 1; fi +CIDS=$(docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" ps -q backend ui-proxy) for attempt in $(seq 1 30); do - HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$CID" 2>/dev/null || true) - if [ "$HEALTH" = healthy ]; then echo "Backend healthy: $TAG"; exit 0; fi + HEALTHY=true + for CID in $CIDS; do + HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$CID" 2>/dev/null || true) + [ "$HEALTH" = healthy ] || HEALTHY=false + done + if [ "$HEALTHY" = true ] && [ -n "$CIDS" ]; then echo "Backend healthy: $TAG"; exit 0; fi sleep 2 done echo "Backend health failed; rolling back" >&2