Files
Havline/internal/nginx/nginx.go
T

417 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package nginx
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/proxy"
)
const (
nginxCmdTimeout = 8 * time.Second
nginxStartWait = 5 * time.Second
nginxTerminateWait = 3 * time.Second
)
type Manager struct {
cfg config.Config
logger *slog.Logger
opts GenerateOptions
}
type ApplyResult struct {
Reloaded bool
Message string
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger}
}
func (m *Manager) SetGenerateOptions(opts GenerateOptions) {
m.opts = opts
}
func (m *Manager) CurrentOptions() GenerateOptions {
return m.opts
}
func (m *Manager) EnsureDirs() error {
for _, dir := range []string{m.cfg.NginxDir(), m.cfg.LogsDir(), m.cfg.CertsDir()} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
workDirs := []string{
"logs",
"client_body_temp",
"proxy_temp",
"fastcgi_temp",
"uwsgi_temp",
"scgi_temp",
filepath.Join("temp", "client_body_temp"),
filepath.Join("temp", "proxy_temp"),
filepath.Join("temp", "fastcgi_temp"),
filepath.Join("temp", "uwsgi_temp"),
filepath.Join("temp", "scgi_temp"),
}
for _, name := range workDirs {
if err := os.MkdirAll(filepath.Join(m.cfg.NginxDir(), name), 0o755); err != nil {
return err
}
}
return nil
}
func (m *Manager) nginxGlobalArgs() []string {
prefix := absNginxPath(m.cfg.NginxDir())
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
errorLog := absNginxPath(filepath.Join(m.cfg.LogsDir(), "error.log"))
return []string{"-p", prefix, "-e", errorLog}
}
func (m *Manager) available() bool {
_, err := exec.LookPath(m.cfg.NginxBin)
return err == nil
}
func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSource) (ApplyResult, error) {
if err := m.EnsureDirs(); err != nil {
return ApplyResult{}, err
}
if err := EnsureErrorPages(m.cfg); err != nil {
return ApplyResult{}, err
}
if err := EnsureCustomDirs(m.cfg); err != nil {
return ApplyResult{}, err
}
if err := SyncHtpasswdFiles(m.cfg, rules); err != nil {
return ApplyResult{}, err
}
opts := m.opts
// Path override is only for pre-rename validation during China CIDR updates.
opts.ChinaCIDRPathOverride = ""
opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
content, err := Generate(m.cfg, rules, certs, opts)
if err != nil {
return ApplyResult{}, err
}
tmpPath := m.cfg.NginxConfigPath() + ".tmp"
if err := fsutil.WriteFileAtomic(tmpPath, []byte(content), 0o644); err != nil {
return ApplyResult{}, err
}
if err := ValidateConfigSyntax(content); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if m.available() {
if err := m.validate(ctx, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
} else {
m.logger.Warn("nginx binary not found, applied syntax-only validation", "bin", m.cfg.NginxBin)
}
currentPath := m.cfg.NginxConfigPath()
// 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if !m.available() {
return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
}
running, err := m.isRunning()
if err != nil {
return ApplyResult{}, err
}
if !running {
if err := m.start(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{Reloaded: true, Message: "Nginx 已启动"}, nil
}
if err := m.reload(ctx); err != nil {
m.logger.Warn("nginx reload failed, trying restart", "error", err)
if err := m.forceRestart(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{Reloaded: true, Message: "Nginx 已重新启动"}, nil
}
return ApplyResult{Reloaded: true, Message: "Nginx 已重载"}, nil
}
func (m *Manager) ValidateOnly(ctx context.Context, rules []proxy.Rule, certs []CertSource) error {
return m.ValidateOnlyWithOptions(ctx, rules, certs, m.opts)
}
func (m *Manager) ValidateOnlyWithOptions(ctx context.Context, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) error {
if opts.ChinaCIDRPathOverride != "" {
opts.ChinaCIDRAvailable = true
} else {
opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
}
content, err := Generate(m.cfg, rules, certs, opts)
if err != nil {
return err
}
if err := ValidateConfigSyntax(content); err != nil {
return err
}
if !m.available() {
m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
return nil
}
tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
return err
}
defer os.Remove(tmpPath)
return m.validate(ctx, tmpPath)
}
func (m *Manager) validate(ctx context.Context, configPath string) error {
tmpErr := filepath.Join(m.cfg.NginxDir(), "validate.error.log")
args := []string{"-e", absNginxPath(tmpErr), "-t", "-c", configPath}
err := m.runNginx(ctx, args, "Nginx 配置校验失败")
_ = os.Remove(tmpErr)
return err
}
func (m *Manager) start(ctx context.Context) error {
running, err := m.isRunning()
if err != nil {
return err
}
if running {
return m.reload(ctx)
}
if m.trySilentReload(ctx) {
m.logger.Info("nginx reloaded existing master")
return nil
}
prepareStartPlatform(m, ctx)
if err := m.runNginxStart(ctx, []string{"-c", m.cfg.NginxConfigPath()}, "Nginx 启动失败"); err != nil {
if strings.Contains(err.Error(), "conflicting server name") && m.trySilentReload(ctx) {
m.logger.Warn("nginx start skipped duplicate master, reloaded instead")
return nil
}
return err
}
m.logger.Info("nginx started")
return nil
}
func (m *Manager) trySilentReload(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel()
fullArgs := append(m.nginxGlobalArgs(), "-c", m.cfg.NginxConfigPath(), "-s", "reload")
cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
return cmd.Run() == nil
}
func (m *Manager) reload(ctx context.Context) error {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err == nil && isPIDAlive(pid) {
if err := reloadProcess(pid); err == nil {
m.logger.Info("nginx reloaded")
return nil
}
m.logger.Warn("nginx signal reload failed, falling back to cli", "pid", pid, "error", err)
} else if err != nil && !os.IsNotExist(err) {
removePIDFile(m.cfg.NginxPIDFile)
}
if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "reload"}, "Nginx 重载失败"); err != nil {
return err
}
m.logger.Info("nginx reloaded")
return nil
}
func (m *Manager) forceRestart(ctx context.Context) error {
m.terminateMaster()
removePIDFile(m.cfg.NginxPIDFile)
prepareStartPlatform(m, ctx)
return m.start(ctx)
}
func (m *Manager) terminateMaster() {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err != nil || !isPIDAlive(pid) {
return
}
_ = terminateProcess(pid, syscall.SIGTERM)
waitProcessExit(pid, nginxTerminateWait)
if isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGKILL)
waitProcessExit(pid, time.Second)
}
}
func (m *Manager) Stop(ctx context.Context) error {
if !m.isRunningQuick() {
return nil
}
if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "Nginx 停止失败"); err != nil {
return err
}
m.logger.Info("nginx stopped")
return nil
}
func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel()
fullArgs := append(m.nginxGlobalArgs(), args...)
cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
if ctx.Err() == context.DeadlineExceeded {
msg = "操作超时"
}
m.logger.Error("nginx command failed", "args", strings.Join(fullArgs, " "), "error", msg)
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
}
return nil
}
// runNginxStart launches nginx without waiting for the master process to exit.
// On Windows the master stays in the foreground and would block cmd.Run().
func (m *Manager) runNginxStart(ctx context.Context, args []string, prefix string) error {
fullArgs := append(m.nginxGlobalArgs(), args...)
cmd := exec.Command(m.cfg.NginxBin, fullArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s:%s", prefix, err.Error())
}
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
deadline := time.Now().Add(nginxStartWait)
for time.Now().Before(deadline) {
if running, err := m.isRunning(); err == nil && running {
return nil
}
select {
case err := <-waitDone:
if running, checkErr := m.isRunning(); checkErr == nil && running {
return nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" && err != nil {
msg = err.Error()
}
if msg == "" {
msg = "进程已退出"
}
m.logger.Error("nginx start failed", "args", strings.Join(fullArgs, " "), "error", msg)
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
default:
}
if ctx.Err() != nil {
return fmt.Errorf("%s:%s", prefix, "操作超时")
}
time.Sleep(100 * time.Millisecond)
}
if running, err := m.isRunning(); err == nil && running {
return nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = "未检测到运行中的进程"
}
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
}
func (m *Manager) isRunning() (bool, error) {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
removePIDFile(m.cfg.NginxPIDFile)
return false, nil
}
if !isPIDAlive(pid) {
removePIDFile(m.cfg.NginxPIDFile)
return false, nil
}
return true, nil
}
func (m *Manager) isRunningQuick() bool {
ok, _ := m.isRunning()
return ok
}
func (m *Manager) ValidateContent(ctx context.Context, content string) error {
if err := ValidateConfigSyntax(content); err != nil {
return err
}
if !m.available() {
m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
return nil
}
tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
return err
}
defer os.Remove(tmpPath)
return m.validate(ctx, tmpPath)
}
func ChinaCIDRExists(cfg config.Config) bool {
return chinaCIDRExists(cfg)
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func sanitizeNginxError(msg string) string {
msg = strings.ReplaceAll(msg, "\r\n", " ")
msg = strings.ReplaceAll(msg, "\n", " ")
return strings.TrimSpace(msg)
}