diff --git a/.gitignore b/.gitignore index cd2d64aa..2b967af6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ log-index/ GeoLite2-City.mmdb *.log .claude/settings.local.json + +# Cloudflare Workers local state (miniflare cache, durable object storage) +cloudflare/.wrangler/ diff --git a/cloudflare/build-binary.sh b/cloudflare/build-binary.sh new file mode 100755 index 00000000..1367f943 --- /dev/null +++ b/cloudflare/build-binary.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Produce the linux/amd64 nginx-ui binary that demo.Dockerfile copies in. +# +# Cross-compiling from macOS needs a CGO toolchain (the SQLite driver), so this +# builds inside a Linux container rather than on the host. The frontend must be +# built first: app/dist is embedded into the binary at compile time. +set -eu + +repo_root="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +if [ ! -f app/dist/index.html ]; then + echo "app/dist is missing; building the frontend first" >&2 + bun run build +fi + +echo "building nginx-ui for linux/amd64 (this takes a few minutes on a cold cache)" +# Same flags as the release build in .github/workflows/build.yml, plus -s. +# An unstripped binary is ~166 MB, which dominates the image and therefore the +# cold start, since Cloudflare distributes the image before an instance runs. +docker run --rm --platform linux/amd64 \ + -v "$repo_root":/src -w /src \ + -v "$HOME/go/pkg/mod":/go/pkg/mod \ + -e GOWORK=off -e CGO_ENABLED=1 -e GOOS=linux -e GOARCH=amd64 \ + golang:1.26-trixie \ + go build -trimpath -tags=jsoniter \ + -ldflags "-s -w -X 'github.com/0xJacky/Nginx-UI/settings.buildTime=$(date +%s)'" \ + -o /src/nginx-ui-linux-amd64/nginx-ui main.go + +ls -la nginx-ui-linux-amd64/nginx-ui diff --git a/cloudflare/bun.lock b/cloudflare/bun.lock new file mode 100644 index 00000000..507fed32 --- /dev/null +++ b/cloudflare/bun.lock @@ -0,0 +1,205 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "nginx-ui-demo-worker", + "dependencies": { + "@cloudflare/containers": "^0.3.7", + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260801.1", + "typescript": "^5.9.2", + "wrangler": "^4.118.0", + }, + }, + }, + "packages": { + "@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260730.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260730.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260730.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260730.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260730.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260801.1", "", {}, "sha512-XCv5xWi47WQOK0LpLa6997Mrpz8Ct+nZmp/M5Xp8Z4BFsarf7nYjkznGOcOoYK5m1GfbMFEEuQ2OIZnbIWoe9A=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.18", "", {}, "sha512-Q5USMGPOLp/dpVE0EA11QGuFyLAccJDKvsrmfqY/ZD540x/3kKlwtvuUxMqzNrOsGE/H4VtXe75EWma0dj/0jA=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@5.20260730.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], + + "wrangler": ["wrangler@4.118.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260730.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260730.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260730.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/cloudflare/package.json b/cloudflare/package.json new file mode 100644 index 00000000..b36634f4 --- /dev/null +++ b/cloudflare/package.json @@ -0,0 +1,20 @@ +{ + "name": "nginx-ui-demo-worker", + "private": true, + "type": "module", + "scripts": { + "build:binary": "sh ./build-binary.sh", + "dev": "sh ./build-binary.sh && wrangler dev -c wrangler.dev.jsonc", + "deploy": "sh ./build-binary.sh && wrangler deploy", + "typecheck": "tsc --noEmit", + "types": "wrangler types" + }, + "dependencies": { + "@cloudflare/containers": "^0.3.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260801.1", + "typescript": "^5.9.2", + "wrangler": "^4.118.0" + } +} diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts new file mode 100644 index 00000000..70db8f16 --- /dev/null +++ b/cloudflare/src/index.ts @@ -0,0 +1,252 @@ +import { Container, getContainer } from '@cloudflare/containers' +import { loadingPage } from './loading' + +interface Env { + NGINX_UI_DEMO: DurableObjectNamespace + /** + * Shared secret for the manual recycle endpoint. Unset means the endpoint is + * disabled, which is the right default: no secret, no lever. + * bunx wrangler secret put DEMO_ADMIN_TOKEN + */ + DEMO_ADMIN_TOKEN?: string +} + +/** + * The demo runs as a single shared instance rather than one per visitor. + * + * Everyone must see the same nginx configuration: a visitor who edits a site + * and reloads has to land back on the container that holds the edit. getRandom + * would scatter them across instances and make the demo look broken. + */ +// Durable Object state persists across container-application deletes, and a DO +// that still references a deleted application never binds a new instance. Bump +// this to hand the Worker a clean Durable Object when that happens. +const INSTANCE = 'demo-v2' + +/** Worker-owned path, never forwarded to the container. */ +const STATUS_PATH = '/__demo/status' + +/** + * Container-served path that must reach nginx even while the container is + * still reported as unhealthy — it is how a failed boot gets diagnosed, so + * gating it behind readiness would defeat it. + */ +const BOOT_LOG_PATH = '/__demo/bootlog' + +/** Worker-owned, secret-gated: stop the container so the next request starts it fresh. */ +const RECYCLE_PATH = '/__demo/recycle' + +export class NginxUiDemo extends Container { + // nginx inside the container listens here; it proxies to nginx-ui on 9000. + defaultPort = 8080 + + // Long enough that a visitor reading the docs mid-session does not get + // dropped, short enough that an idle demo is not billed all night. + sleepAfter = '20m' + + // The SPA's own health endpoint (router/routers.go). The default "ping" path + // would 404 and delay readiness. + pingEndpoint = 'localhost/healthz' + + // Note: the WebSocket origin allowlist lives in resources/demo/app.ini, not + // in envVars here. envVars are applied at container start, so they do not + // reach an already-running instance on deploy — and a value baked into the + // image ships and rolls out with it. Serving this Worker on a hostname other + // than the one in app.ini means adding that origin there too, or every + // WebSocket upgrade will be rejected. + + /** + * Boot in progress, if any. Held on the instance so concurrent requests + * during a cold start share one startup rather than racing several. + */ + private booting?: Promise + + /** + * Report whether the container can serve, kicking off a start if not. + * + * Deliberately does not await the boot: the caller returns a loading page + * immediately instead of holding the request open for several seconds, which + * is what produces a white screen. + */ + /** Current container state, for the status endpoint and for debugging. */ + async status(): Promise<{ ready: boolean, status: string, exitCode?: number }> { + const state = await this.getState() + return { + ready: state.status === 'healthy', + status: state.status, + // exitCode is only present on the stopped-with-code variant. + ...('exitCode' in state ? { exitCode: state.exitCode as number } : {}), + } + } + + async ready(): Promise { + const state = await this.getState() + if (state.status === 'healthy') { + return true + } + + console.log(`container not ready yet: status=${state.status}`) + + this.booting ??= this.startAndWaitForPorts() + .catch((err: unknown) => { + console.error('demo container failed to start', err) + }) + .finally(() => { + this.booting = undefined + }) + + return false + } + + /** + * Return the demo to its pristine state. + * + * Container disk is ephemeral, so stopping IS the restore: the next start + * comes up from the image with the seeded database and configs back in + * place. SIGTERM rather than a kill, because s6-overlay shuts nginx and + * nginx-ui down in order and nginx-ui holds an open SQLite handle. + */ + /** + * Stop the container so the next request starts it fresh. + * + * Needed after changing envVars: they are applied when the container starts, + * so a deploy alone leaves the running instance on the old environment. + */ + async recycle(): Promise { + const state = await this.getState() + if (state.status === 'stopped' || state.status === 'stopped_with_code') { + return 'already-stopped' + } + await this.stop() + return 'stopped' + } + + async restore(): Promise<'stopped' | 'already-stopped'> { + const state = await this.getState() + if (state.status === 'stopped' || state.status === 'stopped_with_code') { + // Most days the idle timeout will already have done this. + return 'already-stopped' + } + + await this.stop() + return 'stopped' + } + + override onError(error: unknown): Response { + console.error('demo container error', error) + return new Response('The demo container failed to start.', { + status: 502, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }) + } +} + +/** A navigation request is one where showing a loading page makes sense. */ +function wantsDocument(request: Request): boolean { + if (request.method !== 'GET') { + return false + } + const accept = request.headers.get('accept') ?? '' + return accept.includes('text/html') +} + +function isWebSocketUpgrade(request: Request): boolean { + return (request.headers.get('upgrade') ?? '').toLowerCase() === 'websocket' +} + +/** + * Stamp the public scheme and host onto a request before it reaches the + * container. + * + * The Worker-to-container hop is plain HTTP, so without this nginx derives + * `http://` while the browser sent `Origin: https://`. Nginx UI's + * WebSocket origin check compares the two and rejects every upgrade — the + * terminal, the log stream and the cluster monitor all fail with nothing in the + * logs to explain it. + */ +function withForwardedHeaders(request: Request, url: URL): Request { + // A WebSocket upgrade is passed through untouched. Reconstructing the + // request drops the upgrade in the Workers runtime, and the handshake then + // fails with no diagnostic anywhere. The origin problem those headers would + // have solved is handled by NGINX_UI_HTTP_WEBSOCKET_TRUSTED_ORIGINS instead. + if (isWebSocketUpgrade(request)) { + return request + } + + const headers = new Headers(request.headers) + headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')) + headers.set('X-Forwarded-Host', url.host) + return new Request(request, { headers }) +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + const container = getContainer(env.NGINX_UI_DEMO, INSTANCE) + + // Answered by the Worker so the loading page has something to poll that + // does not depend on the container being up. + if (url.pathname === STATUS_PATH) { + const status = await container.status() + if (!status.ready) { + // Kick off a boot so polling the status page actually makes progress. + await container.ready() + } + return Response.json(status, { + headers: { 'cache-control': 'no-store' }, + }) + } + + // Force a fresh container, for when a config change only takes effect at + // container start (envVars) rather than at deploy. + if (url.pathname === RECYCLE_PATH) { + const supplied = request.headers.get('x-demo-admin-token') + if (!env.DEMO_ADMIN_TOKEN || supplied !== env.DEMO_ADMIN_TOKEN) { + return new Response('Not found', { status: 404 }) + } + return Response.json({ outcome: await container.recycle() }) + } + + if (url.pathname === BOOT_LOG_PATH) { + // Deliberately unguarded: nginx answers this one even when nginx-ui is + // down, which is precisely when it is worth reading. + await container.ready() + return container.fetch(withForwardedHeaders(request, url)) + } + + if (await container.ready()) { + // fetch(), not containerFetch(): only fetch() carries WebSocket upgrades, + // which the terminal, log stream and cluster monitor all rely on. + return container.fetch(withForwardedHeaders(request, url)) + } + + if (wantsDocument(request) && !isWebSocketUpgrade(request)) { + // 200 at the originally requested URL, never a redirect: the visitor's + // deep link survives, and there is structurally no redirect loop. + return new Response(loadingPage(), { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }, + }) + } + + // XHR, assets and WebSocket upgrades get a normal retry signal instead of + // an HTML page they cannot parse. + return new Response('The demo is starting up.', { + status: 503, + headers: { + 'retry-after': '3', + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }, + }) + }, + + async scheduled(_controller: ScheduledController, env: Env): Promise { + const container = getContainer(env.NGINX_UI_DEMO, INSTANCE) + const outcome = await container.restore() + console.log(`scheduled demo restore: ${outcome}`) + }, +} diff --git a/cloudflare/src/loading.ts b/cloudflare/src/loading.ts new file mode 100644 index 00000000..a61fed25 --- /dev/null +++ b/cloudflare/src/loading.ts @@ -0,0 +1,106 @@ +/** + * The page shown while the demo container is booting. + * + * Must be completely self-contained. During a cold start nothing under + * /assets/* is reachable, because the SPA is served from inside the container + * that has not started yet — so a single external stylesheet, font or image + * reference would leave the loading page itself broken. + */ + +const SPINNER = `` + +export function loadingPage(): string { + return ` + + + + + +Starting Nginx UI demo… + + + +
+ ${SPINNER} +

