// Package nginxsetup prepares the host for a separately configured HTTP-01 gateway. package nginxsetup import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "time" ) const Webroot = "/var/lib/opsx-vpn/acme" var running atomic.Bool type Status struct { OS string `json:"os"` Version string `json:"version"` PackageManager string `json:"package_manager"` Supported bool `json:"supported"` Root bool `json:"root"` Nginx bool `json:"nginx_installed"` Certbot bool `json:"certbot_installed"` Webroot bool `json:"webroot_ready"` Running bool `json:"running"` } // ParseOSRelease never executes source content from /etc/os-release. func ParseOSRelease(raw string) (id, version string) { for _, line := range strings.Split(raw, "\n") { key, value, ok := strings.Cut(line, "=") if !ok { continue } value = strings.Trim(strings.TrimSpace(value), `"'`) switch key { case "ID": id = value case "VERSION_ID": version = value } } return } func Plan(id, version string) (pm string, commands [][]string) { switch { case id == "ubuntu": return "apt-get", [][]string{{"apt-get", "update"}, {"apt-get", "install", "-y", "nginx", "certbot"}} case id == "tencentos" && strings.Split(version, ".")[0] == "4": return "dnf", [][]string{{"dnf", "install", "-y", "nginx", "certbot"}} default: return "", nil } } func Inspect() Status { raw, _ := os.ReadFile("/etc/os-release") id, version := ParseOSRelease(string(raw)) pm, _ := Plan(id, version) _, nginxErr := exec.LookPath("nginx") _, certbotErr := exec.LookPath("certbot") info, dirErr := os.Stat(filepath.Join(Webroot, ".well-known", "acme-challenge")) return Status{OS: id, Version: version, PackageManager: pm, Supported: pm != "", Root: os.Geteuid() == 0, Nginx: nginxErr == nil, Certbot: certbotErr == nil, Webroot: dirErr == nil && info.IsDir(), Running: running.Load()} } func Begin() bool { return running.CompareAndSwap(false, true) } func End() { running.Store(false) } // Run executes only the package commands selected by the server-side OS probe. // emit is called for command output while the process is running. func Run(emit func(string)) error { status := Inspect() if !status.Supported { return fmt.Errorf("仅支持 Ubuntu 和 TencentOS Server 4;当前为 %s %s", status.OS, status.Version) } if !status.Root { return fmt.Errorf("初始化需要以 root 运行的服务进程") } pm, commands := Plan(status.OS, status.Version) if _, err := exec.LookPath(pm); err != nil { return fmt.Errorf("缺少包管理器 %s: %w", pm, err) } if status.Nginx && status.Certbot { commands = nil } if len(commands) > 0 && pm == "apt-get" { // Debian maintainer scripts may start Nginx during installation. Prevent // that without replacing an administrator's pre-existing policy. policy := "/usr/sbin/policy-rc.d" file, err := os.OpenFile(policy, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755) if err != nil { return fmt.Errorf("无法安全禁止安装时自动启动服务(%s):%w;请手动安装", policy, err) } if _, err := file.WriteString("#!/bin/sh\nexit 101\n"); err != nil { file.Close() os.Remove(policy) return err } if err := file.Close(); err != nil { os.Remove(policy) return err } defer os.Remove(policy) emit("已临时禁止安装脚本自动启动服务;安装结束后恢复。\n") } for _, args := range commands { emit("$ " + strings.Join(args, " ") + "\n") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") cmd.Stdout = &outputWriter{emit: emit} cmd.Stderr = cmd.Stdout err := cmd.Run() cancel() if err != nil { return fmt.Errorf("%s 执行失败: %w", args[0], err) } } if _, err := exec.LookPath("nginx"); err != nil { return fmt.Errorf("安装后仍未找到 nginx: %w", err) } if _, err := exec.LookPath("certbot"); err != nil { return fmt.Errorf("安装后仍未找到 certbot: %w", err) } path := filepath.Join(Webroot, ".well-known", "acme-challenge") if err := os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("创建验证目录失败: %w", err) } emit("验证目录已就绪:" + path + "\n") emit("初始化完成。下一步配置 HTTP 站点、检查 nginx -t,再启动 Nginx 并签发证书。\n") return nil } type outputWriter struct { emit func(string) bytes int mu sync.Mutex } func (w *outputWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() n := len(p) if w.bytes < 2*1024*1024 { remaining := 2*1024*1024 - w.bytes if len(p) > remaining { p = p[:remaining] } w.emit(string(p)) w.bytes += len(p) if w.bytes == 2*1024*1024 { w.emit("\n[安装日志显示达到 2MB 上限,命令继续运行]\n") } } return n, nil }