mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
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 <me@jackyu.cn> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jacky <me@jackyu.cn>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<details>
|
||||
<summary><b>Deploy with Docker</b></summary>
|
||||
|
||||
@@ -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(() => {
|
||||
<AAlert type="error" show-icon :message="$gettext('Self check failed, Nginx UI may not work properly')">
|
||||
<template #action>
|
||||
<AButton class="ml-4" size="small" danger @click="router.push('/system/self_check')">
|
||||
{{ $gettext('Check') }}
|
||||
{{ actionLabel }}
|
||||
</AButton>
|
||||
</template>
|
||||
</AAlert>
|
||||
@@ -61,7 +70,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<AButton size="small" danger @click="router.push('/system/self_check')">
|
||||
{{ $gettext('Check') }}
|
||||
{{ actionLabel }}
|
||||
</AButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}'),
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
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' }
|
||||
|
||||
@@ -84,6 +84,7 @@ export const zhCNConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
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' }
|
||||
|
||||
@@ -84,6 +84,7 @@ export const zhTWConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
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' }
|
||||
|
||||
@@ -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 <container> 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.
|
||||
:::
|
||||
@@ -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 <container> nginx -s reload`。
|
||||
|
||||
## 禁用自动升级
|
||||
|
||||
::: info
|
||||
在容器上设置 `NGINX_UI_PRESERVE_BUNDLED_CONF=true` 可关闭启动期的自动升级;
|
||||
UI 内的修复入口仍然可用。
|
||||
:::
|
||||
@@ -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 <container> nginx -s reload`。
|
||||
|
||||
## 停用自動升級
|
||||
|
||||
::: info
|
||||
在容器上設定 `NGINX_UI_PRESERVE_BUNDLED_CONF=true` 即可關閉啟動期自動升級;
|
||||
UI 內的修復入口仍可使用。
|
||||
:::
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}")
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
: "${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 <template> <target> <known_hashes_file>
|
||||
sync_bundled_file() {
|
||||
local template="$1" target="$2" hashes="$3"
|
||||
|
||||
[ -f "$template" ] || { log WARN "template missing: $template"; return 0; }
|
||||
[ -f "$hashes" ] || { log WARN "hash list missing: $hashes"; return 0; }
|
||||
if [ ! -f "$target" ]; then
|
||||
log INFO "target absent; copying template to $target"
|
||||
cp -p "$template" "$target"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if ! command -v sha256sum >/dev/null 2>&1; then
|
||||
log WARN "sha256sum unavailable; skipping sync of $target"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local cur_hash tpl_hash
|
||||
cur_hash="$(sha256sum "$target" | awk '{print $1}')"
|
||||
tpl_hash="$(sha256sum "$template" | awk '{print $1}')"
|
||||
|
||||
if [ "$cur_hash" = "$tpl_hash" ]; then
|
||||
return 0 # already up-to-date
|
||||
fi
|
||||
|
||||
if grep -vE '^[[:space:]]*(#|$)' "$hashes" | awk '{print $1}' \
|
||||
| grep -Fxq "$cur_hash"; then
|
||||
local bak="${target}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
if ! cp -p "$target" "$bak"; then
|
||||
log ERROR "backup failed ($target -> $bak); leaving file untouched"
|
||||
return 1
|
||||
fi
|
||||
local tmp="${target}.tmp.$$"
|
||||
if ! cp -p "$template" "$tmp" || ! mv -f "$tmp" "$target"; then
|
||||
log ERROR "write failed; restoring from $bak"
|
||||
cp -p "$bak" "$target" 2>/dev/null
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
log INFO "Synced $target from bundled template (old saved as $bak)"
|
||||
else
|
||||
log INFO "Skipping $target: customized (hash $cur_hash). See UI self-check."
|
||||
fi
|
||||
}
|
||||
|
||||
init_config_main() {
|
||||
# Early exit: host_via_ssh mode (must come first; see spec §11).
|
||||
if [ "${NGINX_UI_DISABLE_BUNDLED_NGINX:-}" = "true" ]; then
|
||||
log INFO "host mode: skipping bundled nginx config initialization"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Fresh-install seed path (preserves prior behaviour).
|
||||
if [ "$(ls -A "$ETC_NGINX" 2>/dev/null)" = "" ]; then
|
||||
cp -rp "$TEMPLATE_DIR"/* "$ETC_NGINX/"
|
||||
log INFO "Nginx configurations directory initialized"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# User opt-out for the upgrade-existing path.
|
||||
if [ "${NGINX_UI_PRESERVE_BUNDLED_CONF:-}" = "true" ]; then
|
||||
log INFO "NGINX_UI_PRESERVE_BUNDLED_CONF=true; skipping bundled-conf sync"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Whitelist of bundled files we own and may upgrade.
|
||||
if ! sync_bundled_file \
|
||||
"$TEMPLATE_DIR/conf.d/nginx-ui.conf" \
|
||||
"$ETC_NGINX/conf.d/nginx-ui.conf" \
|
||||
"$HASH_FILE"; then
|
||||
log WARN "bundled-conf sync failed; continuing without blocking bundled nginx startup"
|
||||
fi
|
||||
}
|
||||
|
||||
# Only run main when executed directly; --testing lets bats source us.
|
||||
case "${1:-}" in
|
||||
--testing) return 0 ;;
|
||||
*) init_config_main ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Historical SHA256 hashes of the official default conf.d/nginx-ui.conf.
|
||||
# DO NOT delete entries: each line represents a past official default;
|
||||
# removing it would prevent that version's users from getting auto-upgraded.
|
||||
# Append a new line each time resources/docker/nginx-ui.conf changes.
|
||||
# CI enforces this list contains the current template hash on its last line.
|
||||
#
|
||||
# Maintenance: bash resources/docker/scripts/update-nginx-ui-conf-hash.sh
|
||||
6c3f1f06e242facad58e7d2cb3fd10ab1de18a70bf801c01d1b7294226b251b4 # pre-fix template (up to commit 054295ad's parent)
|
||||
93d8357f1fc76297a11855c654cd2aaa7cc517f31afa17c7aa1041d0f5cb2882 # current template (commit 054295ad onward)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Append current SHA256 of resources/docker/nginx-ui.conf to the
|
||||
# known-hashes file. Run this after editing the template, then commit
|
||||
# both files together.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
template=resources/docker/nginx-ui.conf
|
||||
list=resources/docker/nginx-ui.conf.known-hashes
|
||||
|
||||
[ -f "$template" ] || { echo "template not found: $template" >&2; exit 1; }
|
||||
[ -f "$list" ] || { echo "hash list not found: $list" >&2; exit 1; }
|
||||
|
||||
hash=$(sha256sum "$template" | awk '{print $1}')
|
||||
last=$(grep -vE '^[[:space:]]*(#|$)' "$list" | awk '{print $1}' | tail -n1)
|
||||
|
||||
if [ "$hash" = "$last" ]; then
|
||||
echo "No change: hash $hash already at tail of $list"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ver=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")
|
||||
printf '%s # %s (%s)\n' "$hash" "$ver" "$(date +%Y-%m-%d)" >> "$list"
|
||||
echo "Appended: $hash # $ver"
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -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/;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
TMP="$(mktemp -d)"
|
||||
export ETC_NGINX="$TMP/etc-nginx"
|
||||
export TEMPLATE_DIR="$TMP/usr-local/etc/nginx"
|
||||
export HASH_FILE="$TMP/known-hashes"
|
||||
mkdir -p "$ETC_NGINX/conf.d" "$TEMPLATE_DIR/conf.d"
|
||||
|
||||
cp "$BATS_TEST_DIRNAME/fixtures/fixed-default.conf" \
|
||||
"$TEMPLATE_DIR/conf.d/nginx-ui.conf"
|
||||
# Hash list = current template hash + historical (unfixed) hash.
|
||||
sha256sum "$TEMPLATE_DIR/conf.d/nginx-ui.conf" | awk '{print $1}' > "$HASH_FILE"
|
||||
sha256sum "$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf" | awk '{print $1}' >> "$HASH_FILE"
|
||||
|
||||
unset NGINX_UI_DISABLE_BUNDLED_NGINX NGINX_UI_PRESERVE_BUNDLED_CONF
|
||||
|
||||
# Source the script in test mode so functions are defined but main is not run.
|
||||
# shellcheck disable=SC1091
|
||||
source "$BATS_TEST_DIRNAME/../init-config.sh" --testing
|
||||
}
|
||||
|
||||
teardown() { rm -rf "$TMP"; }
|
||||
|
||||
@test "fresh empty dir copies entire template" {
|
||||
rm -rf "$ETC_NGINX"/*
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$ETC_NGINX/conf.d/nginx-ui.conf" ]
|
||||
}
|
||||
|
||||
@test "current-template hash is no-op (no backup created)" {
|
||||
cp "$TEMPLATE_DIR/conf.d/nginx-ui.conf" "$ETC_NGINX/conf.d/nginx-ui.conf"
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
bak_count=$(ls "$ETC_NGINX/conf.d/"*.bak.* 2>/dev/null | wc -l)
|
||||
[ "$bak_count" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "historical hash upgrades the file with a timestamped backup" {
|
||||
cp "$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf" \
|
||||
"$ETC_NGINX/conf.d/nginx-ui.conf"
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
bak=$(ls "$ETC_NGINX/conf.d/"nginx-ui.conf.bak.* 2>/dev/null | head -n1)
|
||||
[ -n "$bak" ]
|
||||
diff -q "$ETC_NGINX/conf.d/nginx-ui.conf" "$TEMPLATE_DIR/conf.d/nginx-ui.conf"
|
||||
diff -q "$bak" "$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf"
|
||||
}
|
||||
|
||||
@test "unknown hash (customized) skips file and logs a message" {
|
||||
printf '# user-customized stub\n' > "$ETC_NGINX/conf.d/nginx-ui.conf"
|
||||
before=$(cat "$ETC_NGINX/conf.d/nginx-ui.conf")
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(cat "$ETC_NGINX/conf.d/nginx-ui.conf")" = "$before" ]
|
||||
[[ "$output" == *"customized"* ]]
|
||||
bak_count=$(ls "$ETC_NGINX/conf.d/"*.bak.* 2>/dev/null | wc -l)
|
||||
[ "$bak_count" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "NGINX_UI_PRESERVE_BUNDLED_CONF=true never syncs" {
|
||||
export NGINX_UI_PRESERVE_BUNDLED_CONF=true
|
||||
cp "$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf" \
|
||||
"$ETC_NGINX/conf.d/nginx-ui.conf"
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
bak_count=$(ls "$ETC_NGINX/conf.d/"*.bak.* 2>/dev/null | wc -l)
|
||||
[ "$bak_count" -eq 0 ]
|
||||
diff -q "$ETC_NGINX/conf.d/nginx-ui.conf" \
|
||||
"$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf"
|
||||
}
|
||||
|
||||
@test "NGINX_UI_DISABLE_BUNDLED_NGINX=true wins over PRESERVE" {
|
||||
export NGINX_UI_DISABLE_BUNDLED_NGINX=true
|
||||
export NGINX_UI_PRESERVE_BUNDLED_CONF=true
|
||||
run init_config_main
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"host mode"* ]]
|
||||
}
|
||||
|
||||
@test "backup failure does not overwrite target" {
|
||||
[ "$(id -u)" -ne 0 ] || skip "chmod-based perm test is no-op as root"
|
||||
cp "$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf" \
|
||||
"$ETC_NGINX/conf.d/nginx-ui.conf"
|
||||
chmod 555 "$ETC_NGINX/conf.d"
|
||||
run init_config_main
|
||||
chmod 755 "$ETC_NGINX/conf.d"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"backup failed"* ]]
|
||||
diff -q "$ETC_NGINX/conf.d/nginx-ui.conf" \
|
||||
"$BATS_TEST_DIRNAME/fixtures/unfixed-default.conf"
|
||||
}
|
||||
@@ -178,6 +178,7 @@ Nuestra imagen dpcker [uozi/nginx-ui:latest](https://hub.docker.com/r/uozi/nginx
|
||||
##### Nota
|
||||
1. Cuando utilice este contenedor por primera vez, asegúrese de que el volumen mapeado a /etc/nginx esté vacío.
|
||||
2. Si desea incluir archivos estáticos, puede mapear directorios al contenedor.
|
||||
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.
|
||||
|
||||
**Ejemplo de desplegado Docker**
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ systemctl restart nginx-ui
|
||||
##### 注意
|
||||
1. 初回利用時は `/etc/nginx` にマッピングするボリュームが空であることを確認してください。
|
||||
2. 静的ファイルを配信する場合は、適切なディレクトリをマッピングしてください。
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary><b>Dockerでデプロイ</b></summary>
|
||||
|
||||
@@ -192,6 +192,7 @@ Docker image của chúng tôi [uozi/nginx-ui:latest](https://hub.docker.com/r/u
|
||||
##### Ghi chú
|
||||
1. Khi khởi chạy container lần đầu tiên, hãy chắc chắn thư mục /etc/nginx trên máy host là rỗng.
|
||||
2. Nếu bạn muốn lưu trữ các tệp tĩnh, bạn có thể mount các thư mục vào 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.
|
||||
|
||||
<details>
|
||||
<summary><b>Triển khai với Docker</b></summary>
|
||||
|
||||
@@ -179,6 +179,7 @@ systemctl restart nginx-ui
|
||||
#### 注意
|
||||
1. 首次使用时,映射到 `/etc/nginx` 的目录必须为空文件夹。
|
||||
2. 如果你想要托管静态文件,可以直接将文件夹映射入容器中。
|
||||
3. 如果你从旧镜像升级,请参阅 [Docker WebSocket 修复指南](https://nginxui.com/zh_CN/guide/docker-websocket-fix.html) 以更新 `conf.d/nginx-ui.conf`。
|
||||
|
||||
**Docker 部署示例**
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ systemctl restart nginx-ui
|
||||
|
||||
注意:對映到 `/etc/nginx` 的資料夾應是一個空資料夾。
|
||||
|
||||
如果你從舊鏡像升級,請參閱 [Docker WebSocket 修復指南](https://nginxui.com/zh_TW/guide/docker-websocket-fix.html) 以更新 `conf.d/nginx-ui.conf`。
|
||||
|
||||
**Docker 範例**
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user