feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+101
View File
@@ -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()
}
+58
View File
@@ -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)
}
}
+54
View File
@@ -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 不应通过")
}
}
+127
View File
@@ -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)")
}
}
+38
View File
@@ -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)
}
}
+345
View File
@@ -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)
}
+67
View File
@@ -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)
}
}
+65
View File
@@ -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 内容应返回错误")
}
}
+35
View File
@@ -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)
}
}
}
+105
View File
@@ -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
+317
View File
@@ -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
}
+127
View File
@@ -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
}
+182
View File
@@ -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)
}
}