Add private Docker UI relay for Nginx Proxy Manager
Test and deploy MQTT backend / test (push) Successful in 1m50s
Test and deploy MQTT backend / deploy (push) Successful in 25s

This commit is contained in:
2026-10-10 09:13:50 +03:30
parent 55f11c9956
commit 1539f067f9
6 changed files with 159 additions and 7 deletions
+3 -1
View File
@@ -4,7 +4,8 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/test-mqtt5 ./cmd/test-mqtt5 \ 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 FROM build AS test
RUN go test ./... && go vet ./... 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 && apk add --no-cache ca-certificates
COPY --from=build /out/test-mqtt5 /test-mqtt5 COPY --from=build /out/test-mqtt5 /test-mqtt5
COPY --from=build /out/device-sim /device-sim COPY --from=build /out/device-sim /device-sim
COPY --from=build /out/ui-proxy /ui-proxy
USER app:app USER app:app
ENTRYPOINT ["/test-mqtt5"] ENTRYPOINT ["/test-mqtt5"]
+46
View File
@@ -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())
}
+58
View File
@@ -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")
}
}
+16
View File
@@ -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. 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.
+26
View File
@@ -55,3 +55,29 @@ services:
options: options:
max-size: 10m max-size: 10m
max-file: "3" 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"
+10 -6
View File
@@ -21,10 +21,10 @@ rollback() {
if [ "$HAD_PREVIOUS" = true ]; then if [ "$HAD_PREVIOUS" = true ]; then
cp "$RUNTIME/.env.previous" "$RUNTIME/.env" cp "$RUNTIME/.env.previous" "$RUNTIME/.env"
cp "$RUNTIME/compose.previous.yaml" "$RUNTIME/compose.yaml" 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." echo "Previous backend release restored; inspect its health."
else 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." echo "First deployment failed; backend stopped."
fi fi
} }
@@ -35,11 +35,15 @@ if ! docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env.nex
fi fi
mv "$RUNTIME/compose.next.yaml" "$RUNTIME/compose.yaml" mv "$RUNTIME/compose.next.yaml" "$RUNTIME/compose.yaml"
mv "$RUNTIME/.env.next" "$RUNTIME/.env" 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 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
CID=$(docker compose --project-directory "$RUNTIME" --env-file "$RUNTIME/.env" -f "$RUNTIME/compose.yaml" ps -q backend) 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 for attempt in $(seq 1 30); do
HEALTH=$(docker inspect --format '{{.State.Health.Status}}' "$CID" 2>/dev/null || true) HEALTHY=true
if [ "$HEALTH" = healthy ]; then echo "Backend healthy: $TAG"; exit 0; fi 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 sleep 2
done done
echo "Backend health failed; rolling back" >&2 echo "Backend health failed; rolling back" >&2