163 lines
4.9 KiB
Go
163 lines
4.9 KiB
Go
// 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
|
|
}
|