Files
Havline/internal/nginx/nginx.go
T
mrlee 862442e77f feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回

- agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编

- 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值

- 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知

- API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
2026-09-24 20:50:17 +08:00

427 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 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚)
if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
return m.activate(ctx)
}
// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。
// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。
func (m *Manager) activate(ctx context.Context) (ApplyResult, error) {
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)
}