mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
f6992d8789
* 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>
193 lines
6.8 KiB
Go
193 lines
6.8 KiB
Go
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
|
|
}
|