feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// apr1Itoa64 是 apr1(Apache MD5 crypt)使用的自定义 base64 字母表,同时用作随机盐字符集。
|
||||
const apr1Itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// apr1Crypt 生成 apr1 哈希,与 `openssl passwd -apr1` 等价(纯 Go,无外部命令依赖)。
|
||||
// 选 apr1 而非 bcrypt:Nginx 对 $apr1$ 是自带实现,跨发行版稳定;bcrypt 依赖系统 crypt(3),
|
||||
// 在 CentOS 7 这类老 glibc 上无法校验。
|
||||
func apr1Crypt(password string) (string, error) {
|
||||
salt, err := apr1RandomSalt(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return apr1CryptWithSalt(password, salt), nil
|
||||
}
|
||||
|
||||
// apr1RandomSalt 生成指定长度的随机盐(字符取自 itoa64,与 openssl 行为一致)。
|
||||
func apr1RandomSalt(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("生成随机盐失败: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range buf {
|
||||
b.WriteByte(apr1Itoa64[int(v)%len(apr1Itoa64)])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// apr1CryptWithSalt 是 apr1 的确定性实现(salt 取前 8 字符),供单元测试用参考向量比对。
|
||||
// 算法来自 Apache apr_md5_encode:MD5 三轮 + 1000 次迭代 + 自定义字节序 base64。
|
||||
func apr1CryptWithSalt(password, salt string) string {
|
||||
if len(salt) > 8 {
|
||||
salt = salt[:8]
|
||||
}
|
||||
ctx := md5.New()
|
||||
ctx.Write([]byte(password))
|
||||
ctx.Write([]byte("$apr1$"))
|
||||
ctx.Write([]byte(salt))
|
||||
|
||||
alt := md5.Sum([]byte(password + salt + password))
|
||||
for i := len(password); i > 0; i -= 16 {
|
||||
n := i
|
||||
if n > 16 {
|
||||
n = 16
|
||||
}
|
||||
ctx.Write(alt[:n])
|
||||
}
|
||||
for i := len(password); i > 0; i >>= 1 {
|
||||
if i&1 != 0 {
|
||||
ctx.Write([]byte{0})
|
||||
} else {
|
||||
ctx.Write([]byte(password[0:1]))
|
||||
}
|
||||
}
|
||||
final := ctx.Sum(nil)
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
round := md5.New()
|
||||
if i&1 != 0 {
|
||||
round.Write([]byte(password))
|
||||
} else {
|
||||
round.Write(final)
|
||||
}
|
||||
if i%3 != 0 {
|
||||
round.Write([]byte(salt))
|
||||
}
|
||||
if i%7 != 0 {
|
||||
round.Write([]byte(password))
|
||||
}
|
||||
if i&1 != 0 {
|
||||
round.Write(final)
|
||||
} else {
|
||||
round.Write([]byte(password))
|
||||
}
|
||||
final = round.Sum(nil)
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
to64 := func(v uint32, n int) {
|
||||
for ; n > 0; n-- {
|
||||
out.WriteByte(apr1Itoa64[v&0x3f])
|
||||
v >>= 6
|
||||
}
|
||||
}
|
||||
to64(uint32(final[0])<<16|uint32(final[6])<<8|uint32(final[12]), 4)
|
||||
to64(uint32(final[1])<<16|uint32(final[7])<<8|uint32(final[13]), 4)
|
||||
to64(uint32(final[2])<<16|uint32(final[8])<<8|uint32(final[14]), 4)
|
||||
to64(uint32(final[3])<<16|uint32(final[9])<<8|uint32(final[15]), 4)
|
||||
to64(uint32(final[4])<<16|uint32(final[10])<<8|uint32(final[5]), 4)
|
||||
to64(uint32(final[11]), 2)
|
||||
|
||||
return "$apr1$" + salt + "$" + out.String()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// 参考向量由本机 OpenSSL 3.5.4 生成:
|
||||
//
|
||||
// openssl passwd -apr1 -salt <salt> <password>
|
||||
//
|
||||
// 自研哈希必须与 openssl 完全一致,否则 Nginx Basic Auth 会永远认证失败。
|
||||
func TestApr1CryptMatchesOpenSSLVectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
password string
|
||||
salt string
|
||||
want string
|
||||
}{
|
||||
{"password", "xxxxxxxx", "$apr1$xxxxxxxx$dxHfLAsjHkDRmG83UXe8K0"},
|
||||
{"hello", "01234567", "$apr1$01234567$JGzOENmOOkvGYFfJQyG9X/"},
|
||||
{"P@ssw0rd!", "abcdefgh", "$apr1$abcdefgh$T9Ta7XuM2Yltpi/4uviFw0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := apr1CryptWithSalt(c.password, c.salt); got != c.want {
|
||||
t.Errorf("apr1CryptWithSalt(%q, %q)\n got: %s\nwant: %s", c.password, c.salt, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApr1CryptSaltHandling(t *testing.T) {
|
||||
// 超过 8 字符的盐只取前 8 位(与 openssl / apr 行为一致)
|
||||
long := apr1CryptWithSalt("password", "xxxxxxxxyyyy")
|
||||
short := apr1CryptWithSalt("password", "xxxxxxxx")
|
||||
if long != short {
|
||||
t.Errorf("盐应截断到 8 字符: %s vs %s", long, short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApr1CryptRandomSalt(t *testing.T) {
|
||||
first, err := apr1Crypt("password")
|
||||
if err != nil {
|
||||
t.Fatalf("apr1Crypt 失败: %v", err)
|
||||
}
|
||||
second, err := apr1Crypt("password")
|
||||
if err != nil {
|
||||
t.Fatalf("apr1Crypt 失败: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Error("随机盐应产生不同哈希")
|
||||
}
|
||||
// 结构:$apr1$<8 位盐>$<22 位编码> = 37 字符
|
||||
if len(first) != 37 {
|
||||
t.Errorf("哈希长度应为 37,实际 %d: %s", len(first), first)
|
||||
}
|
||||
if first[:6] != "$apr1$" || first[14] != '$' {
|
||||
t.Errorf("哈希结构异常: %s", first)
|
||||
}
|
||||
if same := apr1CryptWithSalt("password", first[6:14]); same != first {
|
||||
t.Errorf("从哈希中取出的盐应能复现同一哈希: %s vs %s", same, first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerAuthToken(t *testing.T) {
|
||||
server := &Server{cfg: Config{Token: "secret-token"}}
|
||||
handler := server.auth(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
status int
|
||||
}{
|
||||
{name: "正确 Token", header: "Bearer secret-token", status: http.StatusNoContent},
|
||||
{name: "Bearer 大小写不敏感", header: "bearer secret-token", status: http.StatusNoContent},
|
||||
{name: "多余空白可容忍", header: " Bearer secret-token ", status: http.StatusNoContent},
|
||||
{name: "错误 Token", header: "Bearer wrong-token", status: http.StatusUnauthorized},
|
||||
{name: "长度不同的 Token", header: "Bearer secret-token-longer", status: http.StatusUnauthorized},
|
||||
{name: "缺少 Authorization", status: http.StatusUnauthorized},
|
||||
{name: "认证方案错误", header: "Token secret-token", status: http.StatusUnauthorized},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler(recorder, req)
|
||||
if recorder.Code != tc.status {
|
||||
t.Fatalf("期望状态 %d,实际 %d", tc.status, recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureTokenEqual(t *testing.T) {
|
||||
if !secureTokenEqual("same-token", "same-token") {
|
||||
t.Fatal("相同 Token 应通过")
|
||||
}
|
||||
if secureTokenEqual("short", "longer-token") {
|
||||
t.Fatal("不同 Token 不应通过")
|
||||
}
|
||||
if secureTokenEqual("", "non-empty") {
|
||||
t.Fatal("空 Token 不应通过")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type firewallStatus struct {
|
||||
Tool string `json:"tool"`
|
||||
Active bool `json:"active"`
|
||||
PortOpen bool `json:"port_open"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。
|
||||
func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) {
|
||||
port := 7443
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 {
|
||||
port = parsed
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detectFirewall(port))
|
||||
}
|
||||
|
||||
// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。
|
||||
func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Port <= 0 || req.Port > 65535 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口无效"})
|
||||
return
|
||||
}
|
||||
output, err := allowFirewallPort(req.Port)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)})
|
||||
}
|
||||
|
||||
func detectFirewall(port int) firewallStatus {
|
||||
if path, err := exec.LookPath("ufw"); err == nil {
|
||||
if out, err := exec.Command(path, "status").Output(); err == nil {
|
||||
active, open := parseUFWStatus(string(out), port)
|
||||
if active {
|
||||
return firewallStatus{Tool: "ufw", Active: true, PortOpen: open}
|
||||
}
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("firewall-cmd"); err == nil {
|
||||
if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" {
|
||||
return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)}
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("nft"); err == nil {
|
||||
return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"}
|
||||
}
|
||||
if _, err := exec.LookPath("iptables"); err == nil {
|
||||
return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"}
|
||||
}
|
||||
return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"}
|
||||
}
|
||||
|
||||
// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。
|
||||
func parseUFWStatus(output string, port int) (bool, bool) {
|
||||
active := false
|
||||
open := false
|
||||
needle := strconv.Itoa(port)
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active")
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) {
|
||||
open = true
|
||||
}
|
||||
}
|
||||
return active, open
|
||||
}
|
||||
|
||||
func firewalldPortOpen(path string, port int) bool {
|
||||
out, err := exec.Command(path, "--list-ports").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Fields(string(out)) {
|
||||
if strings.HasPrefix(item, strconv.Itoa(port)+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allowFirewallPort(port int) (string, error) {
|
||||
status := detectFirewall(port)
|
||||
switch status.Tool {
|
||||
case "ufw":
|
||||
out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
case "firewalld":
|
||||
first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||
if err != nil {
|
||||
return strings.TrimSpace(string(first)), err
|
||||
}
|
||||
second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||
return strings.TrimSpace(string(first) + "\n" + string(second)), err
|
||||
case "nftables", "iptables":
|
||||
return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则")
|
||||
default:
|
||||
return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUFWStatus(t *testing.T) {
|
||||
output := `Status: active
|
||||
|
||||
To Action From
|
||||
-- ------ ----
|
||||
22/tcp ALLOW Anywhere
|
||||
7443/tcp ALLOW Anywhere
|
||||
`
|
||||
active, open := parseUFWStatus(output, 7443)
|
||||
if !active || !open {
|
||||
t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open)
|
||||
}
|
||||
_, open = parseUFWStatus(output, 9443)
|
||||
if open {
|
||||
t.Fatal("未放行的端口不应判定为已放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUFWStatusInactive(t *testing.T) {
|
||||
active, open := parseUFWStatus("Status: inactive\n", 7443)
|
||||
if active || open {
|
||||
t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateHTTPSPorts(t *testing.T) {
|
||||
if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 {
|
||||
t.Fatalf("显式端口应只返回该端口:%v", got)
|
||||
}
|
||||
got := candidateHTTPSPorts(0)
|
||||
if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 {
|
||||
t.Fatalf("自动模式应返回 7443/8443/9443:%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
const (
|
||||
// metricsInterval 两次 CPU 采样的最小间隔:间隔内重复调用直接返回上次结果,避免数值抖动
|
||||
metricsInterval = time.Second
|
||||
// maxLogLineBytes 单行日志截断长度,避免一行超长把响应撑大
|
||||
maxLogLineBytes = 4 << 10
|
||||
// maxLogLines 日志尾部行数上限
|
||||
maxLogLines = 2000
|
||||
// maxFRPSConfigBytes frps.toml 回读上限
|
||||
maxFRPSConfigBytes = 256 << 10
|
||||
)
|
||||
|
||||
// metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)。
|
||||
// 复用 internal/sysinfo:agent 跑在 VPS 上,采到的天然是 VPS 自身数值。
|
||||
func (s *Server) metrics(w http.ResponseWriter, _ *http.Request) {
|
||||
s.metricsMu.Lock()
|
||||
defer s.metricsMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if s.metricsCached != nil && now.Sub(s.metricsAt) < metricsInterval {
|
||||
writeJSON(w, http.StatusOK, s.metricsCached)
|
||||
return
|
||||
}
|
||||
|
||||
stats, sample := sysinfo.Read(s.metricsSample)
|
||||
s.metricsSample = sample
|
||||
payload := map[string]any{
|
||||
"cpu_percent": math.Round(stats.CPUPercent*10) / 10,
|
||||
"mem_used_bytes": stats.MemUsedBytes,
|
||||
"mem_total_bytes": stats.MemTotalBytes,
|
||||
"disk_used_bytes": stats.DiskUsedBytes,
|
||||
"disk_total_bytes": stats.DiskTotalBytes,
|
||||
// 当前只统计根文件系统(与 internal/sysinfo 的 Linux 实现一致)
|
||||
"disk_path": "/",
|
||||
"checked_at": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if load := sysinfo.LoadAvg(); len(load) == 3 {
|
||||
payload["load_avg"] = load
|
||||
}
|
||||
if uptime := sysinfo.UptimeSeconds(); uptime > 0 {
|
||||
payload["uptime_seconds"] = uptime
|
||||
}
|
||||
|
||||
s.metricsAt = now
|
||||
s.metricsCached = payload
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// nginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部。
|
||||
// 路径不接受请求参数:只从 `nginx -T` 的实际生效配置、默认目录或环境变量目录里取,避免变成任意文件读取。
|
||||
func (s *Server) nginxLogs(w http.ResponseWriter, r *http.Request) {
|
||||
kind := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
if kind != "access" && kind != "error" {
|
||||
kind = "access"
|
||||
}
|
||||
lines := maxLogLines
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
lines = parsed
|
||||
}
|
||||
}
|
||||
if lines <= 0 || lines > maxLogLines {
|
||||
lines = maxLogLines
|
||||
}
|
||||
|
||||
path := s.nginxLogPath(kind)
|
||||
if path == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": kind, "path": "", "lines": []string{}, "truncated": false,
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content, truncated, err := tailFile(path, lines, maxLogLineBytes)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 文件不存在不是错误:返回空列表 + 路径,由前端提示「未找到日志文件」
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": kind, "path": path, "lines": []string{}, "truncated": false,
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if os.IsPermission(err) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取日志失败:%v", err), "path": path})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": kind, "path": path, "lines": content, "truncated": truncated,
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// nginxLogPath 解析日志文件路径:优先 `nginx -T` 实际生效配置里的指令,其次环境变量目录,最后默认目录
|
||||
func (s *Server) nginxLogPath(kind string) string {
|
||||
directive := "access_log"
|
||||
if kind == "error" {
|
||||
directive = "error_log"
|
||||
}
|
||||
if nginx := s.nginxPath(); nginx != "" {
|
||||
if out, err := exec.Command(nginx, "-T").CombinedOutput(); err == nil {
|
||||
if path := logPathFromDump(string(out), directive); path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := strings.TrimSpace(os.Getenv("HAVLINE_AGENT_NGINX_LOG_DIR"))
|
||||
if dir == "" {
|
||||
dir = "/var/log/nginx"
|
||||
}
|
||||
return filepath.Join(dir, kind+".log")
|
||||
}
|
||||
|
||||
// logPathFromDump 从 `nginx -T` 输出里取第一条绝对路径的日志指令(跳过 off / stderr / 含变量的写法)
|
||||
func logPathFromDump(dump, directive string) string {
|
||||
pattern := regexp.MustCompile(`(?m)^\s*` + directive + `\s+([^;\s]+)`)
|
||||
for _, match := range pattern.FindAllStringSubmatch(dump, -1) {
|
||||
candidate := strings.TrimSpace(match[1])
|
||||
if candidate == "" || candidate == "off" || candidate == "stderr" || strings.Contains(candidate, "$") {
|
||||
continue
|
||||
}
|
||||
// 只接受绝对路径:agent 只跑在 Linux 上,用前导斜杠判定(filepath.IsAbs 在 Windows 上对 /var/... 返回 false)
|
||||
if strings.HasPrefix(candidate, "/") {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tailFile 从文件末尾倒读最多 lines 行:按块回溯,避免把整个日志读进内存;单行超长时截断
|
||||
func tailFile(path string, lines int, maxLine int) ([]string, bool, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
const chunkSize = 32 * 1024
|
||||
var (
|
||||
offset int64 = info.Size()
|
||||
buffer []byte
|
||||
)
|
||||
for offset > 0 && bytes.Count(buffer, []byte("\n")) <= lines {
|
||||
readSize := int64(chunkSize)
|
||||
if offset < readSize {
|
||||
readSize = offset
|
||||
}
|
||||
offset -= readSize
|
||||
block := make([]byte, readSize)
|
||||
if _, err := file.ReadAt(block, offset); err != nil && err != io.EOF {
|
||||
return nil, false, err
|
||||
}
|
||||
buffer = append(block, buffer...)
|
||||
}
|
||||
|
||||
rows := strings.Split(strings.TrimRight(string(buffer), "\n"), "\n")
|
||||
truncated := false
|
||||
if len(rows) > lines {
|
||||
rows = rows[len(rows)-lines:]
|
||||
truncated = true
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(row) > maxLine {
|
||||
row = row[:maxLine] + "…"
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, truncated, nil
|
||||
}
|
||||
|
||||
// vpsCertificate VPS 证书目录里的一张证书
|
||||
// 注意:证书按 <CertsDir>/<域名>/fullchain.pem + privkey.pem 存放(与 putCertificate 一致)。
|
||||
type vpsCertificate struct {
|
||||
Domain string `json:"domain"`
|
||||
CertPath string `json:"cert_path"`
|
||||
KeyPath string `json:"key_path"`
|
||||
NotAfter string `json:"not_after,omitempty"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
}
|
||||
|
||||
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Havline 证书库交叉核对,也能看到宝塔自行申请的证书
|
||||
func (s *Server) certs(w http.ResponseWriter, _ *http.Request) {
|
||||
dir := strings.TrimSpace(s.cfg.CertsDir)
|
||||
payload := map[string]any{
|
||||
"certs": []vpsCertificate{}, "warnings": []string{}, "dir": dir,
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if dir == "" {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if os.IsPermission(err) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": fmt.Sprintf("读取证书目录失败:%v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
certs := make([]vpsCertificate, 0, len(entries))
|
||||
warnings := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
domain := entry.Name()
|
||||
item := vpsCertificate{
|
||||
Domain: domain,
|
||||
CertPath: filepath.Join(dir, domain, "fullchain.pem"),
|
||||
KeyPath: filepath.Join(dir, domain, "privkey.pem"),
|
||||
}
|
||||
notAfter, err := readCertificateNotAfter(item.CertPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s:%v", domain, err))
|
||||
} else {
|
||||
item.NotAfter = notAfter.UTC().Format(time.RFC3339)
|
||||
item.DaysLeft = int(notAfter.Sub(now).Hours() / 24)
|
||||
}
|
||||
certs = append(certs, item)
|
||||
}
|
||||
// 到期最快的排在前面,便于直接看出哪张该续了
|
||||
sort.Slice(certs, func(i, j int) bool { return certs[i].DaysLeft < certs[j].DaysLeft })
|
||||
|
||||
payload["certs"] = certs
|
||||
payload["warnings"] = warnings
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// readCertificateNotAfter 解析 PEM 证书的到期时间
|
||||
func readCertificateNotAfter(path string) (time.Time, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return time.Time{}, fmt.Errorf("不是有效的 PEM 证书")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return cert.NotAfter, nil
|
||||
}
|
||||
|
||||
// nginxReload 显式校验并重载 Nginx,把校验/重载失败的原因回传。
|
||||
// 这里不加 s.mu:validateNginx / reloadNginx 的调用方约定(见 writeRoute 注释)是外部持锁,
|
||||
// 而本端点是用户显式触发的排查动作,不加锁可避免与路由写入相互阻塞。
|
||||
func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
|
||||
payload := map[string]any{"checked_at": time.Now().UTC().Format(time.RFC3339)}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
payload["ok"] = false
|
||||
payload["test_ok"] = false
|
||||
payload["test_error"] = err.Error()
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
payload["test_ok"] = true
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
payload["ok"] = false
|
||||
payload["reload_ok"] = false
|
||||
payload["reload_error"] = err.Error()
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
// 重载成功后让 Nginx 可用性探测的缓存失效,避免页面继续显示旧结论
|
||||
s.invalidateNginxProbe()
|
||||
payload["ok"] = true
|
||||
payload["reload_ok"] = true
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
||||
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
||||
payload := map[string]any{
|
||||
"path": path, "exists": false, "content": "", "truncated": false,
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if path == "" {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if os.IsPermission(err) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": fmt.Sprintf("读取 frps 配置失败:%v", err), "path": path})
|
||||
return
|
||||
}
|
||||
if len(data) > maxFRPSConfigBytes {
|
||||
data = data[:maxFRPSConfigBytes]
|
||||
payload["truncated"] = true
|
||||
}
|
||||
payload["exists"] = true
|
||||
payload["content"] = string(data)
|
||||
payload["mod_time"] = info.ModTime().UTC().Format(time.RFC3339)
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "access.log")
|
||||
if err := os.WriteFile(path, []byte("line1\nline2\nline3\nline4\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
rows, truncated, err := tailFile(path, 2, maxLogLineBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("tailFile: %v", err)
|
||||
}
|
||||
// 文件里还有更早的行没返回,因此应当标记为截断
|
||||
if !truncated {
|
||||
t.Fatal("只取两行时应标记为截断(还有更早的日志未返回)")
|
||||
}
|
||||
if len(rows) != 2 || rows[0] != "line3" || rows[1] != "line4" {
|
||||
t.Fatalf("unexpected rows: %#v", rows)
|
||||
}
|
||||
|
||||
// 请求行数超过文件内容:返回全部且不标记截断
|
||||
rows, truncated, err = tailFile(path, 100, maxLogLineBytes)
|
||||
if err != nil || truncated || len(rows) != 4 {
|
||||
t.Fatalf("unexpected full read: rows=%d truncated=%v err=%v", len(rows), truncated, err)
|
||||
}
|
||||
|
||||
// 单行超长时截断
|
||||
long := strings.Repeat("x", maxLogLineBytes+10)
|
||||
if err := os.WriteFile(path, []byte(long+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
rows, _, err = tailFile(path, 5, maxLogLineBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("tailFile: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || !strings.HasSuffix(rows[0], "…") || len(rows[0]) > maxLogLineBytes+3 {
|
||||
t.Fatalf("超长行未被截断:len=%d", len(rows[0]))
|
||||
}
|
||||
|
||||
// 文件不存在返回错误,由调用方转成「200 + 空列表」
|
||||
if _, _, err := tailFile(filepath.Join(t.TempDir(), "missing.log"), 10, maxLogLineBytes); err == nil {
|
||||
t.Fatal("文件不存在应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogPathFromDump(t *testing.T) {
|
||||
dump := "# configuration file /etc/nginx/nginx.conf:\naccess_log /var/log/nginx/access.log main;\nerror_log /var/log/nginx/error.log warn;\n"
|
||||
if got := logPathFromDump(dump, "access_log"); got != "/var/log/nginx/access.log" {
|
||||
t.Fatalf("unexpected access log: %q", got)
|
||||
}
|
||||
if got := logPathFromDump(dump, "error_log"); got != "/var/log/nginx/error.log" {
|
||||
t.Fatalf("unexpected error log: %q", got)
|
||||
}
|
||||
|
||||
// off / stderr / 相对路径 / 含变量 都不采用
|
||||
skip := "access_log off;\naccess_log stderr;\naccess_log logs/access.log;\naccess_log /var/log/nginx/$host.log;\n"
|
||||
if got := logPathFromDump(skip, "access_log"); got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLimitStateFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
missing, err := readLimitStateFile(filepath.Join(dir, "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("文件不存在应返回空状态: %v", err)
|
||||
}
|
||||
if len(missing) != 0 {
|
||||
t.Fatalf("文件不存在应返回空 map,实际 %#v", missing)
|
||||
}
|
||||
|
||||
validPath := filepath.Join(dir, "valid.json")
|
||||
valid := []byte(`{"a.example.com":{"rate":10,"burst":20,"conn":5}}`)
|
||||
if err := os.WriteFile(validPath, valid, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := readLimitStateFile(validPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := state["a.example.com"]
|
||||
if got.Rate != 10 || got.Burst != 20 || got.Conn != 5 {
|
||||
t.Fatalf("解析结果不正确: %#v", got)
|
||||
}
|
||||
|
||||
brokenPath := filepath.Join(dir, "broken.json")
|
||||
broken := []byte(`{"a.example.com":`)
|
||||
if err := os.WriteFile(brokenPath, broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readLimitStateFile(brokenPath); err == nil {
|
||||
t.Fatal("损坏 JSON 应返回错误")
|
||||
}
|
||||
after, err := os.ReadFile(brokenPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(broken) {
|
||||
t.Fatalf("读取失败不应修改原文件: %q", after)
|
||||
}
|
||||
|
||||
emptyPath := filepath.Join(dir, "empty.json")
|
||||
if err := os.WriteFile(emptyPath, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readLimitStateFile(emptyPath); err == nil {
|
||||
t.Fatal("空文件应返回错误")
|
||||
}
|
||||
|
||||
nullPath := filepath.Join(dir, "null.json")
|
||||
if err := os.WriteFile(nullPath, []byte("null\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readLimitStateFile(nullPath); err == nil {
|
||||
t.Fatal("null 内容应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateListenAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr string
|
||||
allowRemote bool
|
||||
loopback bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"127.0.0.1:7700", false, true, false},
|
||||
{"localhost:7700", false, true, false},
|
||||
{"[::1]:7700", false, true, false},
|
||||
{":7700", false, false, true},
|
||||
{"0.0.0.0:7700", false, false, true},
|
||||
{"0.0.0.0:7700", true, false, false},
|
||||
{"bad-address", false, false, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
loopback, err := validateListenAddr(tc.addr, tc.allowRemote)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("%s 应拒绝", tc.addr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s 不应报错: %v", tc.addr, err)
|
||||
}
|
||||
if loopback != tc.loopback {
|
||||
t.Fatalf("%s loopback=%v, want %v", tc.addr, loopback, tc.loopback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRoute(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req routeRequest
|
||||
ok bool
|
||||
}{
|
||||
{"正常", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, true},
|
||||
{"非法域名", routeRequest{Domain: "bad domain", Upstream: "127.0.0.1:8080"}, false},
|
||||
{"空上游", routeRequest{Domain: "a.example.com"}, false},
|
||||
{"上游带分号", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80; rm -rf /"}, false},
|
||||
{"相对证书目录", routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:80", TLS: true, CertDir: "certs"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateRoute(c.req)
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("%s:期望通过,实际 %v", c.name, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("%s:期望报错", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。
|
||||
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/havline-agent 写 htpasswd,不适合在单测里触发。
|
||||
func TestNginxRouteRendersUpstream(t *testing.T) {
|
||||
out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "")
|
||||
if !strings.HasPrefix(out, "# Managed by havline-agent.") {
|
||||
t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server_name a.example.com;") {
|
||||
t.Errorf("缺少 server_name:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080;") {
|
||||
t.Errorf("缺少 proxy_pass 回源:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "listen 443") {
|
||||
t.Errorf("未启用 TLS 时不应监听 443:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNginxRouteSharedTemplateSemantics 锁定共享模板迁出后的关键语义与顺序。
|
||||
func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
|
||||
out := nginxRoute(routeRequest{
|
||||
Domain: "a.example.com",
|
||||
Upstream: "127.0.0.1:8080",
|
||||
TLS: true,
|
||||
RedirectTLS: true,
|
||||
SecurityHeaders: true,
|
||||
WebSocket: true,
|
||||
AllowIPs: []string{"192.168.1.0/24"},
|
||||
DenyIPs: []string{"8.8.8.8"},
|
||||
RateLimitRate: 10,
|
||||
RateLimitBurst: 20,
|
||||
ConnLimitMax: 5,
|
||||
ChinaOnly: true,
|
||||
}, "/etc/havline-agent/certs/a.example.com")
|
||||
for _, want := range []string{
|
||||
"ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
`add_header Referrer-Policy "strict-origin-when-cross-origin" always;`,
|
||||
"deny 8.8.8.8;",
|
||||
"if ($havline_china_client = 0) { return 403; }",
|
||||
"allow 192.168.1.0/24;",
|
||||
"deny all;",
|
||||
"limit_conn havline_a_example_com_conn 5;",
|
||||
"limit_req zone=havline_a_example_com_req burst=20 nodelay;",
|
||||
"proxy_set_header Upgrade $http_upgrade;",
|
||||
`proxy_set_header Connection "upgrade";`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("缺少 %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
denyIdx := strings.Index(out, "deny 8.8.8.8;")
|
||||
allowIdx := strings.Index(out, "allow 192.168.1.0/24;")
|
||||
denyAllIdx := strings.Index(out, "deny all;")
|
||||
if denyIdx < 0 || allowIdx < 0 || denyAllIdx < 0 || !(denyIdx < allowIdx && allowIdx < denyAllIdx) {
|
||||
t.Fatalf("allow / deny 顺序不正确:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSHA256 覆盖 frps 安装包的校验路径。
|
||||
func TestFileSHA256(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := fileSHA256(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
if got != want {
|
||||
t.Fatalf("sha256 不符:got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var agentRouteParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||
|
||||
// TestAgentRoutesRequireToken 遍历全部 Agent API:匿名请求必须 401,防止新增路由漏挂 auth。
|
||||
func TestAgentRoutesRequireToken(t *testing.T) {
|
||||
server := &Server{cfg: Config{Token: "secret-token"}}
|
||||
routes := agentRoutes(server)
|
||||
if len(routes) < 20 {
|
||||
t.Fatalf("Agent 路由覆盖不足:%d 条", len(routes))
|
||||
}
|
||||
handler := server.Handler()
|
||||
for _, route := range routes {
|
||||
path := agentRouteParamPattern.ReplaceAllString(route.Pattern, "1")
|
||||
req := httptest.NewRequest(route.Method, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("路由 %s %s 匿名请求应返回 401,实际 %d", route.Method, route.Pattern, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/nginxtmpl"
|
||||
)
|
||||
|
||||
const (
|
||||
httpsProxyConfigName = "havlineagent-https.conf"
|
||||
httpsProxyCertPath = "/etc/havline-agent/tls/agent.crt"
|
||||
httpsProxyKeyPath = "/etc/havline-agent/tls/agent.key"
|
||||
)
|
||||
|
||||
type httpsProxyRequest struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type httpsProxyState struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req httpsProxyRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Port < 0 || req.Port > 65535 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
|
||||
return
|
||||
}
|
||||
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "证书或私钥格式无效"})
|
||||
return
|
||||
}
|
||||
if probe := s.probeNginx(); probe.Missing {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx,无法启用 HTTPS 直连"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
port, err := s.configureHTTPSProxy(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.removeHTTPSProxy(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。
|
||||
func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, enabled := readHTTPSProxyState(s.cfg.DataDir)
|
||||
notAfter := ""
|
||||
if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil {
|
||||
notAfter = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": enabled,
|
||||
"port": state.Port,
|
||||
"not_after": notAfter,
|
||||
})
|
||||
}
|
||||
|
||||
// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。
|
||||
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) {
|
||||
var lastErr error
|
||||
for _, port := range candidateHTTPSPorts(req.Port) {
|
||||
if err := s.applyHTTPSProxy(req, port); err != nil {
|
||||
lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err)
|
||||
continue
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("没有可用的 HTTPS 端口")
|
||||
}
|
||||
return 0, lastErr
|
||||
}
|
||||
|
||||
func candidateHTTPSPorts(requested int) []int {
|
||||
if requested > 0 {
|
||||
return []int{requested}
|
||||
}
|
||||
return []int{7443, 8443, 9443}
|
||||
}
|
||||
|
||||
func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
|
||||
dir, err := s.ensureNginxDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsDir := "/etc/havline-agent/tls"
|
||||
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, httpsProxyConfigName)
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
|
||||
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return fmt.Errorf("nginx config rejected: %w", err)
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
pin, err := certificatePin(req.CertPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.verifyHTTPSProxy(port, pin); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
|
||||
}
|
||||
|
||||
func (s *Server) removeHTTPSProxy() error {
|
||||
dir, err := s.ensureNginxDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, httpsProxyConfigName)
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if errors.Is(readErr, os.ErrNotExist) {
|
||||
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreHTTPSConfig(path string, previous []byte, readErr error) {
|
||||
if readErr == nil {
|
||||
_ = atomicWrite(path, previous, 0o640)
|
||||
} else {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
_ = s.reloadNginx()
|
||||
}
|
||||
|
||||
func (s *Server) ensureNginxDir() (string, error) {
|
||||
dir := strings.TrimSpace(s.cfg.NginxConfDir)
|
||||
if dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
dir, err := detectNginxConfDir(s.nginxPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cfg.NginxConfDir = dir
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
||||
return fmt.Sprintf(`# Managed by havline-agent. Do not edit.
|
||||
server {
|
||||
listen %d ssl default_server;
|
||||
server_name _;
|
||||
%s
|
||||
|
||||
location /api/v1/ {
|
||||
proxy_pass http://127.0.0.1:7700;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
}
|
||||
`, port, nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{CertPath: certPath, KeyPath: keyPath}))
|
||||
}
|
||||
|
||||
func certificatePin(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return "", errors.New("证书 PEM 解码失败")
|
||||
}
|
||||
sum := sha256.Sum256(block.Bytes)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// verifyHTTPSProxy 在 reload 后从本机回环地址握手,确认 7443 上真正返回的就是刚写入的证书。
|
||||
// nginx reload 是异步的,这里留出短暂重试窗口,避免旧 worker 造成的瞬时误判。
|
||||
func (s *Server) verifyHTTPSProxy(port int, want string) error {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var lastErr error
|
||||
for {
|
||||
lastErr = probeHTTPSPin(port, want)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return lastErr
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func probeHTTPSPin(port int, want string) error {
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return fmt.Errorf("本机 https://%s 自检失败:%w;%s", addr, err, portListenerHint(port))
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return fmt.Errorf("本机 https://%s 未返回证书;%s", addr, portListenerHint(port))
|
||||
}
|
||||
sum := sha256.Sum256(certs[0].Raw)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if !strings.EqualFold(got, want) {
|
||||
return fmt.Errorf("本机 https://%s 证书指纹不匹配:期望 %s,实际 %s;%s", addr, want, got, portListenerHint(port))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// portListenerHint 把监听该端口的进程信息带进错误里,便于区分 nginx 未生效与端口被其他服务占用。
|
||||
func portListenerHint(port int) string {
|
||||
out, err := exec.Command("ss", "-tlnp").CombinedOutput()
|
||||
if err != nil {
|
||||
return "无法读取端口监听信息"
|
||||
}
|
||||
suffix := ":" + strconv.Itoa(port)
|
||||
lines := make([]string, 0, 2)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.Contains(line, suffix+" ") || strings.HasSuffix(strings.TrimSpace(line), suffix) {
|
||||
lines = append(lines, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "未发现监听 " + suffix + " 的进程"
|
||||
}
|
||||
return "监听 " + suffix + ":" + strings.Join(lines, " | ")
|
||||
}
|
||||
|
||||
func httpsProxyStatePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "https_transport.json")
|
||||
}
|
||||
|
||||
func writeHTTPSProxyState(dataDir string, state httpsProxyState) error {
|
||||
if strings.TrimSpace(dataDir) == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(httpsProxyStatePath(dataDir), data, 0o640)
|
||||
}
|
||||
|
||||
func readHTTPSProxyState(dataDir string) (httpsProxyState, bool) {
|
||||
data, err := os.ReadFile(httpsProxyStatePath(dataDir))
|
||||
if err != nil {
|
||||
return httpsProxyState{}, false
|
||||
}
|
||||
var state httpsProxyState
|
||||
if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 {
|
||||
return httpsProxyState{}, false
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
)
|
||||
|
||||
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
|
||||
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
|
||||
const maxRouteVersions = 10
|
||||
|
||||
type routeVersionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// routeVersions 返回某域名的当前配置与历史版本,供应用侧把「漂移」与「历史」放在一起看。
|
||||
func (s *Server) routeVersions(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.routePath(domain)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
|
||||
return
|
||||
}
|
||||
versions, err := fsutil.ListVersions(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"domain": domain,
|
||||
"content": string(content),
|
||||
"versions": mapRouteVersions(versions),
|
||||
})
|
||||
}
|
||||
|
||||
// routeVersion 返回某个历史版本的内容(前端拿它与当前配置做差异对比)。
|
||||
func (s *Server) routeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
content, err := fsutil.ReadVersion(s.routePath(domain), name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "name": name, "content": string(content)})
|
||||
}
|
||||
|
||||
// rollbackRoute 把某域名的 vhost 回退到指定版本。
|
||||
func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.routePath(domain)
|
||||
content, err := fsutil.ReadVersion(path, name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.applyRouteContent(path, content); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.reloadRouteConfig(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain, "name": name})
|
||||
}
|
||||
|
||||
// applyRouteContent 写入 vhost 内容并做 nginx -t 校验。
|
||||
//
|
||||
// 写之前先把当前内容留成一个版本;校验失败时恢复回上一个可用版本(此前没有文件才删除)。
|
||||
// 旧行为是校验失败直接删文件——那等于把已经在跑的站点从磁盘上抹掉,只能靠重新部署救回来。
|
||||
func (s *Server) applyRouteContent(path string, content []byte) error {
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(path, content, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateRouteConfig(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = atomicWrite(path, previous, 0o640)
|
||||
} else {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return fmt.Errorf("nginx config rejected: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapRouteVersions(entries []fsutil.VersionEntry) []routeVersionInfo {
|
||||
out := make([]routeVersionInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, routeVersionInfo{Name: entry.Name, Size: entry.Size, CreatedAt: entry.CreatedAt})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "havlineagent-https.conf")
|
||||
legacy := path + ".versions"
|
||||
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "20260101T000000.000-abcd.conf"
|
||||
if err := os.WriteFile(filepath.Join(legacy, name), []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
migrateLegacyVersionDirs(dir)
|
||||
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".havlineagent-https.conf.versions"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "havlineagent-https.conf.versions")
|
||||
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := "2026/09/28 14:38:16 [crit] 6053#6053: pread() \"" + legacy + "\" failed (21: Is a directory)"
|
||||
if !migrateVersionDirsFromNginxOutput(output) {
|
||||
t.Fatal("应从 nginx 报错文本中识别并迁移旧版本目录")
|
||||
}
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧版本目录应被移出 include 范围:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificatePin(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
got, err := certificatePin(string(block))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
if want := hex.EncodeToString(sum[:]); got != want {
|
||||
t.Fatalf("certificatePin() = %s, 期望 %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "havline-a.example.com.conf")
|
||||
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{cfg: Config{NginxConfDir: dir}, validateNginxFn: func() error { return nil }}
|
||||
if err := server.applyRouteContent(path, []byte("new config")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil || string(current) != "new config" {
|
||||
t.Fatalf("当前配置未更新:%q %v", current, err)
|
||||
}
|
||||
versions, err := fsutil.ListVersions(path)
|
||||
if err != nil || len(versions) != 1 {
|
||||
t.Fatalf("写 vhost 前应保存上一版本:%v %v", versions, err)
|
||||
}
|
||||
previous, err := fsutil.ReadVersion(path, versions[0].Name)
|
||||
if err != nil || string(previous) != "old config" {
|
||||
t.Fatalf("历史版本内容不符:%q %v", previous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "havline-a.example.com.conf")
|
||||
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{
|
||||
cfg: Config{NginxConfDir: dir},
|
||||
validateNginxFn: func() error { return errors.New("nginx rejected") },
|
||||
}
|
||||
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
|
||||
t.Fatal("校验失败应返回错误")
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil || string(current) != "old config" {
|
||||
t.Fatalf("校验失败后应恢复上一可用版本:%q %v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "havline-new.example.com.conf")
|
||||
server := &Server{
|
||||
cfg: Config{NginxConfDir: dir},
|
||||
validateNginxFn: func() error { return errors.New("nginx rejected") },
|
||||
}
|
||||
if err := server.applyRouteContent(path, []byte("new config")); err == nil {
|
||||
t.Fatal("校验失败应返回错误")
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("首次部署失败应删除新文件,避免留下坏配置:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackRouteReloadsAfterApply(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reloaded := false
|
||||
server := &Server{
|
||||
cfg: Config{NginxConfDir: dir},
|
||||
validateNginxFn: func() error { return nil },
|
||||
reloadNginxFn: func() error { reloaded = true; return nil },
|
||||
}
|
||||
path := server.routePath("a.example.com")
|
||||
if err := os.WriteFile(path, []byte("healthy"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions, err := fsutil.ListVersions(path)
|
||||
if err != nil || len(versions) != 1 {
|
||||
t.Fatalf("准备版本失败:%v %v", versions, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("broken"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", server.rollbackRoute)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/routes/a.example.com/versions/"+versions[0].Name+"/rollback", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("回滚应成功:%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !reloaded {
|
||||
t.Fatal("回滚后应执行 nginx reload")
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil || string(current) != "healthy" {
|
||||
t.Fatalf("回滚后内容不符:%q %v", current, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user