From f6992d8789b594d121417f0c310feee1ef14bb1c Mon Sep 17 00:00:00 2001 From: Hintay Date: Sun, 24 May 2026 10:48:19 +0900 Subject: [PATCH] fix(docker): upgrade persisted bundled nginx-ui.conf safely (#1696) * test(self_check): add fixed-default bundled nginx-ui.conf fixture * test(self_check): add unfixed-default bundled nginx-ui.conf fixture * test(self_check): add customized and partial-fix fixtures * feat(self_check): add error codes for bundled nginx-ui.conf upgrade * feat(self_check): add CheckBundledNginxUIConf * feat(self_check): add idempotent applyBundledConfPatch * feat(self_check): add transactional patch-on-disk with backup restore * feat(self_check): wire FixBundledNginxUIConf with verify+reload * feat(self_check): register bundled nginx-ui.conf WS-fix task * test(docker): add init-config bats fixtures * feat(docker): hash-whitelist sync for bundled nginx-ui.conf * feat(docker): seed nginx-ui.conf known-hashes list * chore(docker): add maintainer script for nginx-ui.conf hash list * ci(docker): add bats + hash-consistency workflow for init-config.sh * feat(docker): ship nginx-ui.conf known-hashes inside the image * feat(self-check): banner button shows Fix when all failures are fixable * docs: add docker websocket fix guide (en) * docs: add docker websocket fix guide (zh_CN, zh_TW) * docs: link docker-websocket-fix page in all locale sidebars * docs(readme): link docker websocket fix guide * docs(readme): link docker websocket fix guide (translations) * fix(self_check): WS-fix check independent of NGINX_UI_IGNORE_DOCKER_SOCKET * fix(docker): respect bundled nginx host mode Keep bundled nginx-ui.conf self-checks aligned with Docker host mode and prevent config sync failures from blocking startup. * fix(docker): tighten bundled conf review fixes Co-authored-by: Jacky --------- Co-authored-by: Cursor Agent Co-authored-by: Jacky --- .github/workflows/docker-init-config-test.yml | 62 +++++ Dockerfile | 1 + README.md | 1 + .../SelfCheck/SelfCheckHeaderBanner.vue | 15 +- app/src/constants/errors/self_check.ts | 5 + docs/.vitepress/config/en.ts | 1 + docs/.vitepress/config/zh_CN.ts | 1 + docs/.vitepress/config/zh_TW.ts | 1 + docs/guide/docker-websocket-fix.md | 57 +++++ docs/zh_CN/guide/docker-websocket-fix.md | 56 +++++ docs/zh_TW/guide/docker-websocket-fix.md | 56 +++++ internal/helper/docker.go | 17 ++ internal/self_check/bundled_nginx_ui_conf.go | 192 ++++++++++++++++ .../self_check/bundled_nginx_ui_conf_test.go | 217 ++++++++++++++++++ internal/self_check/errors.go | 5 + internal/self_check/tasks.go | 14 ++ .../test_cases/bundled/customized-fixed.conf | 32 +++ .../bundled/customized-unfixed.conf | 22 ++ .../test_cases/bundled/fixed-default.conf | 37 +++ .../test_cases/bundled/maps-only-half.conf | 27 +++ .../test_cases/bundled/unfixed-default.conf | 22 ++ resources/docker/init-config.sh | 99 +++++++- resources/docker/nginx-ui.conf.known-hashes | 9 + .../scripts/update-nginx-ui-conf-hash.sh | 25 ++ .../docker/tests/fixtures/fixed-default.conf | 37 +++ .../tests/fixtures/unfixed-default.conf | 22 ++ resources/docker/tests/init-config.bats | 93 ++++++++ resources/readme/README-es.md | 1 + resources/readme/README-ja_JP.md | 1 + resources/readme/README-vi_VN.md | 1 + resources/readme/README-zh_CN.md | 1 + resources/readme/README-zh_TW.md | 2 + 32 files changed, 1125 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/docker-init-config-test.yml create mode 100644 docs/guide/docker-websocket-fix.md create mode 100644 docs/zh_CN/guide/docker-websocket-fix.md create mode 100644 docs/zh_TW/guide/docker-websocket-fix.md create mode 100644 internal/self_check/bundled_nginx_ui_conf.go create mode 100644 internal/self_check/bundled_nginx_ui_conf_test.go create mode 100644 internal/self_check/test_cases/bundled/customized-fixed.conf create mode 100644 internal/self_check/test_cases/bundled/customized-unfixed.conf create mode 100644 internal/self_check/test_cases/bundled/fixed-default.conf create mode 100644 internal/self_check/test_cases/bundled/maps-only-half.conf create mode 100644 internal/self_check/test_cases/bundled/unfixed-default.conf create mode 100644 resources/docker/nginx-ui.conf.known-hashes create mode 100755 resources/docker/scripts/update-nginx-ui-conf-hash.sh create mode 100644 resources/docker/tests/fixtures/fixed-default.conf create mode 100644 resources/docker/tests/fixtures/unfixed-default.conf create mode 100644 resources/docker/tests/init-config.bats diff --git a/.github/workflows/docker-init-config-test.yml b/.github/workflows/docker-init-config-test.yml new file mode 100644 index 00000000..bd56e61b --- /dev/null +++ b/.github/workflows/docker-init-config-test.yml @@ -0,0 +1,62 @@ +name: docker init-config tests + +on: + push: + paths: + - 'resources/docker/init-config.sh' + - 'resources/docker/nginx-ui.conf' + - 'resources/docker/nginx-ui.conf.known-hashes' + - 'resources/docker/scripts/**' + - 'resources/docker/tests/**' + - 'internal/helper/docker.go' + - 'internal/self_check/**' + - 'Dockerfile' + - 'demo.Dockerfile' + - '.github/workflows/docker-init-config-test.yml' + pull_request: + paths: + - 'resources/docker/init-config.sh' + - 'resources/docker/nginx-ui.conf' + - 'resources/docker/nginx-ui.conf.known-hashes' + - 'resources/docker/scripts/**' + - 'resources/docker/tests/**' + - 'internal/helper/docker.go' + - 'internal/self_check/**' + - 'Dockerfile' + - 'demo.Dockerfile' + - '.github/workflows/docker-init-config-test.yml' + +jobs: + hash-consistency: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Verify last known-hash equals current template + run: | + expected=$(sha256sum resources/docker/nginx-ui.conf | awk '{print $1}') + latest=$(grep -vE '^[[:space:]]*(#|$)' resources/docker/nginx-ui.conf.known-hashes \ + | awk '{print $1}' | tail -n1) + if [ "$expected" != "$latest" ]; then + echo "::error::nginx-ui.conf changed without updating known-hashes file." + echo "Run: bash resources/docker/scripts/update-nginx-ui-conf-hash.sh" + exit 1 + fi + + bats: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + - name: Run bats tests + run: bats resources/docker/tests/ + + go-self-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Run bundled config self-check tests + run: go test -tags=unembed ./internal/helper ./internal/self_check -count=1 diff --git a/Dockerfile b/Dockerfile index 00052a81..dfd71033 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,7 @@ RUN echo 'longrun' > /etc/s6-overlay/s6-rc.d/nginx-ui/type && \ # copy nginx config COPY resources/docker/nginx.conf /usr/local/etc/nginx/nginx.conf COPY resources/docker/nginx-ui.conf /usr/local/etc/nginx/conf.d/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 diff --git a/README.md b/README.md index 53340571..af4890c5 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ you can easily make the switch. ##### Note 1. When using this container for the first time, ensure that the volume mapped to /etc/nginx is empty. 2. If you want to host static files, you can map directories to container. +3. If you are upgrading from an older image, see the [Docker WebSocket fix guide](https://nginxui.com/guide/docker-websocket-fix.html) for required `conf.d/nginx-ui.conf` updates.
Deploy with Docker diff --git a/app/src/components/SelfCheck/SelfCheckHeaderBanner.vue b/app/src/components/SelfCheck/SelfCheckHeaderBanner.vue index a071ba79..7b727c3e 100644 --- a/app/src/components/SelfCheck/SelfCheckHeaderBanner.vue +++ b/app/src/components/SelfCheck/SelfCheckHeaderBanner.vue @@ -11,7 +11,7 @@ const props = defineProps<{ const router = useRouter() const selfCheckStore = useSelfCheckStore() -const { hasError, loading } = storeToRefs(selfCheckStore) +const { hasError, loading, data } = storeToRefs(selfCheckStore) const alertEl = useTemplateRef('alertEl') const { width: alertWidth } = useElementSize(alertEl) @@ -26,6 +26,15 @@ const iconRightPosition = computed(() => { return props.userWrapperWidth ? `${props.userWrapperWidth + 50}px` : '50px' }) +const allFailingAreFixable = computed(() => { + const failing = data.value?.filter(r => r.status === 'error') ?? [] + return failing.length > 0 && failing.every(r => r.fixable) +}) + +const actionLabel = computed(() => + allFailingAreFixable.value ? $gettext('Fix') : $gettext('Check'), +) + onMounted(() => { selfCheckStore.check() }) @@ -37,7 +46,7 @@ onMounted(() => { @@ -61,7 +70,7 @@ onMounted(() => {
- {{ $gettext('Check') }} + {{ actionLabel }}
diff --git a/app/src/constants/errors/self_check.ts b/app/src/constants/errors/self_check.ts index 528f6b87..9e73cc38 100644 --- a/app/src/constants/errors/self_check.ts +++ b/app/src/constants/errors/self_check.ts @@ -22,4 +22,9 @@ export default { 40418: () => $gettext('Error log path not exist'), 40419: () => $gettext('Conf.d directory not exists'), 40420: () => $gettext('GeoLite2 database not found at {0}. Log indexing requires GeoLite2 database for geographic IP analysis'), + 40421: () => $gettext('Bundled nginx-ui.conf is missing the WebSocket reverse-proxy fix'), + 50007: () => $gettext('Failed to read bundled nginx-ui.conf: {0}'), + 50008: () => $gettext('Patched nginx-ui.conf is invalid: {0}'), + 50009: () => $gettext('Nginx reload after fix failed: {0}'), + 50010: () => $gettext('Failed to restore nginx-ui.conf from backup: {0}'), } diff --git a/docs/.vitepress/config/en.ts b/docs/.vitepress/config/en.ts index f13cdfd4..4af0fe75 100644 --- a/docs/.vitepress/config/en.ts +++ b/docs/.vitepress/config/en.ts @@ -79,6 +79,7 @@ export const enConfig: LocaleSpecificConfig = { text: 'Appendix', collapsed: false, items: [ + { text: 'Docker WebSocket Fix', link: '/guide/docker-websocket-fix' }, { text: 'Nginx Proxy Example', link: '/guide/nginx-proxy-example' }, { text: 'Reset Password', link: '/guide/reset-password' }, { text: 'License', link: '/guide/license' } diff --git a/docs/.vitepress/config/zh_CN.ts b/docs/.vitepress/config/zh_CN.ts index e2a8eaca..af07c801 100644 --- a/docs/.vitepress/config/zh_CN.ts +++ b/docs/.vitepress/config/zh_CN.ts @@ -84,6 +84,7 @@ export const zhCNConfig: LocaleSpecificConfig = { text: '附录', collapsed: false, items: [ + { text: 'Docker WebSocket 修复', link: '/zh_CN/guide/docker-websocket-fix' }, { text: 'Nginx 代理示例', link: '/zh_CN/guide/nginx-proxy-example' }, { text: '重置密码', link: '/zh_CN/guide/reset-password' }, { text: '开源协议', link: '/zh_CN/guide/license' } diff --git a/docs/.vitepress/config/zh_TW.ts b/docs/.vitepress/config/zh_TW.ts index c31fda16..261bd323 100644 --- a/docs/.vitepress/config/zh_TW.ts +++ b/docs/.vitepress/config/zh_TW.ts @@ -84,6 +84,7 @@ export const zhTWConfig: LocaleSpecificConfig = { text: '附錄', collapsed: false, items: [ + { text: 'Docker WebSocket 修復', link: '/zh_TW/guide/docker-websocket-fix' }, { text: 'Nginx 代理示例', link: '/zh_TW/guide/nginx-proxy-example' }, { text: '重置密碼', link: '/zh_TW/guide/reset-password' }, { text: '開源協議', link: '/zh_TW/guide/license' } diff --git a/docs/guide/docker-websocket-fix.md b/docs/guide/docker-websocket-fix.md new file mode 100644 index 00000000..a6e56969 --- /dev/null +++ b/docs/guide/docker-websocket-fix.md @@ -0,0 +1,57 @@ +# WebSocket fix for persisted Docker installations + +::: tip Applies to +You persisted `/etc/nginx` as a Docker volume from a Nginx UI version older than +the one that introduced this fix, and Nginx UI is fronted by another reverse proxy +that terminates TLS (host nginx, Cloudflare, Traefik, ...). +::: + +## Symptoms + +WebSocket connections (terminal, log live tail, ...) fail with origin-mismatch errors. +This happens because the container's internal nginx was overwriting `X-Forwarded-Proto` +with its own `$scheme` (`http`), breaking the same-origin check on HTTPS deployments. + +## Automatic fix (recommended) + +1. Open **System → Self Check**. +2. Locate **Bundled nginx-ui.conf has WebSocket reverse-proxy fix**. +3. Click **Attempt to fix**. A timestamped `.bak` file is written next to the original. + +::: warning If the fix fails +The original file is restored from backup automatically. The error message includes +the backup path. See *Manual fix* below. +::: + +## Manual fix + +::: code-group + +```nginx [Additions at top of conf.d/nginx-ui.conf] +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; +} +``` + +```diff [Replace inside location /] +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_set_header X-Forwarded-Host $http_host; ++ proxy_set_header X-Forwarded-Proto $forwarded_proto; ++ proxy_set_header X-Forwarded-Host $forwarded_host; +``` + +::: + +After saving, run `docker exec nginx -s reload`. + +## Opt-out + +::: info +Set `NGINX_UI_PRESERVE_BUNDLED_CONF=true` on the container to disable the +startup-time auto-upgrade. The UI-driven fix remains available regardless. +::: diff --git a/docs/zh_CN/guide/docker-websocket-fix.md b/docs/zh_CN/guide/docker-websocket-fix.md new file mode 100644 index 00000000..1187ccca --- /dev/null +++ b/docs/zh_CN/guide/docker-websocket-fix.md @@ -0,0 +1,56 @@ +# 持久化 Docker 部署的 WebSocket 修复 + +::: tip 适用范围 +你以 Docker 卷的形式持久化了 `/etc/nginx`,且镜像版本早于引入本修复的版本; +同时 Nginx UI 前面还有另一层反向代理(host nginx、Cloudflare、Traefik 等)在 +终止 TLS。 +::: + +## 症状 + +WebSocket 连接(终端、日志实时跟踪等)报同源校验失败。原因是容器内部 nginx +将 `X-Forwarded-Proto` 覆盖为自己的 `$scheme`(`http`),导致 HTTPS 部署下 +同源校验失效。 + +## 自动修复(推荐) + +1. 打开 **系统 → 自检**。 +2. 找到 **Bundled nginx-ui.conf 已包含 WebSocket 反代修复**。 +3. 点击 **尝试修复**。原文件旁会生成带时间戳的 `.bak` 备份。 + +::: warning 修复失败时 +原文件会自动从备份恢复。错误信息中包含备份路径。请参考下文「手动修复」。 +::: + +## 手动修复 + +::: code-group + +```nginx [conf.d/nginx-ui.conf 顶部新增] +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; +} +``` + +```diff [location / 内部替换] +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_set_header X-Forwarded-Host $http_host; ++ proxy_set_header X-Forwarded-Proto $forwarded_proto; ++ proxy_set_header X-Forwarded-Host $forwarded_host; +``` + +::: + +保存后执行 `docker exec nginx -s reload`。 + +## 禁用自动升级 + +::: info +在容器上设置 `NGINX_UI_PRESERVE_BUNDLED_CONF=true` 可关闭启动期的自动升级; +UI 内的修复入口仍然可用。 +::: diff --git a/docs/zh_TW/guide/docker-websocket-fix.md b/docs/zh_TW/guide/docker-websocket-fix.md new file mode 100644 index 00000000..1a4b22d1 --- /dev/null +++ b/docs/zh_TW/guide/docker-websocket-fix.md @@ -0,0 +1,56 @@ +# 持久化 Docker 部署的 WebSocket 修復 + +::: tip 適用範圍 +你以 Docker 卷的形式持久化了 `/etc/nginx`,且鏡像版本早於引入本修復的版本; +同時 Nginx UI 前面還有另一層反向代理(host nginx、Cloudflare、Traefik 等)在 +終止 TLS。 +::: + +## 症狀 + +WebSocket 連線(終端、日誌即時追蹤等)報同源驗證失敗。原因是容器內部 nginx +將 `X-Forwarded-Proto` 覆寫為自己的 `$scheme`(`http`),導致 HTTPS 部署下 +同源驗證失效。 + +## 自動修復(推薦) + +1. 開啟 **系統 → 自檢**。 +2. 找到 **Bundled nginx-ui.conf 已包含 WebSocket 反代修復**。 +3. 點擊 **嘗試修復**。原檔案旁會產生帶時間戳的 `.bak` 備份。 + +::: warning 修復失敗時 +原檔案會自動從備份還原。錯誤訊息中包含備份路徑。請參考下文「手動修復」。 +::: + +## 手動修復 + +::: code-group + +```nginx [conf.d/nginx-ui.conf 頂部新增] +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; +} +``` + +```diff [location / 內部替換] +- proxy_set_header X-Forwarded-Proto $scheme; +- proxy_set_header X-Forwarded-Host $http_host; ++ proxy_set_header X-Forwarded-Proto $forwarded_proto; ++ proxy_set_header X-Forwarded-Host $forwarded_host; +``` + +::: + +儲存後執行 `docker exec nginx -s reload`。 + +## 停用自動升級 + +::: info +在容器上設定 `NGINX_UI_PRESERVE_BUNDLED_CONF=true` 即可關閉啟動期自動升級; +UI 內的修復入口仍可使用。 +::: diff --git a/internal/helper/docker.go b/internal/helper/docker.go index 068b252f..dfd7c907 100644 --- a/internal/helper/docker.go +++ b/internal/helper/docker.go @@ -6,6 +6,23 @@ import ( "github.com/spf13/cast" ) +// IsOfficialDockerImage returns true when running inside the official Nginx UI +// docker image. Unlike InNginxUIOfficialDocker, this does NOT honour +// NGINX_UI_IGNORE_DOCKER_SOCKET — that opt-out is specific to features that +// require the docker socket (OTA upgrade), and should not suppress checks +// that simply happen to be docker-only (e.g. bundled config sync). +func IsOfficialDockerImage() bool { + return cast.ToBool(os.Getenv("NGINX_UI_OFFICIAL_DOCKER")) +} + +// ShouldManageBundledNginx returns true when the official Docker image owns +// the bundled Nginx configuration. Host mode disables that ownership even when +// the process is still running inside the official image. +func ShouldManageBundledNginx() bool { + return IsOfficialDockerImage() && + !cast.ToBool(os.Getenv("NGINX_UI_DISABLE_BUNDLED_NGINX")) +} + func InNginxUIOfficialDocker() bool { return cast.ToBool(os.Getenv("NGINX_UI_OFFICIAL_DOCKER")) && !cast.ToBool(os.Getenv("NGINX_UI_IGNORE_DOCKER_SOCKET")) diff --git a/internal/self_check/bundled_nginx_ui_conf.go b/internal/self_check/bundled_nginx_ui_conf.go new file mode 100644 index 00000000..4f3868d1 --- /dev/null +++ b/internal/self_check/bundled_nginx_ui_conf.go @@ -0,0 +1,192 @@ +package self_check + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/0xJacky/Nginx-UI/internal/helper" + "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/uozi-tech/cosy" +) + +// bundledNginxUIConfPath is a var so tests can redirect it. +var bundledNginxUIConfPath = "/etc/nginx/conf.d/nginx-ui.conf" + +// Markers indicating the WS reverse-proxy fix is present. +var ( + reMapForwardedProto = regexp.MustCompile(`(?m)^\s*map\s+\$http_x_forwarded_proto\s+\$forwarded_proto\b`) + reMapForwardedHost = regexp.MustCompile(`(?m)^\s*map\s+\$http_x_forwarded_host\s+\$forwarded_host\b`) + reHeaderForwardedProto = regexp.MustCompile(`(?m)^\s*proxy_set_header\s+X-Forwarded-Proto\s+\$forwarded_proto\b`) + reHeaderForwardedHost = regexp.MustCompile(`(?m)^\s*proxy_set_header\s+X-Forwarded-Host\s+\$forwarded_host\b`) +) + +// CheckBundledNginxUIConf returns nil if the bundled conf has all expected fix markers, +// or ErrBundledNginxUIConfOutdated if any marker is missing. +// Outside of managed bundled Nginx Docker mode, returns nil unconditionally. +func CheckBundledNginxUIConf() error { + if !helper.ShouldManageBundledNginx() { + return nil + } + data, err := os.ReadFile(bundledNginxUIConfPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // delegated to other tasks + } + return cosy.WrapErrorWithParams(ErrFailedToReadBundledNginxUIConf, err.Error()) + } + if !hasBundledConfWebSocketFix(data) { + return ErrBundledNginxUIConfOutdated + } + return nil +} + +func hasBundledConfWebSocketFix(data []byte) bool { + return reMapForwardedProto.Match(data) && + reMapForwardedHost.Match(data) && + reHeaderForwardedProto.Match(data) && + reHeaderForwardedHost.Match(data) +} + +// Patterns to rewrite when an old default is still in place. +var ( + reHeaderForwardedProtoLegacy = regexp.MustCompile(`(?m)^(\s*proxy_set_header\s+X-Forwarded-Proto\s+)\$scheme(\s*;)`) + reHeaderForwardedHostLegacy = regexp.MustCompile(`(?m)^(\s*proxy_set_header\s+X-Forwarded-Host\s+)\$http_host(\s*;)`) +) + +const mapForwardedProtoBlock = `# Preserve X-Forwarded-Proto from an outer reverse proxy (e.g. host nginx +# terminating TLS in front of this container). Only fall back to $scheme +# when the inbound request did not carry the header. +map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + +` + +const mapForwardedHostBlock = `# Same for X-Forwarded-Host: keep what the outer proxy stamped, otherwise +# use the inbound Host header. +map $http_x_forwarded_host $forwarded_host { + default $http_x_forwarded_host; + '' $http_host; +} + +` + +// applyBundledConfPatch returns a patched copy of in. Idempotent. +func applyBundledConfPatch(in []byte) []byte { + out := in + out = reHeaderForwardedProtoLegacy.ReplaceAll(out, []byte("${1}$$forwarded_proto${2}")) + out = reHeaderForwardedHostLegacy.ReplaceAll(out, []byte("${1}$$forwarded_host${2}")) + + var injection strings.Builder + if !reMapForwardedProto.Match(out) { + injection.WriteString(mapForwardedProtoBlock) + } + if !reMapForwardedHost.Match(out) { + injection.WriteString(mapForwardedHostBlock) + } + if injection.Len() > 0 { + out = injectBeforeFirstServer(out, injection.String()) + } + return out +} + +// reFirstServer matches the first top-level `server {` for map injection. +var reFirstServer = regexp.MustCompile(`(?m)^server\s*\{`) + +// injectBeforeFirstServer inserts s before the first top-level `server {`. +// Falls back to prepend if no server block is found. +func injectBeforeFirstServer(in []byte, s string) []byte { + idx := reFirstServer.FindIndex(in) + if idx == nil { + return append([]byte(s), in...) + } + out := make([]byte, 0, len(in)+len(s)) + out = append(out, in[:idx[0]]...) + out = append(out, s...) + out = append(out, in[idx[0]:]...) + return out +} + +// patchOnDiskWithBackup atomically writes the patched contents to bundledNginxUIConfPath. +// On any failure after the backup file exists, the target is restored from the backup +// and an error wrapping the backup path is returned. +// +// Restore errors are folded into the returned error message; ErrCriticalRecoveryFailed +// is reserved for the verify/reload layer (Phase 3.7), where a failed restore after +// nginx -t rejection is a distinct fault class operators need to triage specifically. +func patchOnDiskWithBackup(orig []byte, bak string) error { + patched := applyBundledConfPatch(orig) + if !hasBundledConfWebSocketFix(patched) { + return cosy.WrapErrorWithParams(ErrFixedConfigInvalid, + "unable to apply all required WebSocket reverse-proxy markers; backup at "+bak) + } + tmp := bundledNginxUIConfPath + ".tmp" + if err := os.WriteFile(tmp, patched, 0o644); err != nil { + _ = restoreFromBackup(bundledNginxUIConfPath, bak) + return cosy.WrapErrorWithParams(ErrFixedConfigInvalid, + "write failed: "+err.Error()+"; restored from "+bak) + } + if err := os.Rename(tmp, bundledNginxUIConfPath); err != nil { + _ = os.Remove(tmp) + _ = restoreFromBackup(bundledNginxUIConfPath, bak) + return cosy.WrapErrorWithParams(ErrFixedConfigInvalid, + "rename failed: "+err.Error()+"; restored from "+bak) + } + return nil +} + +// restoreFromBackup copies the contents of bak over target. +func restoreFromBackup(target, bak string) error { + data, err := os.ReadFile(bak) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) +} + +// FixBundledNginxUIConf is the FixFunc for the bundled nginx-ui.conf upgrade +// self_check task (registered in tasks.go). +// Flow: read -> backup -> patch -> atomic write -> nginx -t -> reload. +// On any failure between backup and verify the file is rolled back; the error +// always includes the backup path. +func FixBundledNginxUIConf() error { + orig, err := os.ReadFile(bundledNginxUIConfPath) + if err != nil { + return cosy.WrapErrorWithParams(ErrFailedToReadBundledNginxUIConf, err.Error()) + } + + bak := fmt.Sprintf("%s.bak.%s", bundledNginxUIConfPath, time.Now().Format("20060102150405")) + if err := os.WriteFile(bak, orig, 0o644); err != nil { + return cosy.WrapErrorWithParams(ErrFailedToCreateBackup, err.Error()) + } + + if err := patchOnDiskWithBackup(orig, bak); err != nil { + return err + } + return verifyAndReload(bak) +} + +// verifyAndReload runs `nginx -t` and rolls back on failure, then reloads. +// Reload failures do NOT trigger rollback because the file on disk is already valid. +func verifyAndReload(bak string) error { + if out, err := nginx.TestConfig(); err != nil { + if rerr := restoreFromBackup(bundledNginxUIConfPath, bak); rerr != nil { + return cosy.WrapErrorWithParams(ErrCriticalRecoveryFailed, + "validate failed: "+strings.TrimSpace(out)+ + "; restore failed: "+rerr.Error()+ + "; backup at "+bak) + } + return cosy.WrapErrorWithParams(ErrFixedConfigInvalid, + strings.TrimSpace(out)+"; restored from "+bak) + } + if out, err := nginx.Reload(); err != nil { + return cosy.WrapErrorWithParams(ErrReloadFailed, + strings.TrimSpace(out)+"; backup at "+bak) + } + return nil +} diff --git a/internal/self_check/bundled_nginx_ui_conf_test.go b/internal/self_check/bundled_nginx_ui_conf_test.go new file mode 100644 index 00000000..1f209014 --- /dev/null +++ b/internal/self_check/bundled_nginx_ui_conf_test.go @@ -0,0 +1,217 @@ +package self_check + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uozi-tech/cosy" +) + +// withFixture copies the named fixture from test_cases/bundled/ into a tempdir +// and redirects bundledNginxUIConfPath to it for the duration of the test. +// Also forces InNginxUIOfficialDocker() to true via env var. +func withFixture(t *testing.T, name string) string { + t.Helper() + src := filepath.Join("test_cases", "bundled", name) + data, err := os.ReadFile(src) + require.NoError(t, err, "fixture %s", name) + + dir := t.TempDir() + target := filepath.Join(dir, "nginx-ui.conf") + require.NoError(t, os.WriteFile(target, data, 0o644)) + + orig := bundledNginxUIConfPath + bundledNginxUIConfPath = target + t.Cleanup(func() { bundledNginxUIConfPath = orig }) + + // Force the docker guard on. + t.Setenv("NGINX_UI_OFFICIAL_DOCKER", "true") + t.Setenv("NGINX_UI_IGNORE_DOCKER_SOCKET", "") + return target +} + +func TestCheckBundledNginxUIConf(t *testing.T) { + cases := []struct { + name string + fixture string + wantOK bool + wantErr int32 // cosy error code; ignored if wantOK + }{ + {"unfixed default", "unfixed-default.conf", false, 40421}, + {"fixed default", "fixed-default.conf", true, 0}, + {"customized unfixed", "customized-unfixed.conf", false, 40421}, + {"customized fixed", "customized-fixed.conf", true, 0}, + {"half-fixed (one map missing)", "maps-only-half.conf", false, 40421}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withFixture(t, tc.fixture) + err := CheckBundledNginxUIConf() + if tc.wantOK { + assert.NoError(t, err) + return + } + var cErr *cosy.Error + require.True(t, errors.As(err, &cErr), "want cosy.Error, got %T", err) + assert.Equal(t, tc.wantErr, cErr.Code) + }) + } +} + +func TestCheckBundledNginxUIConf_MissingFile(t *testing.T) { + dir := t.TempDir() + orig := bundledNginxUIConfPath + bundledNginxUIConfPath = filepath.Join(dir, "does-not-exist.conf") + t.Cleanup(func() { bundledNginxUIConfPath = orig }) + t.Setenv("NGINX_UI_OFFICIAL_DOCKER", "true") + t.Setenv("NGINX_UI_IGNORE_DOCKER_SOCKET", "") + + // Missing file is delegated to other tasks; CheckFunc returns nil. + assert.NoError(t, CheckBundledNginxUIConf()) +} + +func TestCheckBundledNginxUIConf_NotInDocker(t *testing.T) { + t.Setenv("NGINX_UI_OFFICIAL_DOCKER", "") + // Even with a missing path, no error when not in docker. + orig := bundledNginxUIConfPath + bundledNginxUIConfPath = "/nonexistent/path" + t.Cleanup(func() { bundledNginxUIConfPath = orig }) + assert.NoError(t, CheckBundledNginxUIConf()) +} + +func TestCheckBundledNginxUIConf_SkipsWhenBundledNginxDisabled(t *testing.T) { + t.Setenv("NGINX_UI_OFFICIAL_DOCKER", "true") + t.Setenv("NGINX_UI_DISABLE_BUNDLED_NGINX", "true") + + dir := t.TempDir() + target := filepath.Join(dir, "nginx-ui.conf") + src := filepath.Join("test_cases", "bundled", "unfixed-default.conf") + data, err := os.ReadFile(src) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, data, 0o644)) + + orig := bundledNginxUIConfPath + bundledNginxUIConfPath = target + t.Cleanup(func() { bundledNginxUIConfPath = orig }) + + assert.NoError(t, CheckBundledNginxUIConf()) +} + +func TestCheckBundledNginxUIConf_RunsEvenWithDockerSocketIgnored(t *testing.T) { + // IGNORE_DOCKER_SOCKET should NOT suppress this check — it's only meant + // to opt out of the docker-socket feature, not all docker-only checks. + t.Setenv("NGINX_UI_OFFICIAL_DOCKER", "true") + t.Setenv("NGINX_UI_IGNORE_DOCKER_SOCKET", "true") + + dir := t.TempDir() + target := filepath.Join(dir, "nginx-ui.conf") + src := filepath.Join("test_cases", "bundled", "unfixed-default.conf") + data, err := os.ReadFile(src) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, data, 0o644)) + + orig := bundledNginxUIConfPath + bundledNginxUIConfPath = target + t.Cleanup(func() { bundledNginxUIConfPath = orig }) + + err = CheckBundledNginxUIConf() + var cErr *cosy.Error + require.True(t, errors.As(err, &cErr)) + assert.Equal(t, int32(40421), cErr.Code) +} + +func TestApplyBundledConfPatch_Idempotent(t *testing.T) { + fixed, err := os.ReadFile(filepath.Join("test_cases", "bundled", "fixed-default.conf")) + require.NoError(t, err) + assert.Equal(t, fixed, applyBundledConfPatch(fixed), + "already-fixed input must be byte-equal output") +} + +func TestApplyBundledConfPatch_UpgradesUnfixed(t *testing.T) { + in, err := os.ReadFile(filepath.Join("test_cases", "bundled", "unfixed-default.conf")) + require.NoError(t, err) + out := applyBundledConfPatch(in) + + assert.True(t, reMapForwardedProto.Match(out), "must inject forwarded_proto map") + assert.True(t, reMapForwardedHost.Match(out), "must inject forwarded_host map") + assert.True(t, reHeaderForwardedProto.Match(out), "must rewrite X-Forwarded-Proto to $forwarded_proto") + assert.True(t, reHeaderForwardedHost.Match(out), "must rewrite X-Forwarded-Host to $forwarded_host") +} + +func TestApplyBundledConfPatch_PreservesCustomization(t *testing.T) { + in, err := os.ReadFile(filepath.Join("test_cases", "bundled", "customized-unfixed.conf")) + require.NoError(t, err) + out := applyBundledConfPatch(in) + + assert.Contains(t, string(out), "client_max_body_size 256M", + "user customization must survive") + assert.Contains(t, string(out), "server_name nginx-ui.example.com", + "user customization must survive") + assert.True(t, reHeaderForwardedProto.Match(out)) + assert.True(t, reHeaderForwardedHost.Match(out)) +} + +func TestApplyBundledConfPatch_HalfFixedFillsOnlyMissing(t *testing.T) { + in, err := os.ReadFile(filepath.Join("test_cases", "bundled", "maps-only-half.conf")) + require.NoError(t, err) + out := applyBundledConfPatch(in) + + // Both maps now present; should not duplicate the existing one. + assert.Equal(t, 1, len(reMapForwardedProto.FindAll(out, -1)), + "forwarded_proto map must appear exactly once") + assert.Equal(t, 1, len(reMapForwardedHost.FindAll(out, -1))) +} + +func TestInjectBeforeFirstServer_FallbackToPrepend(t *testing.T) { + in := []byte("# only comments, no server block\n") + out := injectBeforeFirstServer(in, "INJECTED\n") + assert.Equal(t, "INJECTED\n# only comments, no server block\n", string(out)) +} + +func TestPatchOnDiskWithBackup_RewritesAndBacksUp(t *testing.T) { + target := withFixture(t, "customized-unfixed.conf") + orig, _ := os.ReadFile(target) + + bak := target + ".bak.test" + require.NoError(t, os.WriteFile(bak, orig, 0o644)) + + require.NoError(t, patchOnDiskWithBackup(orig, bak)) + + got, _ := os.ReadFile(target) + assert.True(t, reHeaderForwardedProto.Match(got), "target must be patched") + assert.True(t, reHeaderForwardedHost.Match(got), "target must be patched") + assert.Contains(t, string(got), "client_max_body_size 256M", + "customization must survive") + + bakData, _ := os.ReadFile(bak) + assert.Equal(t, orig, bakData, "backup must contain pre-patch bytes") +} + +func TestPatchOnDiskWithBackup_DoesNotMutateTargetOnWriteError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX-only test: relies on chmod 0o555 to make a directory read-only") + } + + target := withFixture(t, "customized-unfixed.conf") + orig, _ := os.ReadFile(target) + bak := target + ".bak.test" + require.NoError(t, os.WriteFile(bak, orig, 0o644)) + + // Make the parent dir read-only to force os.WriteFile(.tmp) to fail. + dir := filepath.Dir(target) + require.NoError(t, os.Chmod(dir, 0o555)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + err := patchOnDiskWithBackup(orig, bak) + require.Error(t, err) + + // Target must be untouched (restore would also fail under the same chmod, + // but since the .tmp write failed first the target file was never modified). + got, _ := os.ReadFile(target) + assert.Equal(t, orig, got, "failed patch must leave target byte-identical to its pre-patch state") +} diff --git a/internal/self_check/errors.go b/internal/self_check/errors.go index ebc06f14..ae1e9bb8 100644 --- a/internal/self_check/errors.go +++ b/internal/self_check/errors.go @@ -27,4 +27,9 @@ var ( ErrErrorLogPathNotExist = e.New(40418, "Error log path not exist") ErrConfdNotExists = e.New(40419, "Conf.d directory not exists") ErrGeoLiteDBNotFound = e.New(40420, "GeoLite2 database not found at {0}. Log indexing requires GeoLite2 database for geographic IP analysis") + ErrFailedToReadBundledNginxUIConf = e.New(50007, "Failed to read bundled nginx-ui.conf: {0}") + ErrBundledNginxUIConfOutdated = e.New(40421, "Bundled nginx-ui.conf is missing the WebSocket reverse-proxy fix") + ErrFixedConfigInvalid = e.New(50008, "Patched nginx-ui.conf is invalid: {0}") + ErrReloadFailed = e.New(50009, "Nginx reload after fix failed: {0}") + ErrCriticalRecoveryFailed = e.New(50010, "Failed to restore nginx-ui.conf from backup: {0}") ) diff --git a/internal/self_check/tasks.go b/internal/self_check/tasks.go index 2adaed6e..c6b0a4df 100644 --- a/internal/self_check/tasks.go +++ b/internal/self_check/tasks.go @@ -153,6 +153,20 @@ func Init() { }) } + if helper.ShouldManageBundledNginx() { + selfCheckTasks = append(selfCheckTasks, &Task{ + Key: "Docker-BundledNginxUIConf-WS", + Name: translation.C("Bundled nginx-ui.conf has WebSocket reverse-proxy fix"), + Description: translation.C( + "When the container is behind an outer reverse proxy that terminates TLS " + + "(e.g. host nginx, Cloudflare), the bundled conf.d/nginx-ui.conf must trust " + + "the inbound X-Forwarded-Proto/Host headers; otherwise WebSocket origin checks fail. " + + "Older deployments that persisted /etc/nginx may still have the unfixed version."), + CheckFunc: CheckBundledNginxUIConf, + FixFunc: FixBundledNginxUIConf, + }) + } + if settings.NginxLogSettings.IndexingEnabled { selfCheckTasks = append(selfCheckTasks, &Task{ Key: "GeoLite-DB", diff --git a/internal/self_check/test_cases/bundled/customized-fixed.conf b/internal/self_check/test_cases/bundled/customized-fixed.conf new file mode 100644 index 00000000..5aec0252 --- /dev/null +++ b/internal/self_check/test_cases/bundled/customized-fixed.conf @@ -0,0 +1,32 @@ +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 80; + server_name nginx-ui.example.com; + client_max_body_size 256M; + + 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/internal/self_check/test_cases/bundled/customized-unfixed.conf b/internal/self_check/test_cases/bundled/customized-unfixed.conf new file mode 100644 index 00000000..b6edf388 --- /dev/null +++ b/internal/self_check/test_cases/bundled/customized-unfixed.conf @@ -0,0 +1,22 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name nginx-ui.example.com; + client_max_body_size 256M; + + location / { + proxy_set_header Host $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 $scheme; + proxy_set_header X-Forwarded-Host $http_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/internal/self_check/test_cases/bundled/fixed-default.conf b/internal/self_check/test_cases/bundled/fixed-default.conf new file mode 100644 index 00000000..593acc0e --- /dev/null +++ b/internal/self_check/test_cases/bundled/fixed-default.conf @@ -0,0 +1,37 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Preserve X-Forwarded-Proto from an outer reverse proxy (e.g. host nginx +# terminating TLS in front of this container). Only fall back to $scheme +# when the inbound request did not carry the header. +map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + +# Same for X-Forwarded-Host: keep what the outer proxy stamped, otherwise +# use the inbound Host header. +map $http_x_forwarded_host $forwarded_host { + default $http_x_forwarded_host; + '' $http_host; +} + +server { + listen 80; + server_name localhost; # your domain here + client_max_body_size 128M; # maximum upload size + + 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/internal/self_check/test_cases/bundled/maps-only-half.conf b/internal/self_check/test_cases/bundled/maps-only-half.conf new file mode 100644 index 00000000..063e920c --- /dev/null +++ b/internal/self_check/test_cases/bundled/maps-only-half.conf @@ -0,0 +1,27 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' $scheme; +} + +server { + listen 80; + server_name localhost; + client_max_body_size 128M; + + 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 $scheme; + proxy_set_header X-Forwarded-Host $http_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/internal/self_check/test_cases/bundled/unfixed-default.conf b/internal/self_check/test_cases/bundled/unfixed-default.conf new file mode 100644 index 00000000..baf71b92 --- /dev/null +++ b/internal/self_check/test_cases/bundled/unfixed-default.conf @@ -0,0 +1,22 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name localhost; # your domain here + client_max_body_size 128M; # maximum upload size + + location / { + proxy_set_header Host $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 $scheme; + proxy_set_header X-Forwarded-Host $http_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/docker/init-config.sh b/resources/docker/init-config.sh index cd0ee749..ffd8b451 100755 --- a/resources/docker/init-config.sh +++ b/resources/docker/init-config.sh @@ -1,6 +1,97 @@ #!/bin/bash +# Initialize /etc/nginx on first boot, and upgrade bundled project files +# (e.g. conf.d/nginx-ui.conf) when they are still byte-equal to a known +# historical official default. Customized files are left alone; the UI +# self_check task surfaces them as a one-click fix. +# +# Override paths via env vars (used by bats tests). +# Sourcing the script with `--testing` defines functions and returns without +# running init_config_main; any other invocation runs main against the caller's +# environment. +set -u # explicit failure handling; -e would skip our recovery paths -if [ "$(ls -A /etc/nginx)" = "" ]; then - cp -rp /usr/local/etc/nginx/* /etc/nginx/ - echo "[INFO] Nginx configurations directory initialized" -fi +: "${ETC_NGINX:=/etc/nginx}" +: "${TEMPLATE_DIR:=/usr/local/etc/nginx}" +: "${HASH_FILE:=/usr/local/share/nginx-ui/nginx-ui.conf.known-hashes}" + +log() { echo "[$1] init-config: $2"; } + +# sync_bundled_file