Waking the demo up

+

This instance sleeps when nobody is using it. First request takes a few seconds.

+
+

Still starting. This can take up to a minute after a new deploy.

+
+ + +` +} diff --git a/cloudflare/tsconfig.json b/cloudflare/tsconfig.json new file mode 100644 index 00000000..98b17e20 --- /dev/null +++ b/cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/cloudflare/wrangler.dev.jsonc b/cloudflare/wrangler.dev.jsonc new file mode 100644 index 00000000..5fe03442 --- /dev/null +++ b/cloudflare/wrangler.dev.jsonc @@ -0,0 +1,49 @@ +{ + // Local-only override: builds the container from this working tree instead + // of pulling the published image, so `wrangler dev` exercises unreleased + // changes. Not used by `wrangler deploy`. + // + // demo.Dockerfile expects a prebuilt linux/amd64 binary at + // nginx-ui-linux-amd64/nginx-ui in the build context; produce it with: + // + // docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src \ + // -e GOWORK=off -e CGO_ENABLED=1 -e GOOS=linux -e GOARCH=amd64 \ + // golang:1.26-trixie go build -o /src/nginx-ui-linux-amd64/nginx-ui main.go + // + // bunx wrangler dev -c wrangler.dev.jsonc + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "nginx-ui-demo-dev", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "compatibility_flags": ["nodejs_compat"], + + "containers": [ + { + "class_name": "NginxUiDemo", + "image": "../demo.Dockerfile", + "image_build_context": "..", + "image_vars": { + "TARGETOS": "linux", + "TARGETARCH": "amd64" + }, + "instance_type": "basic", + "max_instances": 1 + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "NGINX_UI_DEMO", + "class_name": "NginxUiDemo" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NginxUiDemo"] + } + ] +} diff --git a/cloudflare/wrangler.jsonc b/cloudflare/wrangler.jsonc new file mode 100644 index 00000000..489e128f --- /dev/null +++ b/cloudflare/wrangler.jsonc @@ -0,0 +1,72 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "nginx-ui-demo", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "compatibility_flags": ["nodejs_compat"], + + "observability": { + "enabled": true + }, + + // Keep the workers.dev URL alive alongside the custom domain: it is the only + // way to reach the demo when the custom domain's DNS is being changed. + "workers_dev": true, + + // demo.nginxui.com must already exist as a zone on this account. Wrangler + // takes over the DNS record when custom_domain is true. + "routes": [ + { + "pattern": "demo.nginxui.com", + "custom_domain": true + } + ], + + "containers": [ + { + "class_name": "NginxUiDemo", + // Built and pushed to Cloudflare's own registry by `wrangler deploy`, + // rather than pulled from Docker Hub. That keeps the deployed image and + // this working tree in lockstep, and avoids Docker Hub pull limits. + // + // demo.Dockerfile expects a prebuilt linux/amd64 binary at + // nginx-ui-linux-amd64/nginx-ui; `bun run deploy` produces it first. + "image": "../demo.Dockerfile", + "image_build_context": "..", + "image_vars": { + "TARGETOS": "linux", + "TARGETARCH": "amd64" + }, + // Measured: the container settles around 550 MiB with three nginx-ui + // instances plus nginx, so 1 GiB is the smallest tier that fits. + "instance_type": "basic", + // Not 1. The platform keeps a provisioned instance of its own, and with a + // cap of 1 that instance consumes the whole quota — the Durable Object's + // instance then sits 'inactive' forever and the container never starts. + // Routing is a singleton by instance ID, so only one is ever claimed. + "max_instances": 3 + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "NGINX_UI_DEMO", + "class_name": "NginxUiDemo" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NginxUiDemo"] + } + ], + + // Daily restore. The container is ephemeral, so stopping it is the restore. + // 19:00 UTC is roughly 03:00 in CST, the quietest window for this audience. + "triggers": { + "crons": ["0 19 * * *"] + } +} diff --git a/demo.Dockerfile b/demo.Dockerfile index 8311e2fb..9075f164 100644 --- a/demo.Dockerfile +++ b/demo.Dockerfile @@ -1,40 +1,36 @@ -# CGO_ENABLED=1 GOOS=linux CC=x86_64-unknown-linux-gnu-gcc CXX=x86_64-unknown-linux-gnu-g++ GOARCH=amd64 go build -ldflags "-X 'github.com/0xJacky/Nginx-UI/settings.buildTime=$(date +%s)'" -o nginx-ui -v main.go +# Demo image for the public instance on Cloudflare Containers. +# +# Deliberately does NOT use s6-overlay, unlike the production Dockerfile. +# s6's preinit chowns /run and its suexec calls setgid; Cloudflare Containers +# grant neither CAP_CHOWN nor CAP_SETGID, so s6 exits 111 before anything +# starts. resources/demo/entrypoint.sh starts the same processes directly. +# +# Build the binary first (see cloudflare/build-binary.sh): +# GOWORK=off CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath \ +# -tags=jsoniter -ldflags "-s -w" -o nginx-ui-linux-amd64/nginx-ui main.go ARG NGINX_VERSION=latest FROM nginx:${NGINX_VERSION} ARG TARGETOS ARG TARGETARCH ARG TARGETVARIANT -ARG S6_OVERLAY_VERSION=3.2.1.0 WORKDIR /app -EXPOSE 80 +# 8080 rather than 80: this image runs unprivileged (see USER below) and cannot +# bind a port under 1024. +EXPOSE 8080 ENV DEBIAN_FRONTEND=noninteractive -ENV NGINX_UI_WORKING_DIR=/var/run/ +ENV NGINX_UI_WORKING_DIR=/var/run/nginx-ui +# Deliberately NOT setting NGINX_UI_OFFICIAL_DOCKER. It would enable a Docker +# socket self-check that can only fail here (no socket is mounted), run OTA +# container cleanup that logs errors on every boot, and default RestartCmd to +# `nginx -s stop` — which assumes an s6 supervisor that this image no longer +# has. resources/demo/app.ini sets RestartCmd explicitly instead, and +# entrypoint.sh does the supervising. RUN apt-get update -y \ - && apt-get install -y --no-install-recommends wget xz-utils logrotate nginx-module-geoip \ + && apt-get install -y --no-install-recommends logrotate \ && rm -rf /var/lib/apt/lists/* -RUN case "${TARGETARCH}/${TARGETVARIANT}" in \ - "amd64/"*) S6_ARCH="x86_64" ;; \ - "arm64/"*) S6_ARCH="aarch64" ;; \ - "arm/v7"*) S6_ARCH="arm" ;; \ - "arm/v6"*) S6_ARCH="arm" ;; \ - "arm/v5"*) S6_ARCH="arm" ;; \ - "riscv64/"*) S6_ARCH="riscv64" ;; \ - *) echo "Unsupported arch: ${TARGETARCH}/${TARGETVARIANT}" && exit 1 ;; \ - esac && \ - wget -O /tmp/s6-overlay-noarch.tar.xz https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz && \ - tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \ - wget -O /tmp/s6-overlay-${S6_ARCH}.tar.xz https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz && \ - tar -C / -Jxpf /tmp/s6-overlay-${S6_ARCH}.tar.xz && \ - rm -f /tmp/s6-overlay-noarch.tar.xz /tmp/s6-overlay-${S6_ARCH}.tar.xz - -# register nginx service -COPY resources/docker/nginx.run /etc/s6-overlay/s6-rc.d/nginx/run -RUN echo 'longrun' > /etc/s6-overlay/s6-rc.d/nginx/type && \ - touch /etc/s6-overlay/s6-rc.d/user/contents.d/nginx - RUN mkdir -p /usr/local/etc \ && mkdir /etc/nginx/sites-available \ && mkdir /etc/nginx/sites-enabled \ @@ -42,16 +38,6 @@ RUN mkdir -p /usr/local/etc \ && mkdir /etc/nginx/streams-enabled \ && cp -r /etc/nginx /usr/local/etc/nginx -# init config -COPY resources/docker/init-config.up /etc/s6-overlay/s6-rc.d/init-config/up -COPY resources/docker/init-config.sh /etc/s6-overlay/s6-rc.d/init-config/init-config.sh - -RUN chmod +x /etc/s6-overlay/s6-rc.d/init-config/init-config.sh && \ - echo 'oneshot' > /etc/s6-overlay/s6-rc.d/init-config/type && \ - touch /etc/s6-overlay/s6-rc.d/user/contents.d/init-config && \ - mkdir -p /etc/s6-overlay/s6-rc.d/nginx/dependencies.d && \ - touch /etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-config - # copy demo config COPY resources/demo/ojbk.me /etc/nginx/sites-available/ojbk.me COPY ["resources/demo/Prime Sponsor", "/etc/nginx/sites-available/Prime Sponsor"] @@ -61,15 +47,15 @@ RUN ln -s "/etc/nginx/sites-available/Prime Sponsor" \ COPY resources/demo/app.ini /etc/nginx-ui/app.ini COPY resources/demo/demo.db /etc/nginx-ui/database.db -# register nginx-ui service -COPY resources/docker/nginx-ui.run /etc/s6-overlay/s6-rc.d/nginx-ui/run -RUN echo 'longrun' > /etc/s6-overlay/s6-rc.d/nginx-ui/type && \ - touch /etc/s6-overlay/s6-rc.d/user/contents.d/nginx-ui - # copy nginx config -COPY resources/docker/nginx.conf /etc/nginx/nginx.conf -COPY resources/docker/nginx-ui.conf /etc/nginx/conf.d/nginx-ui.conf +# The demo uses its own nginx.conf / nginx-ui.conf rather than the ones under +# resources/docker: no `user` directive, temp paths under /tmp, and port 8080, +# all so the container can run unprivileged. resources/docker/* stays as-is for +# the production image. +COPY resources/demo/nginx.conf /etc/nginx/nginx.conf +COPY resources/demo/nginx-ui.conf /etc/nginx/conf.d/nginx-ui.conf COPY resources/demo/stub_status_nginx-ui.conf /etc/nginx/conf.d/stub_status_nginx-ui.conf +COPY resources/docker/nginx-ui.conf.known-hashes /usr/local/share/nginx-ui/nginx-ui.conf.known-hashes # copy nginx-ui executable binary COPY nginx-ui-$TARGETOS-$TARGETARCH$TARGETVARIANT/nginx-ui /usr/local/bin/nginx-ui @@ -82,4 +68,30 @@ RUN rm -f /var/log/nginx/access.log && \ rm -f /var/log/nginx/error.log && \ touch /var/log/nginx/error.log -ENTRYPOINT ["/init"] +# extra nginx-ui instances so the cluster view has real peers to talk to, +# reachable over loopback instead of a second container +COPY resources/demo/setup-cluster-nodes.sh /usr/local/bin/setup-cluster-nodes.sh +RUN chmod +x /usr/local/bin/setup-cluster-nodes.sh && \ + /usr/local/bin/setup-cluster-nodes.sh + +COPY resources/demo/entrypoint.sh /usr/local/bin/demo-entrypoint.sh +RUN chmod +x /usr/local/bin/demo-entrypoint.sh + +# Run unprivileged. Cloudflare Containers grant no privileged capabilities, and +# nothing here needs them once the writable paths are owned by the runtime user. +# Everything stays owned by root, and there is no USER directive. Both are +# forced by how Cloudflare Containers run this image: +# +# 1. With `USER nginx` the instance never got scheduled at all — it sat +# 'inactive' indefinitely, while an otherwise identical root image started +# normally. +# 2. Running as root does NOT mean unrestricted: every capability is dropped, +# so without CAP_DAC_OVERRIDE root obeys ordinary file permissions. +# Chowning these paths to `nginx` locked root out of its own filesystem and +# broke nginx's error log and nginx-ui's handover socket. +# +# So: root-owned, root-run, and no capabilities. The isolation comes from the +# capability set and the per-container VM, not from the uid. +RUN mkdir -p /var/cache/nginx /var/lib/nginx /var/run/nginx-ui /etc/nginx-ui + +ENTRYPOINT ["/usr/local/bin/demo-entrypoint.sh"] diff --git a/resources/demo/app.ini b/resources/demo/app.ini index 3bf605ef..cdc53863 100644 --- a/resources/demo/app.ini +++ b/resources/demo/app.ini @@ -40,6 +40,12 @@ Secret = 4a82fe17c5aab629007462b303c367d2863b7db28b95c8f869f2f815672d41d8 [http] GithubProxy = InsecureSkipVerify = false +# The Worker-to-container hop is plain HTTP, so nginx-ui derives http:// +# while the browser sends Origin: https://, and every WebSocket upgrade +# is rejected. Naming the public origin is the supported fix. +# Deploying this image under a different hostname means adding it here, or +# setting NGINX_UI_HTTP_WEBSOCKET_TRUSTED_ORIGINS before the container starts. +WebSocketTrustedOrigins = https://demo.nginxui.com [logrotate] Enabled = false @@ -51,20 +57,30 @@ AccessLogPath = ErrorLogPath = LogDirWhiteList = /var/log/nginx ConfigDir = -PIDPath = +# Must match `pid` in resources/demo/nginx.conf. The demo runs unprivileged and +# cannot write the compiled-in default under /var/run, and without this override +# nginx.IsRunning() reports false and the whole dashboard shows nginx stopped. +PIDPath = /tmp/nginx.pid TestConfigCmd = ReloadCmd = -RestartCmd = +# entrypoint.sh restarts nginx when it exits, so stopping it is a restart. +RestartCmd = nginx -s stop [nginx_log] -AdvancedIndexingEnabled = true +IndexingEnabled = true [node] -Name = +Name = demo Secret = 57D079F2-CA8B-412A-B5C0-FDA291C13391 +InstanceID = 0e6c6b6a-3f1e-4a9f-8f8a-2f9c2b6d41d0 SkipInstallation = false Demo = true +[site_check] +Enabled = true +Concurrency = 5 +IntervalSeconds = 900 + [openai] Provider = openai BaseUrl = diff --git a/resources/demo/demo.db b/resources/demo/demo.db index 9191eff0..265a6fc4 100644 Binary files a/resources/demo/demo.db and b/resources/demo/demo.db differ diff --git a/resources/demo/entrypoint.sh b/resources/demo/entrypoint.sh new file mode 100644 index 00000000..ad1c1a8e --- /dev/null +++ b/resources/demo/entrypoint.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# Demo container entrypoint. +# +# The production image uses s6-overlay, which cannot run here: s6's preinit +# chowns /run and its suexec calls setgid, and Cloudflare Containers grant +# neither CAP_CHOWN nor CAP_SETGID, so s6 exits 111 before anything starts. +# +# Note also that "runs as root" does not mean unrestricted. Every capability is +# dropped, so without CAP_DAC_OVERRIDE root obeys ordinary file permissions and +# nothing in the image may be owned by another user. +set -eu + +: "${PEER_COUNT:=2}" +BOOT_LOG=/var/log/nginx/demo-boot.log + +log() { echo "[entrypoint] $*" | tee -a "$BOOT_LOG"; } + +mkdir -p /var/log/nginx +: > "$BOOT_LOG" + +# Seed /etc/nginx on first boot, mirroring resources/docker/init-config.sh but +# without the s6 oneshot wrapper. +if [ -z "$(ls -A /etc/nginx 2>/dev/null)" ]; then + cp -rp /usr/local/etc/nginx/* /etc/nginx/ + log "initialized /etc/nginx from the bundled template" +fi + +# nginx creates its temp dirs lazily; make them now so a request in the first +# second does not race directory creation. +mkdir -p /tmp/nginx-client-body /tmp/nginx-proxy /tmp/nginx-fastcgi \ + /tmp/nginx-uwsgi /tmp/nginx-scgi + +# The working directories must be created at RUNTIME, not in the image. +# /run is a fresh tmpfs on Cloudflare Containers, so anything mkdir'd there at +# build time is gone by the time this script runs, and nginx-ui fails with +# "bind: no such file or directory" on its risefront handover socket. +mkdir -p /var/run/nginx-ui +n=2 +while [ "$n" -le $((PEER_COUNT + 1)) ]; do + mkdir -p "/var/run/nginx-ui-node${n}" + n=$((n + 1)) +done + +# nginx comes up first and stays up for the container's whole life. Keeping it +# alive even when nginx-ui is down is deliberate: the platform health check and +# the boot log below stay reachable, so a failure is diagnosable instead of +# looking like a container that simply never started. +nginx -g "daemon off;" >> "$BOOT_LOG" 2>&1 & +NGINX_PID=$! +log "started nginx (pid $NGINX_PID)" + +start_ui() { + name="$1" + workdir="$2" + config="$3" + NGINX_UI_WORKING_DIR="$workdir" nginx-ui --config "$config" >> "$BOOT_LOG" 2>&1 & + log "started $name (pid $!)" +} + +# Peer nodes for the cluster view. Each needs its own working directory: +# risefront derives its handover socket path from it and would otherwise treat +# a peer as a hot-reload child of the primary. +n=2 +while [ "$n" -le $((PEER_COUNT + 1)) ]; do + conf="/etc/nginx-ui-node${n}/app.ini" + if [ -f "$conf" ]; then + start_ui "nginx-ui-node${n}" "/var/run/nginx-ui-node${n}" "$conf" + fi + n=$((n + 1)) +done + +start_ui nginx-ui "${NGINX_UI_WORKING_DIR:-/var/run/nginx-ui}" /etc/nginx-ui/app.ini + +shutdown() { + log "received termination signal" + kill -TERM "$NGINX_PID" 2>/dev/null || true + # nginx-ui holds an open SQLite handle; give every child a chance to close. + pkill -TERM nginx-ui 2>/dev/null || true + wait + exit 0 +} +trap shutdown TERM INT + +# Supervise nginx. The UI's "restart nginx" runs `nginx -s stop` (see +# RestartCmd in resources/demo/app.ini), so nginx exiting is a normal event +# that must bring it straight back rather than end the container. +# +# A crashed nginx-ui deliberately does NOT take the container down: it keeps +# serving 502 plus the boot log at /__demo/bootlog, and an opaque restart loop +# is far harder to debug than a 502. +restarts=0 +while true; do + if kill -0 "$NGINX_PID" 2>/dev/null; then + sleep 5 + continue + fi + + restarts=$((restarts + 1)) + if [ "$restarts" -gt 20 ]; then + log "nginx exited $restarts times; giving up so the platform recycles the container" + exit 1 + fi + + log "nginx exited; restarting it (attempt $restarts)" + sleep 1 + nginx -g "daemon off;" >> "$BOOT_LOG" 2>&1 & + NGINX_PID=$! +done diff --git a/resources/demo/nginx-ui.conf b/resources/demo/nginx-ui.conf new file mode 100644 index 00000000..12acc31a --- /dev/null +++ b/resources/demo/nginx-ui.conf @@ -0,0 +1,44 @@ +# Demo-only front proxy. Same as resources/docker/nginx-ui.conf except it +# listens on 8080 instead of 80, because the demo container runs unprivileged +# and cannot bind a port below 1024. The Cloudflare Container binding targets +# 8080 directly, so nothing external depends on the port number. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + +map $http_x_forwarded_host $forwarded_host { + default $http_x_forwarded_host; + '' $http_host; +} + +server { + listen 8080; + server_name localhost; + client_max_body_size 128M; + + # Boot log, so a container whose nginx-ui failed to start can still be + # diagnosed from outside. Serving it needs no working backend, which is the + # entire point: this is reachable exactly when nothing else is. + location = /__demo/bootlog { + default_type text/plain; + alias /var/log/nginx/demo-boot.log; + } + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $forwarded_host; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_pass http://127.0.0.1:9000/; + } +} diff --git a/resources/demo/nginx.conf b/resources/demo/nginx.conf new file mode 100644 index 00000000..32047a45 --- /dev/null +++ b/resources/demo/nginx.conf @@ -0,0 +1,51 @@ +# Demo-only nginx.conf, shaped by how Cloudflare Containers actually run: +# as root, but with every capability dropped. +# +# - `user root` because without CAP_CHOWN nginx cannot hand its temp +# directories to a different worker user, and it refuses to start when +# that chown fails +# - pid and temp paths under /tmp rather than /var/run +# - port 8080 in conf.d/nginx-ui.conf, since CAP_NET_BIND_SERVICE is gone too +# +# Note that dropping all capabilities means root is NOT all-powerful here: with +# no CAP_DAC_OVERRIDE it is subject to ordinary file permissions, so nothing in +# the image may be owned by another user. +user root; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +stream { + include /etc/nginx/streams-enabled/*; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + + keepalive_timeout 65; + + gzip on; + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} diff --git a/resources/demo/setup-cluster-nodes.sh b/resources/demo/setup-cluster-nodes.sh new file mode 100644 index 00000000..07094ee7 --- /dev/null +++ b/resources/demo/setup-cluster-nodes.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Build the peer nodes for the demo cluster view. +# +# Instead of running a second container, the demo runs extra nginx-ui processes +# inside the same container and points the cluster rows at 127.0.0.1. That keeps +# node-to-node traffic on loopback, so it never crosses the Cloudflare Worker +# boundary, never counts as an in-flight request (which would stop the container +# from ever sleeping), and needs no outbound interception. +# +# Two things must differ per instance: +# 1. the config directory, because the SQLite file is resolved as +# dir(configPath)/.db +# 2. NGINX_UI_WORKING_DIR, because risefront derives its handover socket path +# from it (see prefix_dialer.go NewPrefixDialer). Sharing it makes the +# second process attach to the first as a hot-reload child and give up its +# own TCP port. +set -eu + +: "${PEER_COUNT:=2}" +: "${BASE_CONFIG:=/etc/nginx-ui/app.ini}" +: "${BASE_DB:=/etc/nginx-ui/database.db}" + +# Fixed instance IDs so the seeded node rows can reference them and so nothing +# rewrites app.ini on first boot. +peer_instance_id() { + case "$1" in + 2) echo "1b7f4a52-9c3d-4e18-9a71-6d0f2c845b93" ;; + 3) echo "2c8e5b63-ad4e-4f29-8b62-7e1a3d956c04" ;; + *) echo "00000000-0000-4000-8000-00000000000$1" ;; + esac +} + +n=2 +while [ "$n" -le $((PEER_COUNT + 1)) ]; do + # primary node holds 9000, so peer n takes 9000 + (n - 1) + port=$((8999 + n)) + conf_dir="/etc/nginx-ui-node${n}" + run_dir="/var/run/nginx-ui-node${n}" + + mkdir -p "$conf_dir" "$run_dir" + + # Peers exist to populate the cluster view. They share the single nginx in + # this container, so leave the site prober and the log indexer to the + # primary node rather than paying for them three times. + sed -e "s|^Port .*|Port = ${port}|" \ + -e "s|^Name .*|Name = demo-node-${n}|" \ + -e "s|^InstanceID .*|InstanceID = $(peer_instance_id "$n")|" \ + -e "s|^IndexingEnabled .*|IndexingEnabled = false|" \ + "$BASE_CONFIG" > "${conf_dir}/app.ini" + sed -i "s|^Enabled = true|Enabled = false|" "${conf_dir}/app.ini" + + cp "$BASE_DB" "${conf_dir}/database.db" + + # resources/demo/entrypoint.sh discovers peers by looking for these config + # files, so there is nothing else to register. + echo "prepared demo peer node ${n} on port ${port} (${conf_dir})" + n=$((n + 1)) +done