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
+111
View File
@@ -0,0 +1,111 @@
package acme
import (
"fmt"
"strings"
"github.com/go-acme/lego/v4/lego"
)
const (
CALetsEncrypt = "letsencrypt"
CALetsEncryptStaging = "letsencrypt-staging"
CAZeroSSL = "zerossl"
CABuypass = "buypass"
// CALiteSSL 亚数 TrustAsia 的公益免费证书(90 天,支持单域名/通配符/多域名,强制 EAB)
CALiteSSL = "litessl"
)
// caRetired 记录已停止签发的 CA:仍然认识这些值(历史证书记录里可能存着,用于展示与清理),
// 但不允许再用来申请——否则用户只会看到一个难懂的 ACME 报错而不是「这家 CA 不干了」。
var caRetired = map[string]string{
CABuypass: "Buypass 已于 2025-10-15 停止签发证书(ACME 服务 2026-04-15 终止),请改用 Let's Encrypt、LiteSSL 或 ZeroSSL",
}
// CAUnavailableReason 返回该 CA 已停用的原因;可用的 CA 返回空字符串。
func CAUnavailableReason(ca string) string {
return caRetired[NormalizeCA(ca)]
}
const (
zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
buypassDirectoryURL = "https://api.buypass.com/acme/directory"
// liteSSLDirectoryURL 实测 2026-09-27 可访问,目录里 meta.externalAccountRequired = true
liteSSLDirectoryURL = "https://acme.litessl.com/acme/v2/directory"
)
func NormalizeCA(ca string) string {
switch ca {
case "", CALetsEncrypt, "production":
return CALetsEncrypt
case CALetsEncryptStaging, "staging", "letsencrypt-test":
return CALetsEncryptStaging
case CAZeroSSL:
return CAZeroSSL
case CABuypass, "buypass-go":
return CABuypass
case CALiteSSL, "trustasia":
return CALiteSSL
default:
return ca
}
}
func DirectoryURL(ca string) (string, error) {
switch NormalizeCA(ca) {
case CALetsEncrypt:
return lego.LEDirectoryProduction, nil
case CALetsEncryptStaging:
return lego.LEDirectoryStaging, nil
case CAZeroSSL:
return zeroSSLDirectoryURL, nil
case CABuypass:
return buypassDirectoryURL, nil
case CALiteSSL:
return liteSSLDirectoryURL, nil
default:
return "", fmt.Errorf("不支持的颁发机构:%s", ca)
}
}
func CALabel(ca string) string {
switch NormalizeCA(ca) {
case CALetsEncrypt:
return "Let's Encrypt"
case CALetsEncryptStaging:
return "Let's Encrypt 测试"
case CAZeroSSL:
return "ZeroSSL"
case CABuypass:
return "Buypass"
case CALiteSSL:
return "LiteSSL"
default:
if ca == "" {
return "手动导入"
}
return ca
}
}
func ValidateCA(ca string) error {
_, err := DirectoryURL(ca)
return err
}
// ValidateCADomains 校验各 CA 自己的限制。
// LiteSSL 官方支持单域名 / 通配符 / 多域名,因此这里不额外限制。
func ValidateCADomains(ca string, domains []string) error {
switch NormalizeCA(ca) {
case CABuypass:
if len(domains) > 5 {
return fmt.Errorf("Buypass 单张证书最多支持 5 个域名")
}
for _, domain := range domains {
if strings.HasPrefix(domain, "*.") {
return fmt.Errorf("Buypass 不支持通配符证书")
}
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package acme
import "testing"
func TestDirectoryURL(t *testing.T) {
url, err := DirectoryURL(CALetsEncrypt)
if err != nil || url == "" {
t.Fatalf("production: %v", err)
}
url, err = DirectoryURL(CALetsEncryptStaging)
if err != nil || url == "" {
t.Fatalf("staging: %v", err)
}
url, err = DirectoryURL(CAZeroSSL)
if err != nil || url == "" {
t.Fatalf("zerossl: %v", err)
}
url, err = DirectoryURL(CABuypass)
if err != nil || url == "" {
t.Fatalf("buypass: %v", err)
}
if err := ValidateCA("unknown"); err == nil {
t.Fatal("expected error for unknown ca")
}
}
func TestValidateCADomainsBuypass(t *testing.T) {
if err := ValidateCADomains(CABuypass, []string{"*.example.com"}); err == nil {
t.Fatal("expected wildcard error")
}
if err := ValidateCADomains(CABuypass, []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}); err == nil {
t.Fatal("expected domain limit error")
}
if err := ValidateCADomains(CALetsEncrypt, []string{"*.example.com"}); err != nil {
t.Fatalf("letsencrypt wildcard should be allowed: %v", err)
}
}
+98
View File
@@ -0,0 +1,98 @@
package acme
import (
"fmt"
"strings"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/providers/dns/alidns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/providers/dns/dnspod"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
"github.com/havline/havline/internal/ddns"
)
func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
switch provider {
case "dnspod":
cfg := dnspod.NewDefaultConfig()
cfg.LoginToken = cred.LoginToken()
return dnspod.NewDNSProviderConfig(cfg)
case "alidns":
cfg := alidns.NewDefaultConfig()
cfg.APIKey = cred.Token
cfg.SecretKey = cred.Secret
return alidns.NewDNSProviderConfig(cfg)
case "tencentcloud":
cfg := tencentcloud.NewDefaultConfig()
cfg.SecretID = cred.Token
cfg.SecretKey = cred.Secret
return tencentcloud.NewDNSProviderConfig(cfg)
case "volcengine":
return newVolcengineDNSProvider(cred)
default:
cfg := cloudflare.NewDefaultConfig()
cfg.AuthToken = cred.Token
return cloudflare.NewDNSProviderConfig(cfg)
}
}
func dnsProviderName(provider string) string {
switch provider {
case "dnspod", "alidns", "tencentcloud", "volcengine":
return provider
default:
return "cloudflare"
}
}
type loggingDNSProvider struct {
inner challenge.Provider
job *Job
}
func wrapDNSProvider(inner challenge.Provider, job *Job) challenge.Provider {
if job == nil {
return inner
}
return &loggingDNSProvider{inner: inner, job: job}
}
func (p *loggingDNSProvider) Present(domain, token, keyAuth string) error {
record := strings.TrimSuffix(domain, ".")
p.job.Info(fmt.Sprintf("添加 DNS TXT 记录: _acme-challenge.%s", record))
if err := p.inner.Present(domain, token, keyAuth); err != nil {
p.job.Error(fmt.Sprintf("添加 DNS TXT 记录失败: %v", err))
return err
}
p.job.Info("DNS TXT 记录已添加,等待 DNS 传播…")
return nil
}
func (p *loggingDNSProvider) CleanUp(domain, token, keyAuth string) error {
p.job.Info("清理 DNS TXT 验证记录…")
return p.inner.CleanUp(domain, token, keyAuth)
}
func validateDNSCredentials(provider string, cred ddns.Credentials) error {
if err := cred.Validate(dnsProviderName(provider)); err != nil {
return err
}
if provider == "dnspod" && cred.TokenID == "" {
return fmt.Errorf("请先在 DDNS 页面配置 DNSPod ID 和 Token")
}
if provider == "alidns" && cred.Secret == "" {
return fmt.Errorf("请先在 DDNS 页面配置阿里云 AccessKey")
}
if provider == "tencentcloud" && cred.Secret == "" {
return fmt.Errorf("请先在 DDNS 页面配置腾讯云 SecretKey")
}
if provider == "volcengine" && cred.Secret == "" {
return fmt.Errorf("请先在 DDNS 页面配置火山引擎 Secret Access Key")
}
if provider != "dnspod" && provider != "alidns" && provider != "tencentcloud" && provider != "volcengine" && cred.Token == "" {
return fmt.Errorf("请先在 DDNS 页面配置 Cloudflare API Token")
}
return nil
}
@@ -0,0 +1,23 @@
package acme
import (
"testing"
"github.com/havline/havline/internal/ddns"
)
func TestValidateDNSCredentialsVolcengine(t *testing.T) {
if err := validateDNSCredentials("volcengine", ddns.Credentials{Token: "ak", Secret: "sk"}); err != nil {
t.Fatalf("expected volcengine credentials to pass: %v", err)
}
}
func TestNewVolcengineDNSProvider(t *testing.T) {
p, err := newVolcengineDNSProvider(ddns.Credentials{Token: "ak", Secret: "sk"})
if err != nil {
t.Fatal(err)
}
if p == nil {
t.Fatal("expected provider")
}
}
+166
View File
@@ -0,0 +1,166 @@
package acme
import (
"encoding/json"
"fmt"
"strings"
"github.com/havline/havline/internal/validate"
)
func NormalizeCertDomains(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("请填写至少一个域名")
}
if len(raw) > 100 {
return nil, fmt.Errorf("域名数量不能超过 100 个")
}
seen := map[string]bool{}
out := make([]string, 0, len(raw))
for _, item := range raw {
for _, part := range splitDomainInput(item) {
domain, err := normalizeCertDomain(part)
if err != nil {
return nil, err
}
if seen[domain] {
continue
}
seen[domain] = true
out = append(out, domain)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("请填写至少一个域名")
}
return out, nil
}
func splitDomainInput(input string) []string {
input = strings.ReplaceAll(input, "\r\n", "\n")
input = strings.ReplaceAll(input, ",", "\n")
parts := strings.Split(input, "\n")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func normalizeCertDomain(domain string) (string, error) {
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if domain == "" {
return "", fmt.Errorf("域名不能为空")
}
wildcard := strings.HasPrefix(domain, "*.")
base := strings.TrimPrefix(domain, "*.")
if err := validate.Domain(base); err != nil {
return "", err
}
if wildcard {
return "*." + base, nil
}
return base, nil
}
func DomainsUnderZone(domains []string, zone string) error {
return DomainsUnderZones(domains, []string{zone})
}
func DomainsUnderZones(domains []string, zones []string) error {
normalized := normalizeManagedZones(zones)
if len(normalized) == 0 {
return fmt.Errorf("请选择 DNS 凭证")
}
for _, domain := range domains {
base := strings.TrimPrefix(domain, "*.")
if !domainUnderAnyZone(base, normalized) {
return fmt.Errorf("域名 %s 不在所选 DNS 凭证的管理范围内(%s)", domain, strings.Join(normalized, "、"))
}
}
return nil
}
func normalizeManagedZones(zones []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(zones))
for _, zone := range zones {
zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
if zone == "" || seen[zone] {
continue
}
seen[zone] = true
out = append(out, zone)
}
return out
}
func domainUnderAnyZone(domain string, zones []string) bool {
for _, zone := range zones {
if domain == zone || strings.HasSuffix(domain, "."+zone) {
return true
}
}
return false
}
func PrimaryCertDomain(domains []string) string {
for _, domain := range domains {
if !strings.HasPrefix(domain, "*.") {
return domain
}
}
if len(domains) > 0 {
return strings.TrimPrefix(domains[0], "*.")
}
return ""
}
func HasWildcardDomain(domains []string) bool {
for _, domain := range domains {
if strings.HasPrefix(domain, "*.") {
return true
}
}
return false
}
func LegacyCertDomains(domain string, wildcard bool) []string {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil
}
if wildcard {
return []string{domain, "*." + domain}
}
return []string{domain}
}
func EncodeCertDomains(domains []string) string {
if len(domains) == 0 {
return ""
}
b, err := json.Marshal(domains)
if err != nil {
return ""
}
return string(b)
}
func DecodeCertDomains(raw string, domain string, wildcard bool) []string {
raw = strings.TrimSpace(raw)
if raw != "" {
var domains []string
if err := json.Unmarshal([]byte(raw), &domains); err == nil && len(domains) > 0 {
normalized, err := NormalizeCertDomains(domains)
if err == nil {
return normalized
}
}
}
return LegacyCertDomains(domain, wildcard)
}
+43
View File
@@ -0,0 +1,43 @@
package acme
import "testing"
func TestNormalizeCertDomains(t *testing.T) {
domains, err := NormalizeCertDomains([]string{"example.com", "*.example.com", "nas.example.com"})
if err != nil {
t.Fatal(err)
}
if len(domains) != 3 {
t.Fatalf("expected 3 domains, got %d", len(domains))
}
if domains[1] != "*.example.com" {
t.Fatalf("unexpected wildcard: %s", domains[1])
}
}
func TestDomainsUnderZone(t *testing.T) {
domains := []string{"example.com", "*.example.com", "nas.example.com"}
if err := DomainsUnderZone(domains, "example.com"); err != nil {
t.Fatal(err)
}
if err := DomainsUnderZone([]string{"other.com"}, "example.com"); err == nil {
t.Fatal("expected zone mismatch error")
}
}
func TestDomainsUnderZonesMultiRoot(t *testing.T) {
zones := []string{"roven.cc", "chiak.cc"}
if err := DomainsUnderZones([]string{"chiak.cc", "*.chiak.cc"}, zones); err != nil {
t.Fatal(err)
}
if err := DomainsUnderZones([]string{"other.com"}, zones); err == nil {
t.Fatal("expected zone mismatch error")
}
}
func TestDecodeCertDomainsLegacy(t *testing.T) {
got := DecodeCertDomains("", "example.com", true)
if len(got) != 2 || got[1] != "*.example.com" {
t.Fatalf("unexpected legacy domains: %v", got)
}
}
+117
View File
@@ -0,0 +1,117 @@
package acme
import (
"archive/zip"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
certstore "github.com/havline/havline/internal/certificate"
)
type DownloadPart string
const (
DownloadZIP DownloadPart = "zip"
DownloadCert DownloadPart = "cert"
DownloadKey DownloadPart = "key"
)
func (s *Service) Download(ctx context.Context, domain string, part string) (filename string, contentType string, data []byte, err error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return "", "", nil, fmt.Errorf("域名不能为空")
}
rec, err := s.store.GetByDomain(ctx, domain)
if err != nil {
return "", "", nil, err
}
if rec.CertPath == "" || rec.KeyPath == "" {
return "", "", nil, fmt.Errorf("证书文件不存在,可能尚未申请成功")
}
if err := validateCertFilePath(s.cfg.CertsDir(), rec.CertPath); err != nil {
return "", "", nil, err
}
if err := validateCertFilePath(s.cfg.CertsDir(), rec.KeyPath); err != nil {
return "", "", nil, err
}
certPEM, err := os.ReadFile(rec.CertPath)
if err != nil {
return "", "", nil, fmt.Errorf("读取证书失败")
}
keyPEM, err := os.ReadFile(rec.KeyPath)
if err != nil {
return "", "", nil, fmt.Errorf("读取私钥失败")
}
base := downloadBaseName(rec)
switch DownloadPart(strings.ToLower(strings.TrimSpace(part))) {
case "", DownloadZIP:
data, err = zipCertBundle(base, certPEM, keyPEM)
if err != nil {
return "", "", nil, err
}
return base + ".zip", "application/zip", data, nil
case DownloadCert:
return base + "-fullchain.pem", "application/x-pem-file", certPEM, nil
case DownloadKey:
return base + "-privatekey.pem", "application/x-pem-file", keyPEM, nil
default:
return "", "", nil, fmt.Errorf("不支持的下载类型")
}
}
func downloadBaseName(rec certstore.Record) string {
name := strings.TrimPrefix(rec.Domain, "*.")
name = strings.ReplaceAll(name, "*", "wildcard")
name = strings.ReplaceAll(name, "/", "_")
if name == "" {
name = "certificate"
}
return name
}
func validateCertFilePath(certsDir, path string) error {
absCerts, err := filepath.Abs(certsDir)
if err != nil {
return fmt.Errorf("证书目录无效")
}
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("证书路径无效")
}
rel, err := filepath.Rel(absCerts, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("证书路径无效")
}
return nil
}
func zipCertBundle(base string, certPEM, keyPEM []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if err := writeZipEntry(zw, "fullchain.pem", certPEM); err != nil {
return nil, err
}
if err := writeZipEntry(zw, "privatekey.pem", keyPEM); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
+26
View File
@@ -0,0 +1,26 @@
package acme
import (
"path/filepath"
"testing"
)
func TestValidateCertFilePath(t *testing.T) {
certsDir := filepath.Clean("/data/certs")
if err := validateCertFilePath(certsDir, "/data/certs/example.com/fullchain.pem"); err != nil {
t.Fatalf("expected valid path: %v", err)
}
if err := validateCertFilePath(certsDir, "/etc/passwd"); err == nil {
t.Fatal("expected invalid path")
}
}
func TestZipCertBundle(t *testing.T) {
data, err := zipCertBundle("example.com", []byte("cert"), []byte("key"))
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Fatal("empty zip")
}
}
+183
View File
@@ -0,0 +1,183 @@
package acme
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"sync"
"time"
certstore "github.com/havline/havline/internal/certificate"
)
type JobEvent struct {
Type string `json:"type"`
Level string `json:"level,omitempty"`
Message string `json:"message,omitempty"`
Result *JobDonePayload `json:"result,omitempty"`
}
type JobDonePayload struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Domain string `json:"domain,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
CertDir string `json:"cert_dir,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type Job struct {
id string
mu sync.Mutex
events []JobEvent
done bool
waiters map[chan JobEvent]struct{}
}
type JobManager struct {
mu sync.Mutex
jobs map[string]*Job
}
func NewJobManager() *JobManager {
return &JobManager{jobs: map[string]*Job{}}
}
func (m *JobManager) Create() *Job {
job := &Job{
id: fmt.Sprintf("%d", time.Now().UnixNano()),
waiters: map[chan JobEvent]struct{}{},
}
m.mu.Lock()
m.jobs[job.id] = job
m.mu.Unlock()
return job
}
func (m *JobManager) Get(id string) *Job {
m.mu.Lock()
job := m.jobs[id]
m.mu.Unlock()
return job
}
func (j *Job) ID() string {
return j.id
}
func (j *Job) Append(level, message string) {
j.emit(JobEvent{Type: "log", Level: level, Message: message})
}
func (j *Job) Info(message string) { j.Append("info", message) }
func (j *Job) Warn(message string) { j.Append("warn", message) }
func (j *Job) Error(message string) { j.Append("error", message) }
func (j *Job) Finish(result JobDonePayload) {
j.emit(JobEvent{Type: "done", Result: &result})
}
func (j *Job) emit(ev JobEvent) {
j.mu.Lock()
j.events = append(j.events, ev)
if ev.Type == "done" {
j.done = true
}
for ch := range j.waiters {
ch <- ev
}
j.mu.Unlock()
}
func (j *Job) Snapshot() []JobEvent {
j.mu.Lock()
out := append([]JobEvent(nil), j.events...)
j.mu.Unlock()
return out
}
func (j *Job) Subscribe() chan JobEvent {
ch := make(chan JobEvent, 32)
j.mu.Lock()
j.waiters[ch] = struct{}{}
j.mu.Unlock()
return ch
}
func (j *Job) Unsubscribe(ch chan JobEvent) {
j.mu.Lock()
delete(j.waiters, ch)
j.mu.Unlock()
close(ch)
}
func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
job := m.Get(id)
if job == nil {
return fmt.Errorf("任务不存在或已过期")
}
ch := job.Subscribe()
defer job.Unsubscribe(ch)
for _, ev := range job.Snapshot() {
if err := writeJobSSE(w, ev); err != nil {
return err
}
if err := flush(); err != nil {
return err
}
if ev.Type == "done" {
return nil
}
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case ev := <-ch:
if err := writeJobSSE(w, ev); err != nil {
return err
}
if err := flush(); err != nil {
return err
}
if ev.Type == "done" {
return nil
}
}
}
}
func writeJobSSE(w io.Writer, ev JobEvent) error {
data, err := json.Marshal(ev)
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
return err
}
func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
if err != nil {
return JobDonePayload{OK: false, Error: err.Error()}
}
payload := JobDonePayload{
OK: true,
Domain: rec.Domain,
CertPath: rec.CertPath,
KeyPath: rec.KeyPath,
}
if rec.CertPath != "" {
payload.CertDir = filepath.Dir(rec.CertPath)
}
if rec.ExpiresAt != nil {
payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
}
return payload
}
+50
View File
@@ -0,0 +1,50 @@
package acme
import (
"strings"
"testing"
)
// LiteSSL 接替 Buypass 的位置:目录地址实测可用,且强制 EAB。
func TestLiteSSLDirectoryAndLabel(t *testing.T) {
url, err := DirectoryURL(CALiteSSL)
if err != nil {
t.Fatalf("LiteSSL 目录地址解析失败:%v", err)
}
if url != "https://acme.litessl.com/acme/v2/directory" {
t.Fatalf("LiteSSL 目录地址不对:%q", url)
}
if label := CALabel(CALiteSSL); label != "LiteSSL" {
t.Fatalf("LiteSSL 展示名不对:%q", label)
}
// 别名与归一化
if got := NormalizeCA("trustasia"); got != CALiteSSL {
t.Fatalf("别名 trustasia 应归一化为 %s,实际 %q", CALiteSSL, got)
}
if reason := CAUnavailableReason(CALiteSSL); reason != "" {
t.Fatalf("LiteSSL 是可用的 CA,不该有停用原因:%q", reason)
}
}
// LiteSSL 支持通配符与多域名,因此 ValidateCADomains 不应拦它。
func TestValidateCADomainsLiteSSLAllowsWildcard(t *testing.T) {
domains := []string{"*.36800.cc", "a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}
if err := ValidateCADomains(CALiteSSL, domains); err != nil {
t.Fatalf("LiteSSL 应允许通配符与多域名:%v", err)
}
}
// Buypass 保留在停用表里(历史记录要能展示),但提示里应给出可用的替代。
func TestBuypassIsRetiredWithAlternatives(t *testing.T) {
reason := CAUnavailableReason(CABuypass)
if reason == "" {
t.Fatal("Buypass 应仍被标记为已停用")
}
if !strings.Contains(reason, "LiteSSL") {
t.Fatalf("停用提示里应包含 LiteSSL 这个替代项:%q", reason)
}
// 旧记录仍能正常取到目录地址(不报「不支持的颁发机构」)
if _, err := DirectoryURL(CABuypass); err != nil {
t.Fatalf("历史 Buypass 记录的目录地址仍应可解析:%v", err)
}
}
+46
View File
@@ -0,0 +1,46 @@
package acme
import "testing"
func TestParseResolverList(t *testing.T) {
cases := []struct {
name string
raw string
want []string
}{
{"带端口", "223.5.5.5:53, 1.1.1.1:53", []string{"223.5.5.5:53", "1.1.1.1:53"}},
{"缺端口补 53", "8.8.8.8\n9.9.9.9", []string{"8.8.8.8:53", "9.9.9.9:53"}},
{"分号与空格", "223.5.5.5;8.8.4.4 1.0.0.1", []string{"223.5.5.5:53", "8.8.4.4:53", "1.0.0.1:53"}},
{"空值", " ", nil},
{"只留空白项", " , ; \n", nil},
}
for _, item := range cases {
got := parseResolverList(item.raw)
if len(got) != len(item.want) {
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
}
for i := range got {
if got[i] != item.want[i] {
t.Fatalf("%s: 期望 %v,实际 %v", item.name, item.want, got)
}
}
}
}
func TestParseResolverListCapsEntries(t *testing.T) {
raw := "1.1.1.1,1.1.1.2,1.1.1.3,1.1.1.4,1.1.1.5,1.1.1.6,1.1.1.7,1.1.1.8,1.1.1.9,1.1.1.10"
if got := parseResolverList(raw); len(got) != 8 {
t.Fatalf("最多取前 8 条,实际 %d:%v", len(got), got)
}
}
// 内置的默认值必须是 lego 能直接用的 host:port 形式
func TestDefaultResolversAreUsable(t *testing.T) {
got := parseResolverList(defaultACMEDNSResolvers)
if len(got) == 0 {
t.Fatalf("默认解析器解析为空:%q", defaultACMEDNSResolvers)
}
if got[0] != "223.5.5.5:53" {
t.Fatalf("默认解析器首项应带端口,实际 %q", got[0])
}
}
+701
View File
@@ -0,0 +1,701 @@
package acme
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"log/slog"
"net/http"
"net/mail"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
legocert "github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
certstore "github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
type Service struct {
cfg config.Config
store *certstore.Store
ddnsSvc *ddns.Service
settings *settings.Store
proxySvc *service.ProxyService
logger *slog.Logger
notify *notify.Service
jobs *JobManager
expiryAlertMu sync.Mutex
expiryAlerted map[string]string
}
func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
return &Service{
cfg: cfg,
store: store,
ddnsSvc: ddnsSvc,
settings: settings,
proxySvc: proxySvc,
logger: logger.With("module", "ACME"),
notify: notifySvc,
jobs: NewJobManager(),
expiryAlerted: map[string]string{},
}
}
func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
return s.store.List(ctx)
}
func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
if ddnsConfigID <= 0 {
return "", fmt.Errorf("请选择 DNS 任务")
}
domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
if err != nil {
return "", err
}
job := s.jobs.Create()
go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
return job.ID(), nil
}
func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
return s.jobs.Stream(ctx, w, jobID, flush)
}
func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
domains, err := NormalizeCertDomains(domains)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
primary := PrimaryCertDomain(domains)
if primary == "" {
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
}
email, err = s.resolveACMEEmail(ctx, email)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := ValidateCADomains(ca, domains); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := validateDNSCredentials(provider, cred); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
dnsZone := cfg.RootDomain
zones := cfg.ManagedDNSZones()
if len(zones) > 0 {
dnsZone = zones[0]
}
return domains, ca, email, primary, dnsZone, provider, cred, nil
}
func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
domains, err := NormalizeCertDomains(domains)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
primary := PrimaryCertDomain(domains)
if primary == "" {
return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
}
email, err = s.resolveACMEEmail(ctx, email)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
if err := ValidateCADomains(ca, domains); err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
if err != nil {
return nil, "", "", "", "", "", ddns.Credentials{}, err
}
return domains, ca, email, primary, dnsZone, provider, cred, nil
}
func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
ctx := context.Background()
job.Info("开始申请证书…")
job.Info(fmt.Sprintf("主域名: %s", primary))
job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
if err != nil {
s.markCertError(ctx, primary, domains, ca, err)
job.Error(err.Error())
job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
return
}
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
job.Warn("Nginx 重载失败: " + err.Error())
} else {
job.Info("Nginx 已重载")
}
job.Info("证书申请完成")
job.Finish(jobDoneFromRecord(rec, nil))
}
func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
if err != nil {
return nil, err
}
rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
if err != nil {
s.markCertError(ctx, primary, domains, ca, err)
return nil, err
}
if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
s.logger.Warn("save acme ca setting failed", "error", err.Error())
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
}
_ = rec
return s.store.List(ctx)
}
func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
if err != nil {
return certstore.Record{}, fmt.Errorf("证书:%w", err)
}
keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
if err != nil {
return certstore.Record{}, fmt.Errorf("私钥:%w", err)
}
info, err := certstore.InspectImport(certBytes, keyBytes)
if err != nil {
return certstore.Record{}, err
}
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
if err != nil {
return certstore.Record{}, err
}
status := "ok"
if time.Now().After(info.ExpiresAt) {
status = "expired"
}
domainsJSON := EncodeCertDomains(info.Domains)
record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
if err != nil {
return certstore.Record{}, err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
}
s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
return record, nil
}
func (s *Service) Delete(ctx context.Context, domain string) error {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return fmt.Errorf("域名不能为空")
}
existing, err := s.store.GetByDomain(ctx, domain)
if err != nil {
return err
}
if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
}
if err := s.store.Delete(ctx, domain); err != nil {
return err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
}
s.logger.Info("certificate deleted", "domain", domain)
return nil
}
func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
if rootDomain == "" {
return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
}
existing, err := s.store.GetByDomain(ctx, rootDomain)
if err != nil {
return certstore.Record{}, err
}
if existing.ACMECA == "imported" {
return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
}
if ca == "" {
ca = existing.ACMECA
}
ca, err = s.resolveCA(ctx, ca)
if err != nil {
return certstore.Record{}, err
}
email, err := s.resolveACMEEmail(ctx, "")
if err != nil {
return certstore.Record{}, err
}
domains := existing.Domains
if len(domains) == 0 {
domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
}
dnsZone, err := s.resolveDNSZone(ctx, domains)
if err != nil {
return certstore.Record{}, err
}
provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
if err != nil {
return certstore.Record{}, err
}
if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
s.markCertError(ctx, rootDomain, domains, ca, err)
return certstore.Record{}, err
}
if err := s.proxySvc.ReloadAll(ctx); err != nil {
s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
}
rec, err := s.store.GetByDomain(ctx, rootDomain)
if err != nil {
return certstore.Record{}, err
}
if s.notify != nil {
expires := "未知"
if rec.ExpiresAt != nil {
expires = rec.ExpiresAt.Local().Format("2006-01-02")
}
s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
}
return rec, nil
}
func (s *Service) Tick(ctx context.Context) {
records, err := s.store.List(ctx)
if err != nil {
return
}
threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
if err != nil || threshold <= 0 {
threshold = 30
}
for _, rec := range records {
if rec.ExpiresAt == nil {
continue
}
if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
}
if rec.DaysLeft <= threshold {
if rec.ACMECA == "imported" {
continue
}
if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
if s.notify != nil {
s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
}
}
}
}
}
func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
if s.notify == nil {
return
}
today := time.Now().Format("2006-01-02")
s.expiryAlertMu.Lock()
if s.expiryAlerted[domain] == today {
s.expiryAlertMu.Unlock()
return
}
s.expiryAlerted[domain] = today
s.expiryAlertMu.Unlock()
msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
if daysLeft <= 0 {
msg = fmt.Sprintf("%s 已到期或即将失效", domain)
}
s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
}
func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
if err == nil {
return
}
errMsg := err.Error()
s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
_, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
if upsertErr != nil {
s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
}
}
func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
if strings.TrimSpace(ca) == "" {
ca, err := s.settings.Get(ctx, settings.KeyACMECA)
if err != nil || strings.TrimSpace(ca) == "" {
ca = CALetsEncrypt
}
}
ca = NormalizeCA(ca)
if err := ValidateCA(ca); err != nil {
return "", err
}
if reason := CAUnavailableReason(ca); reason != "" {
return "", fmt.Errorf("%s", reason)
}
return ca, nil
}
func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
reg, err := client.Registration.ResolveAccountByKey()
if err == nil {
return reg, nil
}
logStep("注册 ACME 账户…")
switch NormalizeCA(ca) {
case CAZeroSSL:
apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
if err != nil {
return nil, err
}
kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
if err != nil {
return nil, err
}
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: kid,
HmacEncoded: hmac,
})
case CALiteSSL:
// LiteSSL 的目录里 meta.externalAccountRequired = true:必须带 EAB。
// 与 ZeroSSL 不同,不经过厂商 API 换取,而是用户在 FreeSSL 平台自建 EAB 后直接填 kid/hmac。
kid, err := s.settings.Get(ctx, settings.KeyLiteSSLEABKid)
if err != nil {
return nil, err
}
hmac, err := s.settings.Get(ctx, settings.KeyLiteSSLEABHMAC)
if err != nil {
return nil, err
}
kid = strings.TrimSpace(kid)
hmac = strings.TrimSpace(hmac)
if kid == "" || hmac == "" {
return nil, fmt.Errorf("LiteSSL 要求 EAB 凭据:请在设置里填写 EAB Kid 与 EAB HMAC(在 FreeSSL 的「证书自动化 → EAB 管理」创建后原样粘贴)")
}
return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: kid,
HmacEncoded: hmac,
})
default:
return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
}
}
func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
logStep := func(msg string) {
if job != nil {
job.Info(msg)
}
}
caDir, err := DirectoryURL(ca)
if err != nil {
return certstore.Record{}, err
}
logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
user, err := newUser(email)
if err != nil {
return certstore.Record{}, err
}
config := lego.NewConfig(user)
config.CADirURL = caDir
config.Certificate.KeyType = certcrypto.EC256
client, err := lego.NewClient(config)
if err != nil {
return certstore.Record{}, err
}
dnsProvider, err := newDNS01Provider(provider, cred)
if err != nil {
return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
}
dnsProvider = wrapDNSProvider(dnsProvider, job)
// DNS-01 的传播检查由 lego 自己做,它默认读系统 /etc/resolv.conf;
// 容器里那通常是 Docker 的 127.0.0.11,它不返回权威 NS,于是报
// 「[zone=example.com.] could not determine authoritative nameservers」。
// 所以显式指定可靠递归解析器(设置项可改;设为 system 则显式回到系统解析器)。
resolvers, err := s.acmeDNSResolvers(ctx)
if err != nil {
return certstore.Record{}, err
}
var challengeOpts []dns01.ChallengeOption
if len(resolvers) > 0 {
challengeOpts = append(challengeOpts, dns01.AddRecursiveNameservers(resolvers))
}
if err := client.Challenge.SetDNS01Provider(dnsProvider, challengeOpts...); err != nil {
return certstore.Record{}, err
}
logStep("检查 ACME 账户…")
reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
if err != nil {
return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
}
user.registration = reg
logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
request := legocert.ObtainRequest{
Domains: domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(request)
if err != nil {
return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
}
certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
if err != nil {
return certstore.Record{}, err
}
logStep(fmt.Sprintf("证书已保存: %s", certPath))
logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
expiresAt, err := parseCertExpiry(cert.Certificate)
if err != nil {
expiresAt = time.Now().Add(90 * 24 * time.Hour)
}
logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
if err != nil {
return certstore.Record{}, err
}
s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
return rec, nil
}
func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
email = strings.TrimSpace(email)
if email == "" {
stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
if err != nil || strings.TrimSpace(stored) == "" {
return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
}
email = strings.TrimSpace(stored)
}
if err := validateACMEEmail(email); err != nil {
return "", err
}
if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
s.logger.Warn("save acme email failed", "error", err.Error())
}
return email, nil
}
// defaultACMEDNSResolvers 默认递归解析器(阿里/Cloudflare/Google)。
// 容器里的系统解析器常只指向 127.0.0.11,无法用于 DNS-01 的权威 NS 查询;
// 设置项写 system 则回到系统解析器,写其它值则按逗号/空格分隔逐条使用。
const defaultACMEDNSResolvers = "223.5.5.5:53,1.1.1.1:53,8.8.8.8:53"
// acmeDNSResolvers 返回本次申请要用的解析器;空 slice 表示交给 lego 读系统解析器。
func (s *Service) acmeDNSResolvers(ctx context.Context) ([]string, error) {
raw, err := s.settings.Get(ctx, settings.KeyACMEDNSResolvers)
if err != nil || strings.TrimSpace(raw) == "" {
raw = defaultACMEDNSResolvers
}
if strings.EqualFold(strings.TrimSpace(raw), "system") {
return nil, nil
}
resolvers := parseResolverList(raw)
if len(resolvers) == 0 {
return nil, fmt.Errorf("DNS 解析器配置无效(%q):请填 host 或 host:port,多个用逗号分隔;留空用默认值,填 system 用系统解析器", raw)
}
return resolvers, nil
}
// parseResolverList 解析解析器列表:逗号/空格/换行分隔,未写端口时补 53,最多取前 8 个。
func parseResolverList(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == ';'
})
out := make([]string, 0, len(fields))
for _, item := range fields {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if !strings.Contains(item, ":") {
item += ":53"
}
out = append(out, item)
if len(out) >= 8 {
break
}
}
return out
}
func validateACMEEmail(email string) error {
addr, err := mail.ParseAddress(email)
if err != nil || addr.Address != email {
return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
}
return nil
}
func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
for _, domain := range domains {
cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
if err == nil {
return cfg.RootDomain, nil
}
}
return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
}
func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
if err != nil {
return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
}
if err := validateDNSCredentials(provider, cred); err != nil {
return "", ddns.Credentials{}, err
}
return provider, cred, nil
}
func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
names := certStorageNames(domains)
if len(names) == 0 {
return "", "", fmt.Errorf("证书域名不能为空")
}
var certPath, keyPath string
for _, name := range names {
dir := filepath.Join(certsDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", "", err
}
cp := filepath.Join(dir, "fullchain.pem")
kp := filepath.Join(dir, "privatekey.pem")
if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
return "", "", err
}
if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
return "", "", err
}
certPath, keyPath = cp, kp
}
return certPath, keyPath, nil
}
func removeCertFiles(certsDir string, record certstore.Record) error {
domains := record.Domains
if len(domains) == 0 {
domains = LegacyCertDomains(record.Domain, record.Wildcard)
}
var firstErr error
for _, name := range certStorageNames(domains) {
dir := filepath.Join(certsDir, name)
if err := os.RemoveAll(dir); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func certStorageNames(domains []string) []string {
seen := map[string]bool{}
var names []string
for _, domain := range domains {
name := domain
if strings.HasPrefix(domain, "*.") {
name = "wildcard." + strings.TrimPrefix(domain, "*.")
}
if seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
return names
}
func parseCertExpiry(pemBytes []byte) (time.Time, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return time.Time{}, fmt.Errorf("invalid pem")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, err
}
return cert.NotAfter, nil
}
type acmeUser struct {
email string
registration *registration.Resource
key crypto.PrivateKey
}
func (u *acmeUser) GetEmail() string { return u.email }
func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func newUser(email string) (*acmeUser, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
return &acmeUser{email: email, key: privateKey}, nil
}
+37
View File
@@ -0,0 +1,37 @@
package acme
import (
"context"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/havline/havline/internal/ddns"
)
type volcengineDNS struct {
provider *ddns.Volcengine
cred ddns.Credentials
}
func newVolcengineDNSProvider(cred ddns.Credentials) (challenge.Provider, error) {
if err := cred.Validate("volcengine"); err != nil {
return nil, err
}
return &volcengineDNS{
provider: ddns.NewVolcengine(),
cred: cred,
}, nil
}
func (p *volcengineDNS) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
return p.provider.UpsertTXTRecord(context.Background(), p.cred, fqdn, info.Value)
}
func (p *volcengineDNS) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
fqdn := dns01.UnFqdn(info.EffectiveFQDN)
return p.provider.DeleteTXTRecord(context.Background(), p.cred, fqdn, info.Value)
}
+94
View File
@@ -0,0 +1,94 @@
package acme
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type zeroSSLEABResponse struct {
Success int `json:"success"`
EABKid string `json:"eab_kid"`
EABHmacKey string `json:"eab_hmac_key"`
Error struct {
Code int `json:"code"`
Type string `json:"type"`
} `json:"error"`
}
// zeroSSLEABEndpoint 单独成变量,测试可以替换成本地 httptest 服务。
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials"
func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
}
endpoint := zeroSSLEABEndpoint + "?access_key=" + url.QueryEscape(apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", "", err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
// 不能把 url.Error 整个包进错误:它的字符串里是带 access_key 的完整 URL,
// 而这条错误会进任务日志与 <数据目录>/logs/app.log,等于把密钥写进日志。
return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%s", redactURLError(err))
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", "", err
}
var payload zeroSSLEABResponse
parseErr := json.Unmarshal(body, &payload)
if resp.StatusCode != http.StatusOK {
// 422 基本都是 access_key 无效(也可能是账号/权限问题)。ZeroSSL 会在
// error.type 与 error.code 里写清原因,所以带上它,而不是只回状态码让用户猜。
if parseErr == nil && payload.Error.Type != "" {
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s(错误码 %d)", resp.StatusCode, payload.Error.Type, payload.Error.Code)
}
return "", "", fmt.Errorf("ZeroSSL API 返回 %d:%s", resp.StatusCode, snippet(body))
}
if parseErr != nil {
return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", parseErr)
}
if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
if payload.Error.Type != "" {
return "", "", fmt.Errorf("ZeroSSL API 错误:%s(错误码 %d)", payload.Error.Type, payload.Error.Code)
}
return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
}
return payload.EABKid, payload.EABHmacKey, nil
}
// redactURLError 只保留底层错误原因,避免把带 access_key 的 URL 写进日志。
func redactURLError(err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
return urlErr.Err.Error()
}
return err.Error()
}
// snippet 压平并截断响应正文:既不把整页 HTML 灌进日志,也不让换行破坏排版。
func snippet(body []byte) string {
text := strings.Join(strings.Fields(string(body)), " ")
if text == "" {
return "(响应体为空)"
}
if len(text) > 200 {
text = text[:200] + "…"
}
return text
}
+86
View File
@@ -0,0 +1,86 @@
package acme
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// withEndpoint 把 ZeroSSL 端点指向本地测试服务(用完恢复)。
func withEndpoint(t *testing.T, handler http.HandlerFunc) {
t.Helper()
server := httptest.NewServer(handler)
previous := zeroSSLEABEndpoint
zeroSSLEABEndpoint = server.URL
t.Cleanup(func() {
zeroSSLEABEndpoint = previous
server.Close()
})
}
func TestZeroSSLEABCredentialsSuccess(t *testing.T) {
withEndpoint(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("access_key"); got != "good-key" {
t.Fatalf("access_key 应原样传出,实际 %q", got)
}
_, _ = w.Write([]byte(`{"success":1,"eab_kid":"kid-1","eab_hmac_key":"hmac-1"}`))
})
kid, hmac, err := zerosslEABCredentials(context.Background(), "good-key")
if err != nil {
t.Fatalf("不应报错:%v", err)
}
if kid != "kid-1" || hmac != "hmac-1" {
t.Fatalf("凭据解析错误:kid=%q hmac=%q", kid, hmac)
}
}
// 422 时要把 ZeroSSL 给的错误类型带出来,而不是只回一个状态码让用户猜
func TestZeroSSLEABCredentialsSurfacesErrorType(t *testing.T) {
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"success":false,"error":{"code":2902,"type":"invalid_api_key"}}`))
})
_, _, err := zerosslEABCredentials(context.Background(), "bad-key")
if err == nil {
t.Fatal("期望报错")
}
if !strings.Contains(err.Error(), "422") || !strings.Contains(err.Error(), "invalid_api_key") {
t.Fatalf("错误里应含状态码与错误类型,实际:%v", err)
}
}
// 非 JSON 的失败响应:退回截断正文,便于定位(例如网关返回的 HTML)
func TestZeroSSLEABCredentialsFallsBackToBodySnippet(t *testing.T) {
withEndpoint(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html><body>\n 502 Bad Gateway\n</body></html>"))
})
_, _, err := zerosslEABCredentials(context.Background(), "any")
if err == nil || !strings.Contains(err.Error(), "502 Bad Gateway") {
t.Fatalf("应带上响应正文摘要,实际:%v", err)
}
}
// 网络错误必须脱敏:url.Error 的字符串里带 access_key,泄漏进日志等于泄密
func TestZeroSSLEABCredentialsDoesNotLeakKey(t *testing.T) {
const key = "SUPERSECRETACCESSKEY"
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close() // 立刻关掉,制造连接失败
previous := zeroSSLEABEndpoint
zeroSSLEABEndpoint = server.URL
t.Cleanup(func() { zeroSSLEABEndpoint = previous })
_, _, err := zerosslEABCredentials(context.Background(), key)
if err == nil {
t.Fatal("期望请求失败")
}
if strings.Contains(err.Error(), key) {
t.Fatalf("错误信息里泄露了 API Key:%v", err)
}
}
+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)
}
}
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/notify"
)
type AuthHandler struct {
auth *auth.Service
notify *notify.Service
guard *loginGuard
}
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
}
const (
loginFailureLimit = 5
loginLockoutWindow = 5 * time.Minute
)
// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝,
// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。
type loginGuard struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginGuard() *loginGuard {
return &loginGuard{failures: make(map[string][]time.Time)}
}
// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。
func (g *loginGuard) blocked(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
kept := g.recentLocked(ip)
g.failures[ip] = kept
return len(kept) >= loginFailureLimit
}
func (g *loginGuard) fail(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
if len(g.failures) > 1024 {
for key := range g.failures {
if len(g.recentLocked(key)) == 0 {
delete(g.failures, key)
}
}
}
g.failures[ip] = append(g.recentLocked(ip), time.Now())
}
func (g *loginGuard) reset(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.failures, ip)
}
// recentLocked 返回窗口内的失败时间(调用方需持有锁)。
func (g *loginGuard) recentLocked(ip string) []time.Time {
now := time.Now()
kept := make([]time.Time, 0, len(g.failures[ip]))
for _, at := range g.failures[ip] {
if now.Sub(at) < loginLockoutWindow {
kept = append(kept, at)
}
}
return kept
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authStatusResponse struct {
Initialized bool `json:"initialized"`
Authenticated bool `json:"authenticated"`
}
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
initialized, err := h.auth.IsInitialized(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取初始化状态失败")
return
}
authenticated := false
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
if err := h.auth.ValidateSession(r.Context(), sessionID); err == nil {
authenticated = true
}
}
writeJSON(w, http.StatusOK, authStatusResponse{
Initialized: initialized,
Authenticated: authenticated,
})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
ip := clientIP(r)
if h.guard.blocked(ip) {
writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, auth.ErrUnauthorized) {
h.guard.fail(ip)
if h.notify != nil {
h.notify.RecordLoginFailure(r.Context(), ip)
}
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
return
}
writeError(r, w, http.StatusInternalServerError, "登录失败")
return
}
h.guard.reset(ip)
setSessionCookie(w, r, sessionID)
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
}
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
sessionID := sessionIDFromContext(r.Context())
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return
}
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
_ = h.auth.Logout(r.Context(), sessionID)
}
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
http.SetCookie(w, &http.Cookie{
Name: "havline_session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
}
// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure;
// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "havline_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
func TestAuthSetupAndLoginFlow(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
tmpDir := t.TempDir()
authSvc := auth.New(conn)
proxySvc := service.NewProxyService(config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil)
handler := NewRouter(Deps{
Config: config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"},
Auth: authSvc,
Proxy: proxySvc,
Backup: backup.New(tmpDir),
Discovery: discovery.New(),
StartedAt: time.Now().UTC().Format(time.RFC3339),
})
statusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
statusRec := httptest.NewRecorder()
handler.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status code: %d", statusRec.Code)
}
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
t.Fatalf("setup admin: %v", err)
}
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password123",
})
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody))
loginRec := httptest.NewRecorder()
handler.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status: %d body=%s", loginRec.Code, loginRec.Body.String())
}
cookie := loginRec.Result().Cookies()[0]
if cookie.Name != "havline_session" || cookie.HttpOnly == false {
t.Fatal("expected httponly session cookie")
}
authStatusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
authStatusReq.AddCookie(cookie)
authStatusRec := httptest.NewRecorder()
handler.ServeHTTP(authStatusRec, authStatusReq)
if authStatusRec.Code != http.StatusOK {
t.Fatalf("auth status code: %d", authStatusRec.Code)
}
var authStatus authStatusResponse
if err := json.Unmarshal(authStatusRec.Body.Bytes(), &authStatus); err != nil {
t.Fatalf("decode auth status: %v", err)
}
if !authStatus.Initialized || !authStatus.Authenticated {
t.Fatalf("expected authenticated status, got %+v", authStatus)
}
proxiesReq := httptest.NewRequest(http.MethodGet, "/api/proxies", nil)
proxiesReq.AddCookie(cookie)
proxiesRec := httptest.NewRecorder()
handler.ServeHTTP(proxiesRec, proxiesReq)
if proxiesRec.Code != http.StatusOK {
t.Fatalf("proxies status: %d", proxiesRec.Code)
}
}
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"io"
"net/http"
"os"
"path/filepath"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/settings"
)
type BackupHandler struct {
svc *backup.Service
settings *settings.Store
}
func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *BackupHandler {
return &BackupHandler{svc: svc, settings: settingsStore}
}
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败")
return
}
defer os.Remove(path)
file, err := os.Open(path)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份失败")
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
_, _ = io.Copy(w, file)
}
func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(64 << 20); err != nil {
writeError(r, w, http.StatusBadRequest, "上传文件无效")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
return
}
defer file.Close()
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
out, err := os.CreateTemp("", "havline-restore-*.tar.gz")
if err != nil {
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return
}
tmpPath := out.Name()
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(tmpPath)
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return
}
out.Close()
if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
_ = os.Remove(tmpPath)
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
_ = os.Remove(tmpPath)
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
}
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
// 与「立即下载」不同:这些文件长期留在 <DataDir>/backups 下。
func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
archives, err := h.svc.List()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份列表失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"archives": archives,
"keep": backup.KeepCount(r.Context(), h.settings),
})
}
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
return
}
name := filepath.Base(path)
// 写失败的备份不能留在列表里冒充可用备份
if _, err := h.svc.Verify(r.Context(), path); err != nil {
_ = h.svc.Remove(name)
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
return
}
removed, err := h.svc.Prune(backup.KeepCount(r.Context(), h.settings))
if err != nil {
writeError(r, w, http.StatusInternalServerError, "备份已创建,但清理旧备份失败:"+err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"name": name, "removed": removed})
}
// DownloadArchive 下载指定备份;用 ServeContent 支持断点续传
func (h *BackupHandler) DownloadArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Path(r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
file, err := os.Open(path)
if err != nil {
writeError(r, w, http.StatusNotFound, "备份不存在")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份失败", err)
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
}
// RestoreArchive 从指定备份恢复。恢复会覆盖数据库与证书,前端必须二次确认
func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Path(r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
// 恢复前先校验:半个包不能拿去覆盖现有数据
if _, err := h.svc.Verify(r.Context(), path); err != nil {
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
return
}
if err := h.svc.Restore(r.Context(), path); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
}
// DeleteArchive 删除指定备份
func (h *BackupHandler) DeleteArchive(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Remove(r.PathValue("name")); err != nil {
if err.Error() == "备份不存在" {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/havline/havline/internal/acme"
)
type CertHandler struct {
svc *acme.Service
}
func NewCertHandler(svc *acme.Service) *CertHandler {
return &CertHandler{svc: svc}
}
func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
records, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取证书失败")
return
}
writeJSON(w, http.StatusOK, records)
}
func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []map[string]string{
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
// Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,不再列为可选 CA
// (历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下);LiteSSL 接替其位置。
{"value": acme.CALiteSSL, "label": acme.CALabel(acme.CALiteSSL)},
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
})
}
func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
var req struct {
Domain string `json:"domain"`
DNSZone string `json:"dns_zone"`
DDNSConfigID int64 `json:"ddns_config_id"`
Domains []string `json:"domains"`
CA string `json:"ca"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
domains := req.Domains
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
domains = []string{req.Domain}
}
if req.DDNSConfigID <= 0 {
writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
return
}
jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
}
func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(r.PathValue("id"))
if jobID == "" {
writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
flusher.Flush()
return nil
}); err != nil {
return
}
}
func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
var req struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.PathValue("domain"))
if domain == "" {
writeError(r, w, http.StatusBadRequest, "域名不能为空")
return
}
part := r.URL.Query().Get("part")
filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.PathValue("domain"))
if domain == "" {
writeError(r, w, http.StatusBadRequest, "域名不能为空")
return
}
if err := h.svc.Delete(r.Context(), domain); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
}
func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
var req struct {
Domain string `json:"domain"`
CA string `json:"ca"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/chinacidr"
)
type ChinaCIDRHandler struct {
svc *chinacidr.Service
}
func NewChinaCIDRHandler(svc *chinacidr.Service) *ChinaCIDRHandler {
return &ChinaCIDRHandler{svc: svc}
}
func (h *ChinaCIDRHandler) Status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
}
func (h *ChinaCIDRHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.Update(ctx); err != nil {
h.svc.RecordLastError(ctx, err)
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
}
+404
View File
@@ -0,0 +1,404 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/havline/havline/internal/cloudflared"
)
type CloudflareHandler struct {
svc *cloudflared.Service
}
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
return &CloudflareHandler{svc: svc}
}
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Create(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Update(r.Context(), id, input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Start)
}
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Stop)
}
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) RefreshCredentials(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.RefreshCredentials)
}
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := fn(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
status, err := h.svc.RuntimeStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
output, err := h.svc.Logs(r.Context(), id, lines)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"output": output})
}
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
content, err := h.svc.PreviewConfig(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) Routes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
routes, err := h.svc.Routes(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) ReplaceRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var request struct {
Routes []cloudflared.RouteInput `json:"routes"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
routes, err := h.svc.ReplaceRoutes(r.Context(), id, request.Routes)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) Drift(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Drift(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) SyncRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.PushTunnel(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Diagnostics(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Diagnostics(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) Preflight(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Preflight(r.Context()))
}
func (h *CloudflareHandler) TestRoute(w http.ResponseWriter, r *http.Request) {
var request struct {
Service string `json:"service"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.TestService(r.Context(), request.Service)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
"mirrors": cloudflared.DefaultMirrors(),
})
}
func (h *CloudflareHandler) AppSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.AppSettings(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Request) {
var input cloudflared.AppSettingsInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
settings, err := h.svc.SaveAppSettings(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) {
var request struct {
AccountID string `json:"account_id"`
Token string `json:"token"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
message, err := h.svc.TestToken(r.Context(), request.AccountID, request.Token, request.Kind)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message})
}
func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
settings, err := h.svc.SetEnabled(r.Context(), request.Enabled)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
Version string `json:"version"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
version, err := h.svc.DownloadBinaryVersion(r.Context(), request.Mirror, request.Version)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) LatestBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.LatestBinaryVersion(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"version": version})
}
func (h *CloudflareHandler) RollbackBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.RollbackBinary()
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.IngressCandidates(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func (h *CloudflareHandler) Zones(w http.ResponseWriter, r *http.Request) {
zones, err := h.svc.Zones(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (h *CloudflareHandler) AccessStatus(w http.ResponseWriter, r *http.Request) {
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
enabled, name, err := h.svc.AccessStatus(r.Context(), hostname)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "name": name})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
network.TransportProtocol = defaults.TransportProtocol
}
if strings.TrimSpace(network.EdgeIPVersion) == "" {
network.EdgeIPVersion = defaults.EdgeIPVersion
}
if network.HAConnections <= 0 {
network.HAConnections = defaults.HAConnections
}
if strings.TrimSpace(network.ProxyMode) == "" {
network.ProxyMode = defaults.ProxyMode
}
return network
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/havline/havline/internal/ddns"
)
type DDNSHandler struct {
svc *ddns.Service
}
func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
return &DDNSHandler{svc: svc}
}
type ddnsRequest struct {
Provider string `json:"provider"`
RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"`
Domains []string `json:"domains"`
IPv4Enabled *bool `json:"ipv4_enabled"`
IPv6Enabled *bool `json:"ipv6_enabled"`
Enabled *bool `json:"enabled"`
CustomIPv4 string `json:"custom_ipv4"`
CustomIPv6 string `json:"custom_ipv6"`
APIToken string `json:"api_token"`
APITokenID string `json:"api_token_id"`
APISecret string `json:"api_secret"`
}
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
return ddns.SaveInput{
Provider: req.Provider,
RootDomain: req.RootDomain,
RecordName: req.RecordName,
RecordNames: req.RecordNames,
Domains: req.Domains,
IPv4Enabled: boolDefault(req.IPv4Enabled, true),
IPv6Enabled: boolDefault(req.IPv6Enabled, false),
Enabled: boolDefault(req.Enabled, true),
CustomIPv4: req.CustomIPv4,
CustomIPv6: req.CustomIPv6,
APIToken: req.APIToken,
APITokenID: req.APITokenID,
APISecret: req.APISecret,
}
}
func (h *DDNSHandler) List(w http.ResponseWriter, r *http.Request) {
var configs []ddns.Config
var err error
if r.URL.Query().Get("lite") == "1" {
configs, err = h.svc.ListLite(r.Context())
} else {
configs, err = h.svc.List(r.Context())
}
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取 DDNS 配置失败")
return
}
writeJSON(w, http.StatusOK, configs)
}
func (h *DDNSHandler) Create(w http.ResponseWriter, r *http.Request) {
var req ddnsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
cfg, err := h.svc.Create(r.Context(), h.toInput(req))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
var req ddnsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
cfg, err := h.svc.Update(r.Context(), id, h.toInput(req))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
var req struct {
ConfigID int64 `json:"config_id"`
Provider string `json:"provider"`
APIToken string `json:"api_token"`
APITokenID string `json:"api_token_id"`
APISecret string `json:"api_secret"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.svc.Test(r.Context(), ddns.TestInput{
ConfigID: req.ConfigID,
Provider: req.Provider,
APIToken: req.APIToken,
APITokenID: req.APITokenID,
APISecret: req.APISecret,
}); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "连接成功"})
}
func (h *DDNSHandler) UpdateOne(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
cfg, err := h.svc.UpdateNow(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) UpdateAll(w http.ResponseWriter, r *http.Request) {
configs, err := h.svc.UpdateAll(r.Context())
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, configs)
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"io/fs"
"log/slog"
"github.com/havline/havline/internal/acme"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/chinacidr"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/metrics"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/traffic"
"github.com/havline/havline/internal/updater"
)
type Deps struct {
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Cloudflare *cloudflared.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
}
+20
View File
@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/discovery"
)
type DiscoveryHandler struct {
svc *discovery.Service
}
func NewDiscoveryHandler(svc *discovery.Service) *DiscoveryHandler {
return &DiscoveryHandler{svc: svc}
}
func (h *DiscoveryHandler) Scan(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
writeJSON(w, http.StatusOK, h.svc.Scan(r.Context(), host))
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"net/http"
"path/filepath"
"strconv"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/logstore"
)
type LogsHandler struct {
cfg config.Config
}
func NewLogsHandler(cfg config.Config) *LogsHandler {
return &LogsHandler{cfg: cfg}
}
func (h *LogsHandler) Access(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
keyword := r.URL.Query().Get("keyword")
status := queryInt(r, "status", 0)
entries, err := logstore.ReadAccess(filepath.Join(h.cfg.LogsDir(), "access.log"), limit, keyword, status)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取访问日志失败")
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *LogsHandler) Error(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
keyword := r.URL.Query().Get("keyword")
lines, err := logstore.ReadError(filepath.Join(h.cfg.LogsDir(), "error.log"), limit, keyword)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取错误日志失败")
return
}
writeJSON(w, http.StatusOK, lines)
}
func (h *LogsHandler) System(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
level := r.URL.Query().Get("level")
keyword := r.URL.Query().Get("keyword")
entries, err := logstore.ReadSystem(filepath.Join(h.cfg.LogsDir(), "app.log"), limit, level, keyword)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取系统日志失败")
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *LogsHandler) Stream(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("type")
if kind == "" {
kind = "error"
}
var path string
switch kind {
case "access":
path = filepath.Join(h.cfg.LogsDir(), "access.log")
case "error":
path = filepath.Join(h.cfg.LogsDir(), "error.log")
default:
writeError(r, w, http.StatusBadRequest, "实时日志仅支持 Nginx 访问/错误日志")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
tail := queryInt(r, "tail", 100)
var filter logstore.LineFilter
if kind == "access" {
filter = logstore.AccessHostFilter(r.URL.Query()["host"])
}
_ = logstore.StreamFile(r.Context(), path, w, func() error {
flusher.Flush()
return nil
}, tail, filter)
}
func (h *LogsHandler) StreamAccessForHosts(w http.ResponseWriter, r *http.Request, hosts []string) {
endpoints := make([]logstore.AccessEndpoint, 0, len(hosts))
for _, host := range hosts {
endpoints = append(endpoints, logstore.AccessEndpoint{Host: host})
}
h.StreamAccessForEndpoints(w, r, endpoints)
}
func (h *LogsHandler) StreamAccessForEndpoints(w http.ResponseWriter, r *http.Request, endpoints []logstore.AccessEndpoint) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
path := filepath.Join(h.cfg.LogsDir(), "access.log")
tail := queryInt(r, "tail", 100)
filter := logstore.AccessEndpointFilter(endpoints)
_ = logstore.StreamFile(r.Context(), path, w, func() error {
flusher.Flush()
return nil
}, tail, filter)
}
func queryInt(r *http.Request, key string, fallback int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return n
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
func TestLogsAccessRoute(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
authSvc := auth.New(conn)
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
t.Fatalf("setup: %v", err)
}
sessionID, err := authSvc.Login(context.Background(), "admin", "password123")
if err != nil {
t.Fatalf("login: %v", err)
}
handler := NewRouter(Deps{
Config: config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")},
Auth: authSvc,
Proxy: service.NewProxyService(config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil),
Backup: backup.New(dir),
Discovery: discovery.New(),
StartedAt: time.Now().UTC().Format(time.RFC3339),
})
req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid json: %v", err)
}
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/havline/havline/internal/metrics"
)
type MetricsHandler struct {
store *metrics.Store
}
func NewMetricsHandler(store *metrics.Store) *MetricsHandler {
return &MetricsHandler{store: store}
}
// History 返回指标历史:窗口内的序列已按桶取平均,前端拿到就能直接画。
// hours 最多 30 天(=保留期),buckets 最多 metrics.MaxBuckets。
func (h *MetricsHandler) History(w http.ResponseWriter, r *http.Request) {
hours := 24
if raw := r.URL.Query().Get("hours"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*30 {
hours = parsed
}
}
buckets := 48
if raw := r.URL.Query().Get("buckets"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
buckets = parsed
}
}
if buckets > metrics.MaxBuckets {
buckets = metrics.MaxBuckets
}
series, err := h.store.Query(r.Context(), time.Duration(hours)*time.Hour, buckets)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取指标历史失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"hours": hours,
"buckets": buckets,
"series": series,
})
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/havline/havline/internal/auth"
)
type contextKey string
const sessionContextKey contextKey = "session_id"
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("havline_session")
if err == nil && cookie.Value != "" {
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
return
}
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
if tokenDeniedPath(r.URL.Path) {
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
return
}
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
return
}
next.ServeHTTP(w, r)
return
}
sessionID, _ := r.Context().Value(sessionContextKey).(string)
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return
}
next.ServeHTTP(w, r)
})
}
}
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
const prefix = "Bearer "
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
func tokenDeniedPath(path string) bool {
if path == "/api/auth/password" || path == "/api/auth/logout" {
return true
}
return strings.HasPrefix(path, "/api/settings/tokens") ||
strings.HasPrefix(path, "/api/system/update")
}
func sessionIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(sessionContextKey).(string)
return v
}
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/db"
)
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := auth.New(conn)
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
if err != nil {
t.Fatalf("create token: %v", err)
}
return svc, created.Token
}
func okTokenHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
}
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
}
}
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
}
}
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
cases := map[string]string{
"未知令牌": "Bearer deadbeef",
"错误前缀": "Token abc",
"没有凭据": "",
}
for name, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
}
}
}
func TestBearerTokenParsing(t *testing.T) {
cases := map[string]string{
"Bearer abc": "abc",
"bearer abc": "abc",
"Bearer abc": "abc",
"Basic abc": "",
"Bearer ": "",
"": "",
}
for header, want := range cases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
if got := bearerToken(req); got != want {
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"encoding/json"
"net/http"
"github.com/havline/havline/internal/service"
)
type NginxHandler struct {
proxy *service.ProxyService
}
func NewNginxHandler(proxy *service.ProxyService) *NginxHandler {
return &NginxHandler{proxy: proxy}
}
type saveRuleNginxRequest struct {
Mode string `json:"mode"`
Content *string `json:"content"`
}
type rollbackNginxRequest struct {
Backup string `json:"backup"`
}
func (h *NginxHandler) GetRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
view, err := h.proxy.GetRuleNginx(r.Context(), id)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) PutRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req saveRuleNginxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
view, err := h.proxy.SaveRuleNginx(r.Context(), id, service.SaveRuleNginxInput{
Mode: req.Mode,
Content: req.Content,
})
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req rollbackNginxRequest
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
}
view, err := h.proxy.RollbackRuleNginx(r.Context(), id, req.Backup)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetGlobal(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.GetGlobalNginx(r.Context())
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) PutGlobal(w http.ResponseWriter, r *http.Request) {
var req saveRuleNginxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
view, err := h.proxy.SaveGlobalNginx(r.Context(), service.SaveGlobalNginxInput{
Mode: req.Mode,
Content: req.Content,
})
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
var req rollbackNginxRequest
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
}
view, err := h.proxy.RollbackGlobalNginx(r.Context(), req.Backup)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersions(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.GetNginxConfigVersions()
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.ReadNginxConfigVersion(r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.RollbackNginxConfigVersion(r.Context(), r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
if err == nil {
return
}
msg := err.Error()
if msg == "规则不存在" {
writeError(r, w, http.StatusNotFound, msg, err)
return
}
writeError(r, w, http.StatusBadRequest, msg, err)
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"encoding/json"
"net/http"
"github.com/havline/havline/internal/notify"
)
type NotifyHandler struct {
notify *notify.Service
}
func NewNotifyHandler(notifySvc *notify.Service) *NotifyHandler {
return &NotifyHandler{notify: notifySvc}
}
func (h *NotifyHandler) Test(w http.ResponseWriter, r *http.Request) {
if h.notify == nil {
writeError(r, w, http.StatusInternalServerError, "通知服务未初始化")
return
}
var req notify.TestInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.notify.SendTest(r.Context(), req); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "测试通知已发送"})
}
+277
View File
@@ -0,0 +1,277 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/logstore"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/traffic"
)
type ProxyHandler struct {
cfg config.Config
svc *service.ProxyService
logs *LogsHandler
traffic *traffic.Collector
}
func NewProxyHandler(cfg config.Config, svc *service.ProxyService, logs *LogsHandler, collector *traffic.Collector) *ProxyHandler {
return &ProxyHandler{cfg: cfg, svc: svc, logs: logs, traffic: collector}
}
type proxyRequest struct {
Domain string `json:"domain"`
Upstream string `json:"upstream"`
ListenPort *int `json:"listen_port"`
ListenIPv4 *bool `json:"listen_ipv4"`
ListenIPv6 *bool `json:"listen_ipv6"`
Hosts []string `json:"hosts"`
HTTPSEnabled *bool `json:"https_enabled"`
HTTPRedirect *bool `json:"http_redirect"`
Enabled *bool `json:"enabled"`
Name *string `json:"name"`
Remark *string `json:"remark"` // deprecated alias for name
Security *securityRequest `json:"security,omitempty"`
Exits []string `json:"exits"`
CFTunnelID *int64 `json:"cf_tunnel_id"`
}
type proxyReorderRequest struct {
IDs []int64 `json:"ids"`
}
func (h *ProxyHandler) List(w http.ResponseWriter, r *http.Request) {
rules, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
return
}
writeJSON(w, http.StatusOK, proxy.RulesForAPI(rules))
}
func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
var req proxyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
hosts := req.hosts()
if len(hosts) == 0 {
writeError(r, w, http.StatusBadRequest, "至少需要一个前端域名")
return
}
listenPort := intDefault(req.ListenPort, h.cfg.DefaultListenPort(boolDefault(req.HTTPSEnabled, true)))
name := ""
if resolved := req.resolveName(); resolved != nil {
name = *resolved
}
in := proxy.CreateInput{
Upstream: req.Upstream,
ListenPort: listenPort,
ListenIPv4: boolDefault(req.ListenIPv4, true),
ListenIPv6: boolDefault(req.ListenIPv6, false),
Hosts: hosts,
HTTPSEnabled: boolDefault(req.HTTPSEnabled, true),
HTTPRedirect: boolDefault(req.HTTPRedirect, true),
Enabled: boolDefault(req.Enabled, true),
Name: name,
Security: req.securityInput(),
Exits: req.Exits,
CFTunnelID: int64Default(req.CFTunnelID, 0),
}
rule, err := h.svc.Create(r.Context(), in)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, proxy.RuleForAPI(rule))
}
func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req proxyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
in := proxy.UpdateInput{}
if req.Upstream != "" {
in.Upstream = &req.Upstream
}
if req.ListenPort != nil {
in.ListenPort = req.ListenPort
}
if req.ListenIPv4 != nil {
in.ListenIPv4 = req.ListenIPv4
}
if req.ListenIPv6 != nil {
in.ListenIPv6 = req.ListenIPv6
}
if hosts := req.hosts(); len(hosts) > 0 {
in.Hosts = &hosts
}
if req.HTTPSEnabled != nil {
in.HTTPSEnabled = req.HTTPSEnabled
}
if req.HTTPRedirect != nil {
in.HTTPRedirect = req.HTTPRedirect
}
if req.Enabled != nil {
in.Enabled = req.Enabled
}
if resolved := req.resolveName(); resolved != nil {
in.Name = resolved
}
if req.Security != nil {
sec := req.securityInput()
in.Security = &sec
}
if req.Exits != nil {
in.Exits = &req.Exits
}
if req.CFTunnelID != nil {
in.CFTunnelID = req.CFTunnelID
}
rule, err := h.svc.Update(r.Context(), id, in)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, proxy.RuleForAPI(rule))
}
func (h *ProxyHandler) Traffic(w http.ResponseWriter, r *http.Request) {
if h.traffic == nil {
writeJSON(w, http.StatusOK, []traffic.RuleTraffic{})
return
}
rules, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
return
}
writeJSON(w, http.StatusOK, h.traffic.SnapshotForRules(rules))
}
func (h *ProxyHandler) Clients(w http.ResponseWriter, r *http.Request) {
if h.traffic == nil {
writeJSON(w, http.StatusOK, []traffic.ClientConn{})
return
}
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
rule, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, "规则不存在")
return
}
writeJSON(w, http.StatusOK, h.traffic.ClientsForRule(rule))
}
func (h *ProxyHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
rule, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, "规则不存在")
return
}
endpoints := rule.Endpoints()
if len(endpoints) == 0 {
writeError(r, w, http.StatusBadRequest, "该规则没有前端域名")
return
}
matches := make([]logstore.AccessEndpoint, 0, len(endpoints))
for _, ep := range endpoints {
matches = append(matches, logstore.AccessEndpoint{Host: ep.Hostname, Port: ep.Port})
}
h.logs.StreamAccessForEndpoints(w, r, matches)
}
func (h *ProxyHandler) Reorder(w http.ResponseWriter, r *http.Request) {
var req proxyReorderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.svc.Reorder(r.Context(), req.IDs); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ProxyHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (req proxyRequest) resolveName() *string {
if req.Name != nil {
return req.Name
}
return req.Remark
}
func (req proxyRequest) hosts() []string {
if len(req.Hosts) > 0 {
return req.Hosts
}
if req.Domain != "" {
return []string{req.Domain}
}
return nil
}
func intDefault(v *int, fallback int) int {
if v == nil {
return fallback
}
return *v
}
func parseID(raw string) (int64, error) {
return strconv.ParseInt(raw, 10, 64)
}
func boolDefault(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
func int64Default(v *int64, fallback int64) int64 {
if v == nil {
return fallback
}
return *v
}
+60
View File
@@ -0,0 +1,60 @@
package api
import (
"github.com/havline/havline/internal/proxy"
)
type securityRequest struct {
IPBlacklist []string `json:"ip_blacklist,omitempty"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPWhitelistMode *bool `json:"ip_whitelist_mode,omitempty"`
IPBlacklistText *string `json:"ip_blacklist_text,omitempty"`
IPWhitelistText *string `json:"ip_whitelist_text,omitempty"`
ChinaOnly *bool `json:"china_only,omitempty"`
BasicAuth *basicAuthRequest `json:"basic_auth,omitempty"`
RateLimit *proxy.RateLimitConfig `json:"rate_limit,omitempty"`
ConnLimit *proxy.ConnLimitConfig `json:"conn_limit,omitempty"`
ProxySSLVerifyOff *bool `json:"proxy_ssl_verify_off,omitempty"`
ProxyHostUpstream *bool `json:"proxy_host_upstream,omitempty"`
TLSMin13Only *bool `json:"tls_min_13_only,omitempty"`
SecurityHeaders *bool `json:"security_headers,omitempty"`
}
type basicAuthRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
}
func (req *proxyRequest) securityInput() proxy.SecurityInput {
if req.Security == nil {
return proxy.SecurityInput{}
}
s := req.Security
in := proxy.SecurityInput{
IPBlacklist: s.IPBlacklist,
IPWhitelist: s.IPWhitelist,
IPWhitelistMode: s.IPWhitelistMode,
ChinaOnly: s.ChinaOnly,
RateLimit: s.RateLimit,
ConnLimit: s.ConnLimit,
ProxySSLVerifyOff: s.ProxySSLVerifyOff,
ProxyHostUpstream: s.ProxyHostUpstream,
TLSMin13Only: s.TLSMin13Only,
SecurityHeaders: s.SecurityHeaders,
}
if s.IPBlacklistText != nil {
in.IPBlacklist = proxy.ParseIPListText(*s.IPBlacklistText)
}
if s.IPWhitelistText != nil {
in.IPWhitelist = proxy.ParseIPListText(*s.IPWhitelistText)
}
if s.BasicAuth != nil {
in.BasicAuth = &proxy.BasicAuthInput{
Enabled: s.BasicAuth.Enabled,
Username: s.BasicAuth.Username,
Password: s.BasicAuth.Password,
}
}
return in
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"reflect"
)
type ErrorBody struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
_, _ = w.Write([]byte("null"))
return
}
// Go nil slices/maps encode as JSON null; clients expect [] / {}.
v := reflect.ValueOf(payload)
switch v.Kind() {
case reflect.Slice:
if v.IsNil() {
_, _ = w.Write([]byte("[]"))
return
}
case reflect.Map:
if v.IsNil() {
_, _ = w.Write([]byte("{}"))
return
}
}
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(r *http.Request, w http.ResponseWriter, status int, message string, cause ...error) {
logAPIError(r, status, message, cause)
writeJSON(w, status, ErrorBody{Error: message})
}
func logAPIError(r *http.Request, status int, message string, cause []error) {
if status < 400 {
return
}
attrs := []any{
"module", "API",
"status", status,
"message", message,
}
if r != nil {
attrs = append(attrs, "method", r.Method, "path", r.URL.Path)
}
if len(cause) > 0 && cause[0] != nil {
attrs = append(attrs, "error", cause[0].Error())
}
switch {
case status >= 500:
slog.Error("request failed", attrs...)
default:
slog.Warn("request rejected", attrs...)
}
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteJSONNilSlice(t *testing.T) {
var items []string
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, items)
if rec.Body.String() != "[]" {
t.Fatalf("expected [], got %q", rec.Body.String())
}
}
func TestWriteJSONNilMap(t *testing.T) {
var values map[string]string
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, values)
if rec.Body.String() != "{}" {
t.Fatalf("expected {}, got %q", rec.Body.String())
}
}
func TestWriteJSONSlice(t *testing.T) {
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, []int{1, 2})
var out []int
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Fatalf("expected 2 items, got %v", out)
}
}
func TestWriteErrorLogsClientError(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodPost, "/api/settings/notify/test", nil)
rec := httptest.NewRecorder()
writeError(req, rec, http.StatusBadRequest, "请填写 SMTP 主机")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
if !bytes.Contains(buf.Bytes(), []byte("请填写 SMTP 主机")) {
t.Fatalf("expected log message, got %s", buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("/api/settings/notify/test")) {
t.Fatalf("expected log path, got %s", buf.String())
}
}
+328
View File
@@ -0,0 +1,328 @@
package api
import (
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"github.com/havline/havline/internal/auth"
)
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
type routeSpec struct {
Pattern string
Handler http.HandlerFunc
Public bool
}
func NewRouter(deps Deps) http.Handler {
handler, _ := newRouterWithSpecs(deps)
return handler
}
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
logsHandler := NewLogsHandler(deps.Config)
r := &Router{
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
ddnsHandler: NewDDNSHandler(deps.DDNS),
certHandler: NewCertHandler(deps.ACME),
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
logsHandler: logsHandler,
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
}
mux := http.NewServeMux()
var specs []routeSpec
public := func(pattern string, handler http.HandlerFunc) {
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
}
protected := func(pattern string, handler http.HandlerFunc) {
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler})
}
public("GET /api/version", Version(deps.Config))
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
public("GET /api/status-page", r.statusPageHandler.Get)
public("GET /api/auth/status", r.authHandler.Status)
public("POST /api/auth/login", r.authHandler.Login)
public("POST /api/auth/logout", r.authHandler.Logout)
protect := protected
protect("POST /api/auth/password", r.authHandler.ChangePassword)
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
protect("POST /api/settings/tokens", r.authHandler.CreateToken)
protect("DELETE /api/settings/tokens/{id}", r.authHandler.DeleteToken)
protect("GET /api/status", r.statusHandler.Get)
protect("GET /api/proxies", r.proxyHandler.List)
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
protect("POST /api/proxies", r.proxyHandler.Create)
protect("PUT /api/proxies/reorder", r.proxyHandler.Reorder)
protect("PUT /api/proxies/{id}", r.proxyHandler.Update)
protect("DELETE /api/proxies/{id}", r.proxyHandler.Delete)
protect("GET /api/proxies/{id}/logs/stream", r.proxyHandler.StreamLogs)
protect("GET /api/proxies/{id}/clients", r.proxyHandler.Clients)
protect("GET /api/proxies/{id}/nginx", r.nginxHandler.GetRule)
protect("PUT /api/proxies/{id}/nginx", r.nginxHandler.PutRule)
protect("POST /api/proxies/{id}/nginx/rollback", r.nginxHandler.RollbackRule)
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
protect("GET /api/settings/nginx/config/versions", r.nginxHandler.GetConfigVersions)
protect("GET /api/settings/nginx/config/versions/{name}", r.nginxHandler.GetConfigVersion)
protect("POST /api/settings/nginx/config/versions/{name}/rollback", r.nginxHandler.RollbackConfigVersion)
protect("GET /api/ddns", r.ddnsHandler.List)
protect("POST /api/ddns", r.ddnsHandler.Create)
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
protect("DELETE /api/ddns/{id}", r.ddnsHandler.Delete)
protect("POST /api/ddns/test", r.ddnsHandler.Test)
protect("POST /api/ddns/update", r.ddnsHandler.UpdateAll)
protect("POST /api/ddns/{id}/update", r.ddnsHandler.UpdateOne)
protect("GET /api/certificates", r.certHandler.List)
protect("GET /api/certificates/ca-options", r.certHandler.Options)
protect("POST /api/certificates/apply", r.certHandler.Apply)
protect("GET /api/certificates/jobs/{id}/stream", r.certHandler.ApplyJobStream)
protect("POST /api/certificates/import", r.certHandler.Import)
protect("POST /api/certificates/renew", r.certHandler.Renew)
protect("GET /api/certificates/{domain}/download", r.certHandler.Download)
protect("DELETE /api/certificates/{domain}", r.certHandler.Delete)
notifyHandler := NewNotifyHandler(deps.Notify)
protect("GET /api/settings", r.settingsHandler.Get)
protect("PUT /api/settings", r.settingsHandler.Put)
protect("POST /api/settings/notify/test", notifyHandler.Test)
if deps.ChinaCIDR != nil {
protect("GET /api/settings/china-cidr", r.chinaCIDRHandler.Status)
protect("POST /api/settings/china-cidr/refresh", r.chinaCIDRHandler.Refresh)
}
protect("GET /api/logs/access", r.logsHandler.Access)
protect("GET /api/logs/error", r.logsHandler.Error)
protect("GET /api/logs/system", r.logsHandler.System)
protect("GET /api/metrics/history", r.metricsHandler.History)
protect("GET /api/logs/stream", r.logsHandler.Stream)
protect("GET /api/system/update/status", r.updateHandler.Status)
protect("POST /api/system/update/apply", r.updateHandler.Apply)
protect("GET /api/backup/export", r.backupHandler.Export)
protect("POST /api/backup/restore", r.backupHandler.Restore)
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
protect("POST /api/backup/archives", r.backupHandler.CreateArchive)
protect("GET /api/backup/archives/{name}", r.backupHandler.DownloadArchive)
protect("POST /api/backup/archives/{name}/restore", r.backupHandler.RestoreArchive)
protect("DELETE /api/backup/archives/{name}", r.backupHandler.DeleteArchive)
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
if deps.FRPMulti != nil {
frpHandler := r.frpmultiHandler
protect("GET /api/frpmulti/status", frpHandler.Status)
protect("GET /api/frpmulti/runtime", frpHandler.Runtime)
protect("PUT /api/frpmulti/runtime/download-proxy", frpHandler.SetDownloadProxy)
protect("GET /api/frpmulti/releases", frpHandler.Releases)
protect("GET /api/frpmulti/binaries", frpHandler.Binaries)
protect("GET /api/frpmulti/download", frpHandler.DownloadStatus)
protect("POST /api/frpmulti/releases/{version}/download", frpHandler.DownloadRelease)
protect("POST /api/frpmulti/binaries/{version}/activate", frpHandler.ActivateBinary)
protect("DELETE /api/frpmulti/binaries/{version}", frpHandler.DeleteBinary)
protect("GET /api/frpmulti/servers", frpHandler.ListServers)
protect("GET /api/frpmulti/servers/{id}/detail", frpHandler.ServerDetail)
protect("GET /api/frpmulti/servers/{id}/diagnostics", frpHandler.ServerDiagnostics)
protect("GET /api/frpmulti/servers/{id}/frps-config", frpHandler.FRPSServerConfig)
protect("POST /api/frpmulti/servers/{id}/start", frpHandler.StartServer)
protect("POST /api/frpmulti/servers/{id}/stop", frpHandler.StopServer)
protect("POST /api/frpmulti/servers", frpHandler.CreateServer)
protect("PUT /api/frpmulti/servers/{id}", frpHandler.UpdateServer)
protect("DELETE /api/frpmulti/servers/{id}", frpHandler.DeleteServer)
protect("POST /api/frpmulti/servers/{id}/test", frpHandler.TestServer)
protect("POST /api/frpmulti/servers/{id}/agent/probe", frpHandler.AgentProbe)
protect("POST /api/frpmulti/servers/{id}/agent/install", frpHandler.AgentInstall)
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
protect("GET /api/frpmulti/servers/{id}/agent/transport", frpHandler.AgentTransportStatus)
protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert)
protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus)
protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow)
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
protect("GET /api/frpmulti/routes/uptime", frpHandler.RouteUptime)
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs)
protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig)
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}/rollback", frpHandler.AgentRollbackRouteVersion)
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
protect("POST /api/frpmulti/servers/{id}/agent/frps-install", frpHandler.InstallFRPSOnAgent)
protect("POST /api/frpmulti/servers/{id}/agent/frps-action", frpHandler.AgentFRPSAction)
protect("POST /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.PushFRPSConfigToAgent)
protect("GET /api/frpmulti/proxies", frpHandler.ListProxies)
protect("GET /api/frpmulti/proxies/traffic", frpHandler.ListProxiesTraffic)
protect("POST /api/frpmulti/proxies", frpHandler.CreateProxy)
protect("PUT /api/frpmulti/proxies/{id}", frpHandler.UpdateProxy)
protect("DELETE /api/frpmulti/proxies/{id}", frpHandler.DeleteProxy)
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
protect("POST /api/frpmulti/start", frpHandler.Start)
protect("POST /api/frpmulti/stop", frpHandler.Stop)
protect("POST /api/frpmulti/reload", frpHandler.Reload)
protect("GET /api/frpmulti/logs", frpHandler.Logs)
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
}
if deps.Cloudflare != nil {
cf := r.cloudflareHandler
protect("GET /api/cloudflare/tunnels", cf.List)
protect("POST /api/cloudflare/tunnels", cf.Create)
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
protect("POST /api/cloudflare/tunnels/{id}/credentials/refresh", cf.RefreshCredentials)
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
protect("GET /api/cloudflare/tunnels/{id}/routes", cf.Routes)
protect("PUT /api/cloudflare/tunnels/{id}/routes", cf.ReplaceRoutes)
protect("GET /api/cloudflare/tunnels/{id}/drift", cf.Drift)
protect("POST /api/cloudflare/tunnels/{id}/sync", cf.SyncRoutes)
protect("GET /api/cloudflare/tunnels/{id}/diagnostics", cf.Diagnostics)
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
protect("GET /api/cloudflare/binary/latest", cf.LatestBinary)
protect("POST /api/cloudflare/binary/rollback", cf.RollbackBinary)
protect("GET /api/cloudflare/settings", cf.AppSettings)
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
protect("POST /api/cloudflare/settings/test", cf.TestToken)
protect("POST /api/cloudflare/enabled", cf.SetEnabled)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
protect("GET /api/cloudflare/zones", cf.Zones)
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
protect("GET /api/cloudflare/preflight", cf.Preflight)
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
}
if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler())
}
for _, spec := range specs {
if spec.Public {
mux.HandleFunc(spec.Pattern, spec.Handler)
continue
}
mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler)))
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
}
type Router struct {
authHandler *AuthHandler
proxyHandler *ProxyHandler
nginxHandler *NginxHandler
statusHandler *StatusHandler
ddnsHandler *DDNSHandler
certHandler *CertHandler
settingsHandler *SettingsHandler
chinaCIDRHandler *ChinaCIDRHandler
logsHandler *LogsHandler
backupHandler *BackupHandler
statusPageHandler *StatusPageHandler
metricsHandler *MetricsHandler
discoveryHandler *DiscoveryHandler
frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
authSvc *auth.Service
staticFS fs.FS
}
func (r *Router) spaHandler() http.Handler {
fileServer := http.FileServer(http.FS(r.staticFS))
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/api/") {
http.NotFound(w, req)
return
}
path := strings.TrimPrefix(req.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(r.staticFS, path); err != nil {
// SPA fallback: unknown routes serve index.html
req.URL.Path = "/index.html"
}
fileServer.ServeHTTP(w, req)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (w *statusRecorder) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if strings.HasPrefix(r.URL.Path, "/api/") &&
!strings.Contains(r.URL.Path, "/logs/stream") &&
!strings.Contains(r.URL.Path, "/certificates/jobs/") &&
rec.status >= 400 {
level := slog.LevelWarn
msg := "api request completed with client error"
if rec.status >= 500 {
level = slog.LevelError
msg = "api request failed"
}
logger.Log(r.Context(), level, msg,
"module", "HTTP",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
}
})
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/settings"
)
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
func TestProtectedRoutesRequireAuth(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
authSvc := auth.New(conn)
box, err := secret.NewBox("router-auth-test-key")
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
handler, specs := newRouterWithSpecs(Deps{
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Cloudflare: cfSvc,
Settings: settings.NewStore(conn),
Logger: logger,
})
protected, public := 0, 0
for _, spec := range specs {
req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil)
code := callRouteSafely(handler, req)
if spec.Public {
public++
if code == http.StatusUnauthorized {
t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern)
}
continue
}
protected++
if code != http.StatusUnauthorized {
t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code)
}
}
if protected < 100 {
t.Fatalf("受保护路由覆盖不足:%d 条", protected)
}
if public < 4 {
t.Fatalf("公开路由覆盖不足:%d 条", public)
}
}
func routeMethod(pattern string) string {
method, _, _ := strings.Cut(pattern, " ")
return method
}
func sampleRoutePath(pattern string) string {
_, path, _ := strings.Cut(pattern, " ")
return routeParamPattern.ReplaceAllString(path, "1")
}
// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。
func callRouteSafely(handler http.Handler, req *http.Request) (code int) {
defer func() {
if recover() != nil {
code = 0
}
}()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/validate"
)
type SettingsHandler struct {
store *settings.Store
proxy *service.ProxyService
notify *notify.Service
}
func NewSettingsHandler(store *settings.Store, proxySvc *service.ProxyService, notifySvc *notify.Service) *SettingsHandler {
return &SettingsHandler{store: store, proxy: proxySvc, notify: notifySvc}
}
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
values, err := h.store.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
return
}
notify.SanitizeSettings(values)
// 状态页访问码只回掩码:既不下发明文也不下发哈希
if values[settings.KeyStatusPageCodeHash] != "" {
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
}
delete(values, settings.KeyStatusPageCodeHash)
writeJSON(w, http.StatusOK, values)
}
func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
var req map[string]string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
allowed := map[string]bool{
settings.KeyTheme: true,
settings.KeyTimezone: true,
settings.KeyLogRetentionDays: true,
settings.KeyDDNSCheckInterval: true,
settings.KeyCertRenewThreshold: true,
settings.KeyRootDomain: true,
settings.KeyACMEEmail: true,
settings.KeyACMECA: true,
settings.KeyZeroSSLAPIKey: true,
settings.KeyACMEDNSResolvers: true,
settings.KeyLiteSSLEABKid: true,
settings.KeyLiteSSLEABHMAC: true,
settings.KeyNotifyWebhookURL: true,
settings.KeyNotifyType: true,
settings.KeyNotifyEmailJSON: true,
settings.KeyNotifySMTPPassword: true,
settings.KeyNotifyWebhookJSON: true,
settings.KeyNotifyWebhookSecret: true,
settings.KeyNotifyTelegramJSON: true,
settings.KeyNotifyTelegramToken: true,
settings.KeyNotifyOnDDNSIPChange: true,
settings.KeyNotifyOnDDNSFailure: true,
settings.KeyNotifyOnCertExpiry: true,
settings.KeyNotifyOnCertRenewSuccess: true,
settings.KeyNotifyOnIPFrequentAccess: true,
settings.KeyNotifyOnCertRenewFailure: true,
settings.KeyNotifyOnLoginFailure: true,
settings.KeyNotifyOnNginxReloadFailure: true,
settings.KeyNotifyIPFrequentThreshold: true,
settings.KeyNotifyIPFrequentWindowSec: true,
settings.KeyNotifyLoginFailureThreshold: true,
settings.KeyNotifyLoginFailureWindowSec: true,
settings.KeyNotifyOnResourceThreshold: true,
settings.KeyNotifyOnTunnelDown: true,
settings.KeyNotifyCPUThreshold: true,
settings.KeyNotifyMemoryThreshold: true,
settings.KeyNotifyDiskThreshold: true,
settings.KeyNotifyTunnelFailThreshold: true,
settings.KeyNotifyOnFrpsDown: true,
settings.KeyNotifyOnAgentUnreachable: true,
settings.KeyNotifyOnRouteDrift: true,
settings.KeyNotifyFrpsFailThreshold: true,
settings.KeyNotifyAgentFailThreshold: true,
settings.KeyNotifyDriftCooldownHours: true,
settings.KeyBackupAutoEnabled: true,
settings.KeyRouteHealthEnabled: true,
settings.KeyStatusPageEnabled: true,
settings.KeyMetricsHistoryEnabled: true,
settings.KeyStatusPageCode: true,
settings.KeyBackupKeepCount: true,
settings.KeyNotifyOnBackupFailure: true,
settings.KeyTrustedProxy: true,
settings.KeyGlobalIPBlacklist: true,
settings.KeyGlobalIPWhitelist: true,
settings.KeyChinaCIDRUpdateHours: true,
settings.KeyChinaCIDRSourceV4: true,
settings.KeyChinaCIDRSourceV6: true,
}
filtered := map[string]string{}
for key, value := range req {
if !allowed[key] {
continue
}
if err := h.validateSetting(key, value); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
filtered[key] = value
}
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
delete(filtered, settings.KeyStatusPageCode)
trimmed := strings.TrimSpace(code)
switch {
case trimmed == MaskedStatusPageCode:
case trimmed == "":
filtered[settings.KeyStatusPageCodeHash] = ""
default:
filtered[settings.KeyStatusPageCodeHash] = hashStatusPageCode(trimmed)
}
}
if h.notify != nil && h.notify.Store() != nil {
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
if err := h.notify.Store().Save(r.Context(), in); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
for key := range filtered {
if isNotifySettingKey(key) {
delete(filtered, key)
}
}
}
}
if err := h.store.SetMany(r.Context(), filtered); err != nil {
writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
return
}
if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
if err := h.proxy.ReloadAll(r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
return
}
}
values, err := h.store.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
return
}
notify.SanitizeSettings(values)
// 状态页访问码只回掩码:既不下发明文也不下发哈希
if values[settings.KeyStatusPageCodeHash] != "" {
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
}
delete(values, settings.KeyStatusPageCodeHash)
writeJSON(w, http.StatusOK, values)
}
func isNotifySettingKey(key string) bool {
switch key {
case settings.KeyNotifyType,
settings.KeyNotifyEmailJSON,
settings.KeyNotifySMTPPassword,
settings.KeyNotifyWebhookJSON,
settings.KeyNotifyWebhookSecret,
settings.KeyNotifyTelegramJSON,
settings.KeyNotifyTelegramToken,
settings.KeyNotifyOnDDNSIPChange,
settings.KeyNotifyOnDDNSFailure,
settings.KeyNotifyOnCertExpiry,
settings.KeyNotifyOnCertRenewSuccess,
settings.KeyNotifyOnIPFrequentAccess,
settings.KeyNotifyOnCertRenewFailure,
settings.KeyNotifyOnLoginFailure,
settings.KeyNotifyOnNginxReloadFailure,
settings.KeyNotifyIPFrequentThreshold,
settings.KeyNotifyIPFrequentWindowSec,
settings.KeyNotifyLoginFailureThreshold,
settings.KeyNotifyLoginFailureWindowSec,
settings.KeyNotifyOnResourceThreshold,
settings.KeyNotifyOnTunnelDown,
settings.KeyNotifyCPUThreshold,
settings.KeyNotifyMemoryThreshold,
settings.KeyNotifyDiskThreshold,
settings.KeyNotifyTunnelFailThreshold,
settings.KeyNotifyOnFrpsDown,
settings.KeyNotifyOnAgentUnreachable,
settings.KeyNotifyOnRouteDrift,
settings.KeyNotifyFrpsFailThreshold,
settings.KeyNotifyAgentFailThreshold,
settings.KeyNotifyDriftCooldownHours,
settings.KeyNotifyOnBackupFailure,
settings.KeyNotifyWebhookURL:
return true
default:
return false
}
}
func (h *SettingsHandler) validateSetting(key, value string) error {
switch key {
case settings.KeyNotifyEmailJSON, settings.KeyNotifyWebhookJSON, settings.KeyNotifyTelegramJSON:
if value != "" {
var raw map[string]any
if err := json.Unmarshal([]byte(value), &raw); err != nil {
return err
}
}
case settings.KeyNotifyType:
switch value {
case "", "email", "webhook", "telegram":
default:
return fmt.Errorf("无效的通知类型")
}
case settings.KeyNotifyIPFrequentThreshold:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 10 || n > 10000 {
return fmt.Errorf("频繁访问阈值需在 10–10000 之间")
}
}
case settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureWindowSec:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 10 || n > 3600 {
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
}
}
case settings.KeyBackupKeepCount:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 1 || n > 60 {
return fmt.Errorf("备份保留份数需在 1–60 之间")
}
}
case settings.KeyNotifyLoginFailureThreshold:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 3 || n > 100 {
return fmt.Errorf("登录失败阈值需在 3–100 之间")
}
}
case settings.KeyGlobalIPBlacklist, settings.KeyGlobalIPWhitelist:
var list []string
if value != "" && value != "[]" {
if err := json.Unmarshal([]byte(value), &list); err != nil {
return err
}
if err := validate.IPOrCIDRList(list); err != nil {
return err
}
}
case settings.KeyTrustedProxy:
if value != "" && value != "{}" {
var raw map[string]any
if err := json.Unmarshal([]byte(value), &raw); err != nil {
return err
}
}
}
return nil
}
+231
View File
@@ -0,0 +1,231 @@
package api
import (
"context"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/acme"
certstore "github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/logstore"
"github.com/havline/havline/internal/publicip"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/sysinfo"
)
type StatusHandler struct {
cfg config.Config
proxySvc *service.ProxyService
ddnsSvc *ddns.Service
acmeSvc *acme.Service
cloudflareSvc *cloudflared.Service
startedAt string
nginxPIDFile string
cpuMu sync.Mutex
cpuSample sysinfo.CPUSample
}
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
_, sample := sysinfo.Read(sysinfo.CPUSample{})
return &StatusHandler{
cfg: cfg,
proxySvc: proxySvc,
ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc,
cloudflareSvc: cloudflareSvc,
startedAt: startedAt,
nginxPIDFile: nginxPIDFile,
cpuSample: sample,
}
}
type statusResponse struct {
PublicIPv4 string `json:"public_ipv4"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPSource string `json:"public_ip_source"`
DDNSStatus string `json:"ddns_status"`
DDNSCount int `json:"ddns_count"`
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
CertificateStatus string `json:"certificate_status"`
CertificateDays int `json:"certificate_days"`
CertificateCount int `json:"certificate_count"`
CertificateSummary string `json:"certificate_summary,omitempty"`
ProxyCount int `json:"proxy_count"`
NginxStatus string `json:"nginx_status"`
RequestToday int `json:"request_today"`
RequestYesterday int `json:"request_yesterday"`
RequestTrend *int `json:"request_trend,omitempty"`
RequestsHourly []int `json:"requests_hourly"`
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
ErrorToday int `json:"error_today"`
AvgResponseMs float64 `json:"avg_response_ms"`
CPUPercent float64 `json:"cpu_percent"`
MemUsedBytes uint64 `json:"mem_used_bytes"`
MemTotalBytes uint64 `json:"mem_total_bytes"`
DiskUsedBytes uint64 `json:"disk_used_bytes"`
DiskTotalBytes uint64 `json:"disk_total_bytes"`
CloudflareCount int `json:"cloudflare_count"`
CloudflareOnline int `json:"cloudflare_online"`
CloudflareStatus string `json:"cloudflare_status"`
CloudflareMessage string `json:"cloudflare_message,omitempty"`
StartedAt string `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
rules, err := h.proxySvc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败")
return
}
nginxStatus := "unknown"
if _, err := os.Stat(h.nginxPIDFile); err == nil {
nginxStatus = "running"
} else if os.IsNotExist(err) {
nginxStatus = "stopped"
}
ddnsStatus, ddnsLastUpdated, ddnsCount := h.ddnsSvc.Summary(r.Context())
ipv4, ipv6, ipSource := h.resolvePublicIPs(r.Context())
records, _ := h.acmeSvc.List(r.Context())
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
h.setCPUSample(cpuSample)
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
if h.cloudflareSvc != nil {
cfSummary = h.cloudflareSvc.Summary(r.Context())
}
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
hourly, hourlyLabels := logstore.HourlyAccessCounts(accessLog)
started, _ := time.Parse(time.RFC3339, h.startedAt)
uptime := time.Since(started).Seconds()
writeJSON(w, http.StatusOK, statusResponse{
PublicIPv4: ipv4,
PublicIPv6: ipv6,
PublicIPSource: ipSource,
DDNSStatus: ddnsStatus,
DDNSCount: ddnsCount,
DDNSLastUpdated: ddnsLastUpdated,
CertificateStatus: certStatus,
CertificateDays: certDays,
CertificateCount: certCount,
CertificateSummary: certSummary,
ProxyCount: len(rules),
NginxStatus: nginxStatus,
RequestToday: total,
RequestYesterday: yesterday,
RequestTrend: requestTrendPercent(total, yesterday),
RequestsHourly: hourly,
RequestsHourlyLabels: hourlyLabels,
ErrorToday: errors,
AvgResponseMs: avgMs,
CPUPercent: resources.CPUPercent,
MemUsedBytes: resources.MemUsedBytes,
MemTotalBytes: resources.MemTotalBytes,
DiskUsedBytes: resources.DiskUsedBytes,
DiskTotalBytes: resources.DiskTotalBytes,
CloudflareCount: cfSummary.Count,
CloudflareOnline: cfSummary.Online,
CloudflareStatus: cfSummary.Status,
CloudflareMessage: cfSummary.Message,
StartedAt: h.startedAt,
UptimeSeconds: int64(uptime),
})
}
// currentCPUSample 与 setCPUSample 用锁保护上次采样,/api/status 可能被并发调用
func (h *StatusHandler) currentCPUSample() sysinfo.CPUSample {
h.cpuMu.Lock()
defer h.cpuMu.Unlock()
return h.cpuSample
}
func (h *StatusHandler) setCPUSample(sample sysinfo.CPUSample) {
h.cpuMu.Lock()
defer h.cpuMu.Unlock()
h.cpuSample = sample
}
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
return ipv4, ipv6, "ddns"
}
if ipv4, ipv6, ok := h.ddnsSvc.LastKnownPublicIPs(ctx); ok {
return ipv4, ipv6, "ddns"
}
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ipv4, ipv6, err := publicip.Detect(detectCtx)
if err != nil {
return "", ipv6, "none"
}
return ipv4, ipv6, "detect"
}
func summarizeCertificates(records []certstore.Record) (status string, days int, count int, summary string) {
if len(records) == 0 {
return "none", 0, 0, ""
}
count = len(records)
status = "ok"
days = records[0].DaysLeft
for _, rec := range records {
if rec.DaysLeft < days {
days = rec.DaysLeft
}
switch {
case rec.Status == "error" || rec.DaysLeft <= 0:
status = "error"
case rec.DaysLeft <= 30 && status != "error":
status = "warning"
}
}
if count == 1 {
summary = certificateSummaryName(records[0])
} else {
summary = fmt.Sprintf("%d 张证书", count)
}
return status, days, count, summary
}
func requestTrendPercent(today, yesterday int) *int {
if yesterday <= 0 {
if today <= 0 {
return nil
}
v := 100
return &v
}
v := int(math.Round(float64(today-yesterday) / float64(yesterday) * 100))
return &v
}
func certificateSummaryName(rec certstore.Record) string {
for _, domain := range rec.Domains {
if strings.HasPrefix(domain, "*.") {
return domain
}
}
if rec.Wildcard {
return "*." + rec.Domain
}
return rec.Domain
}
@@ -0,0 +1,18 @@
package api
import "testing"
func TestRequestTrendPercent(t *testing.T) {
if got := requestTrendPercent(0, 0); got != nil {
t.Fatalf("expected nil trend, got %v", *got)
}
if got := requestTrendPercent(10, 0); got == nil || *got != 100 {
t.Fatalf("expected 100 for new traffic, got %v", got)
}
if got := requestTrendPercent(150, 100); got == nil || *got != 50 {
t.Fatalf("expected 50, got %v", got)
}
if got := requestTrendPercent(50, 100); got == nil || *got != -50 {
t.Fatalf("expected -50, got %v", got)
}
}
+252
View File
@@ -0,0 +1,252 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/settings"
)
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
// 状态页会被扫描器盯上,这里比登录接口更宽松但仍有限度。
const (
statusPageWindow = time.Minute
statusPageLimit = 60
)
// StatusPageHandler 提供**匿名只读**状态页数据,默认关闭。
//
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
type StatusPageHandler struct {
settings *settings.Store
frp *frp.Service
cloudflare *cloudflared.Service
limiter *statusPageLimiter
}
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
return &StatusPageHandler{
settings: settingsStore,
frp: frpSvc,
cloudflare: cloudflareSvc,
limiter: newStatusPageLimiter(),
}
}
type statusPageRoute struct {
Domain string `json:"domain"`
State string `json:"state"`
Source string `json:"source,omitempty"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
Incidents []statusPageIncident `json:"incidents"`
}
type statusPageIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
type statusPagePayload struct {
Title string `json:"title"`
CodeRequired bool `json:"code_required"`
CheckedAt string `json:"checked_at"`
Up int `json:"up"`
Down int `json:"down"`
Unknown int `json:"unknown"`
Routes []statusPageRoute `json:"routes"`
}
// Get 是唯一的匿名入口:关闭时一律 404(与「不存在」不可区分),
// 设了访问码时校验 ?code=,校验失败也回 404。
func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
if h == nil || h.settings == nil {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
if !h.limiter.allow(clientIPForLimit(r)) {
writeError(r, w, http.StatusTooManyRequests, "请求过于频繁")
return
}
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
if !enabled || (h.frp == nil && h.cloudflare == nil) {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
codeHash, err := h.settings.Get(r.Context(), settings.KeyStatusPageCodeHash)
if err != nil {
codeHash = ""
}
codeRequired := strings.TrimSpace(codeHash) != ""
if codeRequired && hashStatusPageCode(r.URL.Query().Get("code")) != codeHash {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
payload := statusPagePayload{
Title: "Havline 服务状态",
CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0),
}
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case frp.RouteHealthUp:
payload.Up++
case frp.RouteHealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "frp",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapFRPIncidents(stat.Incidents),
})
}
return nil
}
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case cloudflared.HealthUp:
payload.Up++
case cloudflared.HealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "cloudflare",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapCloudflareIncidents(stat.Incidents),
})
}
return nil
}
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func hashStatusPageCode(code string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
return hex.EncodeToString(sum[:])
}
// clientIPForLimit 取限流用的客户端 IP
func clientIPForLimit(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// statusPageLimiter 按 IP 的滑动窗口限流;只给匿名状态页用
type statusPageLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
now func() time.Time
}
func newStatusPageLimiter() *statusPageLimiter {
return &statusPageLimiter{hits: map[string][]time.Time{}, now: time.Now}
}
func (l *statusPageLimiter) allow(ip string) bool {
now := l.now()
cutoff := now.Add(-statusPageWindow)
l.mu.Lock()
defer l.mu.Unlock()
kept := make([]time.Time, 0, len(l.hits[ip])+1)
for _, at := range l.hits[ip] {
if at.After(cutoff) {
kept = append(kept, at)
}
}
if len(kept) >= statusPageLimit {
l.hits[ip] = kept
return false
}
l.hits[ip] = append(kept, now)
// 顺手清理长期不活跃的 IP,避免 map 无限增长
if len(l.hits) > 1024 {
for key, times := range l.hits {
if len(times) == 0 {
delete(l.hits, key)
}
}
}
return true
}
// MaskedStatusPageCode 供设置接口回显用(与通知模块的掩码约定一致)
const MaskedStatusPageCode = notify.MaskedSecret
@@ -0,0 +1,43 @@
package api
import (
"testing"
"time"
)
func TestStatusPageLimiterBlocksAfterLimit(t *testing.T) {
limiter := newStatusPageLimiter()
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
limiter.now = func() time.Time { return now }
for i := 0; i < statusPageLimit; i++ {
if !limiter.allow("1.2.3.4") {
t.Fatalf("第 %d 次请求应放行", i+1)
}
}
if limiter.allow("1.2.3.4") {
t.Fatal("超过配额应拒绝")
}
// 限流按 IP 计:别的访客不该被牵连
if !limiter.allow("5.6.7.8") {
t.Fatal("其它 IP 不应被牵连")
}
// 窗口滑过后恢复
now = now.Add(statusPageWindow + time.Second)
if !limiter.allow("1.2.3.4") {
t.Fatal("窗口过后应恢复放行")
}
}
func TestHashStatusPageCodeHidesPlaintext(t *testing.T) {
hash := hashStatusPageCode("my-secret")
if hash == "my-secret" || len(hash) != 64 {
t.Fatalf("访问码应以 SHA-256 存储,实际 %q", hash)
}
if hashStatusPageCode(" my-secret ") != hash {
t.Fatal("前后空白裁剪后应得到同一哈希")
}
if hashStatusPageCode("other") == hash {
t.Fatal("不同访问码不应得到同一哈希")
}
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/havline/havline/internal/auth"
)
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
const maxTokenExpiryDays = 3650
type createTokenRequest struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
func (h *AuthHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := h.auth.ListAPITokens(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
}
// CreateToken 新建令牌;明文只在这一个响应里返回,之后无法再取回。
func (h *AuthHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = auth.TokenScopeRead
}
var expiresAt time.Time
if req.ExpiresInDays > 0 {
if req.ExpiresInDays > maxTokenExpiryDays {
writeError(r, w, http.StatusBadRequest, "有效期最长 3650 天")
return
}
expiresAt = time.Now().AddDate(0, 0, req.ExpiresInDays)
}
token, err := h.auth.CreateAPIToken(r.Context(), req.Name, scope, expiresAt)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, token)
}
func (h *AuthHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的令牌 ID")
return
}
if err := h.auth.DeleteAPIToken(r.Context(), id); err != nil {
if err.Error() == "令牌不存在" {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusInternalServerError, "删除令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/updater"
)
type UpdateHandler struct {
client *updater.Client
}
func NewUpdateHandler(client *updater.Client) *UpdateHandler {
return &UpdateHandler{client: client}
}
func (h *UpdateHandler) Status(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeJSON(w, http.StatusOK, updater.Status{
Enabled: false,
Phase: updater.PhaseIdle,
Message: "当前部署未启用一键升级",
})
return
}
status, err := h.client.Status(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "读取升级状态失败", err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *UpdateHandler) Apply(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeError(r, w, http.StatusServiceUnavailable, "当前部署未启用一键升级")
return
}
status, err := h.client.Apply(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "启动升级失败", err)
return
}
writeJSON(w, http.StatusAccepted, status)
}
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestUpdateHandlerDisabled(t *testing.T) {
handler := NewUpdateHandler(nil)
statusRecorder := httptest.NewRecorder()
statusReq := httptest.NewRequest(http.MethodGet, "/api/system/update/status", nil)
handler.Status(statusRecorder, statusReq)
if statusRecorder.Code != http.StatusOK {
t.Fatalf("状态接口期望 200,实际 %d", statusRecorder.Code)
}
if body := statusRecorder.Body.String(); !strings.Contains(body, `"enabled":false`) {
t.Fatalf("未返回 enabled=false: %s", body)
}
applyRecorder := httptest.NewRecorder()
applyReq := httptest.NewRequest(http.MethodPost, "/api/system/update/apply", nil)
handler.Apply(applyRecorder, applyReq)
if applyRecorder.Code != http.StatusServiceUnavailable {
t.Fatalf("升级接口期望 503,实际 %d", applyRecorder.Code)
}
}
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/version"
)
type versionResponse struct {
Version string `json:"version"`
NginxHTTPPort int `json:"nginx_http_port"`
NginxHTTPSPort int `json:"nginx_https_port"`
}
func Version(cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, versionResponse{
Version: version.Version,
NginxHTTPPort: cfg.NginxDefaultHTTPPort,
NginxHTTPSPort: cfg.NginxDefaultHTTPSPort,
})
}
}
@@ -0,0 +1,36 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/version"
)
func TestVersionEndpoint(t *testing.T) {
version.Version = "0.1.6"
cfg := config.Config{NginxDefaultHTTPPort: 18080, NginxDefaultHTTPSPort: 9443}
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
rec := httptest.NewRecorder()
Version(cfg)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["version"] != "0.1.6" {
t.Fatalf("version %q", body["version"])
}
if body["nginx_http_port"] != float64(18080) {
t.Fatalf("nginx_http_port %v", body["nginx_http_port"])
}
if body["nginx_https_port"] != float64(9443) {
t.Fatalf("nginx_https_port %v", body["nginx_https_port"])
}
}
+322
View File
@@ -0,0 +1,322 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/havline/havline/internal/acme"
"github.com/havline/havline/internal/api"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/chinacidr"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/logstore"
"github.com/havline/havline/internal/metrics"
"github.com/havline/havline/internal/monitor"
"github.com/havline/havline/internal/nginx"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/scheduler"
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/traffic"
"github.com/havline/havline/internal/updater"
)
type App struct {
cfg config.Config
logger *slog.Logger
server *http.Server
nginx *nginx.Manager
frpMulti *frp.Service
cloudflare *cloudflared.Service
scheduler *scheduler.Scheduler
shutdownFn context.CancelFunc
db interface{ Close() error }
}
func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
}
// 会话密钥:显式配置优先;未配置则在数据目录里读/生成并持久化
// (它同时是 secret box 的加密密钥,所以只在未配置时才生成,不替换已配置的值)
secretSource, err := cfg.ResolveSessionSecret()
if err != nil {
return nil, err
}
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
conn, err := db.Open(cfg.DBPath())
if err != nil {
return nil, err
}
if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
conn.Close()
return nil, err
}
secretBox, err := secret.NewBox(cfg.SessionSecret)
if err != nil {
conn.Close()
return nil, err
}
authSvc := auth.New(conn)
if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
conn.Close()
return nil, fmt.Errorf("bootstrap admin: %w", err)
}
proxyStore := proxy.NewStore(conn)
settingsStore := settings.NewStore(conn)
ddnsStore := ddns.NewStore(conn)
certStore := certificate.NewStore(conn)
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
if err := nginxMgr.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
notifySvc := notify.New(settingsStore, secretBox, logger)
proxySvc.SetNotify(notifySvc)
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
backupSvc := backup.New(cfg.DataDir)
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
metricsStore := metrics.NewStore(conn)
discoverySvc := discovery.New()
// 新增:多服务端 FRP 模块(接管原网关模式)
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
if err := frpMultiSvc.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
if err := cloudflareSvc.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket)
handler := api.NewRouter(api.Deps{
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Cloudflare: cloudflareSvc,
Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS,
StartedAt: startedAt,
})
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
handler = stripPrefixIfPresent(prefix, handler)
}
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
ReadTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
}
ctx, cancel := context.WithCancel(context.Background())
frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
go cloudflareSvc.StartAll(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
}
chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
if chinaHours <= 0 {
chinaHours = 24
}
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New(
scheduler.Job{
Name: "ddns",
Interval: time.Duration(intervalMinutes) * time.Minute,
Run: ddnsSvc.Tick,
},
scheduler.Job{
Name: "acme",
Interval: 24 * time.Hour,
Run: acmeSvc.Tick,
},
scheduler.Job{
Name: "china_cidr",
Interval: time.Duration(chinaHours) * time.Hour,
Run: chinaCIDRSvc.Tick,
},
scheduler.Job{
Name: "monitor",
Interval: 2 * time.Minute,
Run: monitorChecker.Run,
},
scheduler.Job{
Name: "backup",
Interval: backup.AutoInterval,
Run: backupRunner.Run,
},
)
sched.Start(ctx)
trafficCollector.Start(ctx)
app := &App{
cfg: cfg,
logger: logger,
server: server,
nginx: nginxMgr,
frpMulti: frpMultiSvc,
scheduler: sched,
shutdownFn: cancel,
db: conn,
}
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
cancel()
conn.Close()
return nil, err
}
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
}
if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
}
logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
return app, nil
}
func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
if err := proxySvc.ReloadAll(ctx); err != nil {
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
}
return nil
}
func (a *App) Run() error {
errCh := make(chan error, 2)
go func() {
errCh <- a.server.ListenAndServe()
}()
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
return err
}
listener, err := net.Listen("unix", socket)
if err != nil {
return err
}
if err := os.Chmod(socket, 0o660); err != nil {
_ = listener.Close()
return err
}
go func() {
errCh <- a.server.Serve(listener)
}()
}
err := <-errCh
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func (a *App) Shutdown(ctx context.Context) error {
a.logger.Info("application shutting down", "module", "SYSTEM")
if a.shutdownFn != nil {
a.shutdownFn()
}
if err := a.server.Shutdown(ctx); err != nil {
return err
}
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
_ = os.Remove(socket)
}
if err := a.nginx.Stop(ctx); err != nil {
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
}
if a.frpMulti != nil {
if err := a.frpMulti.Stop(ctx); err != nil {
a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
}
}
if a.db != nil {
return a.db.Close()
}
return nil
}
func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, prefix) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
}
next.ServeHTTP(w, r)
})
}
func ResolveMigrationsDir() (string, error) {
candidates := []string{
"migrations",
filepath.Join("..", "migrations"),
filepath.Join("..", "..", "migrations"),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c, nil
}
}
return "", fmt.Errorf("migrations directory not found")
}
+241
View File
@@ -0,0 +1,241 @@
package app
import (
"context"
"fmt"
"log/slog"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/monitor"
"github.com/havline/havline/internal/traffic"
)
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
type monitorSource struct {
frp *frp.Service
cloudflare *cloudflared.Service
logger *slog.Logger
traffic *traffic.Collector
}
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
if s.cloudflare == nil {
return []monitor.CloudflareRouteStatus{}, nil
}
statuses, err := s.cloudflare.HealthStatuses(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
for _, item := range statuses {
out = append(out, monitor.CloudflareRouteStatus{
TunnelID: item.TunnelID,
Name: item.Name,
Domain: item.Domain,
State: item.State,
Reason: item.Reason,
Detail: item.Detail,
Known: item.Known,
})
}
return out, nil
}
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
if s.cloudflare == nil {
return nil
}
converted := make([]cloudflared.HealthSample, 0, len(samples))
for _, sample := range samples {
converted = append(converted, cloudflared.HealthSample{
TunnelID: sample.TunnelID,
Name: sample.Name,
Domain: sample.Domain,
State: sample.State,
Reason: sample.Reason,
Detail: sample.Detail,
})
}
return s.cloudflare.RecordHealth(ctx, converted)
}
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
if s.cloudflare == nil {
return nil
}
_, err := s.cloudflare.PruneHealth(ctx)
return err
}
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
servers, err := s.enabledServers(ctx)
if err != nil {
return nil, err
}
routes := make([]monitor.RouteStatus, 0, len(servers))
for _, server := range servers {
// 单台服务端探测失败不影响其它服务端:它本轮跳过,等下一轮
health, err := s.frp.RouteHealth(ctx, server.ID)
if err != nil {
s.logger.Warn("巡检跳过服务端隧道", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
continue
}
for _, item := range health {
routes = append(routes, monitor.RouteStatus{
ServerID: server.ID,
Domain: item.Domain,
TunnelOK: item.TunnelOK,
DNSOK: item.DNSOK,
ServiceOK: item.ServiceOK,
Known: item.TunnelKnown,
Detail: item.TunnelDetail,
})
}
}
return routes, nil
}
func (s monitorSource) Servers(ctx context.Context) ([]monitor.ServerStatus, error) {
servers, err := s.enabledServers(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.ServerStatus, 0, len(servers))
for _, server := range servers {
out = append(out, monitor.ServerStatus{
ID: server.ID,
Name: server.Name,
DesiredState: server.DesiredState,
ProcessState: server.ProcessState,
ConnectionState: server.ConnectionState,
Detail: server.LastError,
})
}
return out, nil
}
func (s monitorSource) Agents(ctx context.Context) ([]monitor.AgentStatus, error) {
servers, err := s.enabledServers(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.AgentStatus, 0, len(servers))
for _, server := range servers {
// AgentClient 只在「地址与令牌都可用」时才返回 ok:没配 agent 的服务端不参与可达性判定
client, ok := s.frp.AgentClient(ctx, server.ID)
if !ok {
continue
}
status := monitor.AgentStatus{ID: server.ID, Name: server.Name, Reachable: true}
if err := client.Ping(ctx); err != nil {
status.Reachable = false
status.Detail = err.Error()
}
out = append(out, status)
}
return out, nil
}
func (s monitorSource) Drift(ctx context.Context) ([]monitor.DriftStatus, error) {
servers, err := s.enabledServers(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.DriftStatus, 0, len(servers))
for _, server := range servers {
report, err := s.frp.RouteDrift(ctx, server.ID)
if err != nil {
s.logger.Warn("巡检跳过漂移检测", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
continue
}
status := monitor.DriftStatus{ID: server.ID, Name: server.Name, Count: len(report.Items)}
for _, item := range report.Items {
status.Items = append(status.Items, fmt.Sprintf("%s %s", item.Domain, driftKindText(item.Kind)))
}
out = append(out, status)
}
return out, nil
}
// Traffic 返回若干域名的当前上下行速率:流量采集器内部按 host:port 累计,这里按域名合并。
func (s monitorSource) Traffic(ctx context.Context, domains []string) (map[string]monitor.TrafficRate, error) {
if s.traffic == nil {
return nil, nil
}
snapshot := s.traffic.SnapshotByHost(domains)
rates := make(map[string]monitor.TrafficRate, len(snapshot))
for host, item := range snapshot {
rates[host] = monitor.TrafficRate{Upload: item.UploadRate, Download: item.DownloadRate}
}
return rates, nil
}
func (s monitorSource) enabledServers(ctx context.Context) ([]frp.Server, error) {
servers, err := s.frp.ListServers(ctx)
if err != nil {
return nil, err
}
enabled := make([]frp.Server, 0, len(servers))
for _, server := range servers {
// 停用中的服务端是用户主动关掉的,不该参与任何告警判定
if server.Enabled {
enabled = append(enabled, server)
}
}
return enabled, nil
}
// driftKindText 与公网反代页漂移清单的文案保持一致,通知里才不用回页面去对照。
func driftKindText(kind string) string {
switch kind {
case frp.RouteDriftMissing:
return "未部署"
case frp.RouteDriftOrphan:
return "孤儿配置"
case frp.RouteDriftNotLoaded:
return "未生效"
case frp.RouteDriftContentMismatch:
return "内容不一致"
default:
return kind
}
}
// healthRecorder 把 monitor 的健康结论翻成 frp 侧样本并落库(
// 两侧的状态字面量由 healthStateValue 显式翻译,改动一侧会在这里编译期报错)。
type healthRecorder struct {
frp *frp.Service
}
func (r healthRecorder) RecordRouteHealth(ctx context.Context, samples []monitor.HealthSample) error {
converted := make([]frp.RouteHealthSample, 0, len(samples))
for _, sample := range samples {
converted = append(converted, frp.RouteHealthSample{
ServerID: sample.ServerID,
Domain: sample.Domain,
State: healthStateValue(sample.State),
Reason: sample.Reason,
Detail: sample.Detail,
})
}
_, err := r.frp.RecordRouteHealth(ctx, converted)
return err
}
func (r healthRecorder) PruneRouteHealth(ctx context.Context) error {
_, err := r.frp.PruneRouteHealth(ctx)
return err
}
func healthStateValue(state string) string {
switch state {
case monitor.HealthUp:
return frp.RouteHealthUp
case monitor.HealthDown:
return frp.RouteHealthDown
default:
return frp.RouteHealthUnknown
}
}
@@ -0,0 +1,24 @@
package app
import (
"testing"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/monitor"
)
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
// 这个测试把翻译钉住,避免以后改一侧的常量而另一侧悄悄写错值。
func TestHealthStateValue(t *testing.T) {
cases := map[string]string{
monitor.HealthUp: frp.RouteHealthUp,
monitor.HealthDown: frp.RouteHealthDown,
monitor.HealthUnknown: frp.RouteHealthUnknown,
"其他": frp.RouteHealthUnknown,
}
for input, want := range cases {
if got := healthStateValue(input); got != want {
t.Fatalf("%q 应翻译为 %q,实际 %q", input, want, got)
}
}
}
+225
View File
@@ -0,0 +1,225 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/havline/havline/internal/validate"
)
const sessionTTL = 7 * 24 * time.Hour
var ErrUnauthorized = errors.New("unauthorized")
var ErrNotInitialized = errors.New("not initialized")
type Service struct {
db *sql.DB
}
func New(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
return count > 0, err
}
const defaultAdminUsername = "admin"
func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
initialized, err := s.IsInitialized(ctx)
if err != nil {
return err
}
if initialized {
return nil
}
password := strings.TrimSpace(os.Getenv("HAVLINE_INITIAL_ADMIN_PASSWORD"))
generated := false
if password == "" {
var err error
password, err = generatePassword(16)
if err != nil {
return err
}
generated = true
}
if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
return err
}
if generated {
log.Info(
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
"module", "SYSTEM",
"username", defaultAdminUsername,
"password", password,
)
} else {
log.Info(
"首次启动已使用 HAVLINE_INITIAL_ADMIN_PASSWORD 创建管理员账户,请在登录后尽快修改密码",
"module", "SYSTEM",
"username", defaultAdminUsername,
"password_source", "environment",
)
}
return nil
}
func (s *Service) Setup(ctx context.Context, username, password string) error {
if err := validate.Username(username); err != nil {
return err
}
if err := validate.Password(password); err != nil {
return err
}
initialized, err := s.IsInitialized(ctx)
if err != nil {
return err
}
if initialized {
return fmt.Errorf("管理员已初始化")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
return err
}
func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
var adminID int
var hash string
err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUnauthorized
}
return "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return "", ErrUnauthorized
}
return s.createSession(ctx, adminID)
}
func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
if sessionID == "" {
return 0, ErrUnauthorized
}
var adminID int
err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrUnauthorized
}
return 0, err
}
return adminID, nil
}
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
if err := validate.Password(newPassword); err != nil {
return err
}
var hash string
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
if err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
return fmt.Errorf("当前密码不正确")
}
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil {
return err
}
// 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线)
_, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID)
return err
}
func (s *Service) Logout(ctx context.Context, sessionID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
return err
}
func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return ErrUnauthorized
}
var expiresAt string
err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUnauthorized
}
return err
}
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return ErrUnauthorized
}
if time.Now().After(expires) {
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
return ErrUnauthorized
}
return nil
}
func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
id, err := randomToken(32)
if err != nil {
return "", err
}
expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
if err != nil {
return "", err
}
return id, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func generatePassword(length int) (string, error) {
const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
if length < 8 {
length = 8
}
out := make([]byte, length)
randBytes := make([]byte, length)
if _, err := rand.Read(randBytes); err != nil {
return "", err
}
for i := range out {
out[i] = letters[int(randBytes[i])%len(letters)]
}
return string(out), nil
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/havline/havline/internal/db"
)
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := New(conn)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
initialized, err := svc.IsInitialized(context.Background())
if err != nil || !initialized {
t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
}
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
t.Fatalf("expected unauthorized for wrong password, got %v", err)
}
}
func TestEnsureDefaultAdminUsesEnvPassword(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Setenv("HAVLINE_INITIAL_ADMIN_PASSWORD", "env-password-123")
svc := New(conn)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("bootstrap with env password: %v", err)
}
if _, err := svc.Login(context.Background(), "admin", "env-password-123"); err != nil {
t.Fatalf("expected env password login to succeed, got %v", err)
}
}
+166
View File
@@ -0,0 +1,166 @@
package auth
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
//
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
const (
TokenScopeRead = "read"
TokenScopeWrite = "write"
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
// 同一分钟内重复使用不再写。
lastUsedWriteInterval = time.Minute
maxTokenNameLength = 64
)
type APIToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresAt string `json:"expires_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
type CreatedAPIToken struct {
APIToken
Token string `json:"token"`
}
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
FROM api_tokens ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
tokens := make([]APIToken, 0, 8)
for rows.Next() {
var token APIToken
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
return nil, err
}
tokens = append(tokens, token)
}
return tokens, rows.Err()
}
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
name = strings.TrimSpace(name)
if name == "" {
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
}
if len([]rune(name)) > maxTokenNameLength {
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
}
plain, err := randomToken(32)
if err != nil {
return CreatedAPIToken{}, err
}
expires := ""
if !expiresAt.IsZero() {
expires = expiresAt.UTC().Format(time.RFC3339)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
name, hashToken(plain), scope, expires)
if err != nil {
return CreatedAPIToken{}, err
}
id, err := res.LastInsertId()
if err != nil {
return CreatedAPIToken{}, err
}
token, err := s.apiTokenByID(ctx, id)
if err != nil {
return CreatedAPIToken{}, err
}
return CreatedAPIToken{APIToken: token, Token: plain}, nil
}
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("令牌不存在")
}
return nil
}
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", ErrUnauthorized
}
var (
id int64
scope string
expiresAt string
)
err := s.db.QueryRowContext(ctx,
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
Scan(&id, &scope, &expiresAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUnauthorized
}
return "", err
}
if expiresAt != "" {
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(expires) {
return "", ErrUnauthorized
}
}
now := time.Now().UTC()
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
// 回写失败不影响本次调用:last_used_at 只是审计信息
_, _ = s.db.ExecContext(ctx,
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
now.Format(time.RFC3339), id, cutoff)
return scope, nil
}
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
var token APIToken
err := s.db.QueryRowContext(ctx, `
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
FROM api_tokens WHERE id = ?`, id).
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
return token, err
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
func newTestService(t *testing.T) *Service {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(conn)
}
func TestAPITokenLifecycle(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
t.Fatalf("unexpected created token: %+v", created)
}
// 库里只存 SHA-256:明文不落盘
var stored string
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
t.Fatalf("read hash: %v", err)
}
if stored == created.Token || len(stored) != 64 {
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
}
scope, err := svc.ValidateAPIToken(ctx, created.Token)
if err != nil || scope != TokenScopeRead {
t.Fatalf("validate: scope=%q err=%v", scope, err)
}
var lastUsed string
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
t.Fatalf("read last_used_at: %v", err)
}
if lastUsed == "" {
t.Fatal("校验通过后应回写 last_used_at")
}
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
t.Fatalf("未知令牌应拒绝,实际 %v", err)
}
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
t.Fatalf("删除后令牌应失效,实际 %v", err)
}
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
t.Fatal("重复删除应报错")
}
}
func TestAPITokenExpiry(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
t.Fatalf("过期令牌应拒绝,实际 %v", err)
}
}
func TestAPITokenRejectsBadInput(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
t.Fatal("空名称应报错")
}
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
t.Fatal("未知权限范围应报错")
}
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
t.Fatalf("空令牌应拒绝,实际 %v", err)
}
}
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
t.Fatalf("create first: %v", err)
}
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
t.Fatalf("create second: %v", err)
}
tokens, err := svc.ListAPITokens(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(tokens) != 2 || tokens[0].Name != "second" {
t.Fatalf("应按 id 倒序返回:%+v", tokens)
}
}
+157
View File
@@ -0,0 +1,157 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"time"
"github.com/havline/havline/internal/settings"
)
// 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满
const (
DefaultKeepCount = 7
MaxKeepCount = 60
)
// archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。
// List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。
var archiveNamePattern = regexp.MustCompile(`^havline-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
// Archive 留在磁盘上的一份备份
type Archive struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
}
func (s *Service) backupsDir() string {
return filepath.Join(s.dataDir, "backups")
}
// List 按新的在前列出备份;目录不存在返回空列表(还没备份过是正常状态)。
func (s *Service) List() ([]Archive, error) {
entries, err := os.ReadDir(s.backupsDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
archives := make([]Archive, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !archiveNamePattern.MatchString(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
archives = append(archives, Archive{Name: entry.Name(), Size: info.Size(), ModTime: info.ModTime().UTC()})
}
// 文件名前缀就是时间戳,按名倒序即按时间倒序
sort.Slice(archives, func(i, j int) bool { return archives[i].Name > archives[j].Name })
return archives, nil
}
// Prune 只保留最新的 keep 份,返回删除数量;keep <= 0 表示不剪枝。
func (s *Service) Prune(keep int) (int, error) {
if keep <= 0 {
return 0, nil
}
archives, err := s.List()
if err != nil {
return 0, err
}
removed := 0
for _, archive := range archives[min(keep, len(archives)):] {
if err := s.Remove(archive.Name); err != nil {
return removed, err
}
removed++
}
return removed, nil
}
// Path 把备份名解析成绝对路径;名字必须匹配备份命名格式,避免被用来读写目录外的文件。
func (s *Service) Path(name string) (string, error) {
if !archiveNamePattern.MatchString(name) {
return "", fmt.Errorf("备份名无效")
}
return filepath.Join(s.backupsDir(), name), nil
}
// Remove 删除指定备份
func (s *Service) Remove(name string) error {
path, err := s.Path(name)
if err != nil {
return err
}
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("备份不存在")
}
return err
}
return nil
}
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
// 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。
func (s *Service) Verify(ctx context.Context, path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
return 0, fmt.Errorf("备份文件无法解压:%w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
count := 0
hasDatabase := false
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("备份文件已损坏:%w", err)
}
if err := ctx.Err(); err != nil {
return count, err
}
count++
if header.Name == "havline.db" {
hasDatabase = true
}
}
if !hasDatabase {
return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份")
}
return count, nil
}
// KeepCount 读取保留份数(未配置或越界时回落到默认值)。
// 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。
func KeepCount(ctx context.Context, store *settings.Store) int {
keep, err := store.GetInt(ctx, settings.KeyBackupKeepCount)
if err != nil || keep <= 0 {
return DefaultKeepCount
}
if keep > MaxKeepCount {
return MaxKeepCount
}
return keep
}
+89
View File
@@ -0,0 +1,89 @@
package backup
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"time"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/settings"
)
// AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」
// 判断该不该备份,因此频繁重启(NAS 上很常见)不会重复备份。
const AutoInterval = 24 * time.Hour
// AutoRunner 定期导出一份归档、回读校验、再按保留份数剪枝。
type AutoRunner struct {
svc *Service
settings *settings.Store
notify *notify.Service
logger *slog.Logger
now func() time.Time
}
func NewAutoRunner(svc *Service, settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger) *AutoRunner {
if logger == nil {
logger = slog.Default()
}
return &AutoRunner{svc: svc, settings: settingsStore, notify: notifySvc, logger: logger, now: time.Now}
}
// Run 跑一轮。失败只通知、不返回错误:scheduler 的任务没有重试机制,通知才是可见性来源。
func (r *AutoRunner) Run(ctx context.Context) {
if r == nil || r.svc == nil || r.settings == nil {
return
}
enabled, err := r.settings.GetBool(ctx, settings.KeyBackupAutoEnabled)
if err != nil || !enabled {
return
}
archives, err := r.svc.List()
if err != nil {
r.logger.Warn("读取备份列表失败", "module", "BACKUP", "error", err.Error())
return
}
if len(archives) > 0 && !dueForBackup(archives[0].ModTime, r.now(), AutoInterval) {
return
}
path, err := r.svc.Export(ctx)
if err != nil {
r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error()))
return
}
entries, err := r.svc.Verify(ctx, path)
if err != nil {
// 校验不过的归档不算一份可用备份:删掉并如实告警,别让用户以为已经有备份
if removeErr := r.svc.Remove(filepath.Base(path)); removeErr != nil {
r.logger.Warn("删除校验失败的备份失败", "module", "BACKUP", "error", removeErr.Error())
}
r.fail(ctx, fmt.Sprintf("备份校验失败:%s", err.Error()))
return
}
removed, err := r.svc.Prune(KeepCount(ctx, r.settings))
if err != nil {
r.logger.Warn("剪枝旧备份失败", "module", "BACKUP", "error", err.Error())
}
r.logger.Info("自动备份完成", "module", "BACKUP",
"file", filepath.Base(path), "entries", entries, "removed", removed)
}
// dueForBackup 判断该不该做一次备份:没有备份,或最新一份已超过间隔。
func dueForBackup(newest, now time.Time, interval time.Duration) bool {
if newest.IsZero() {
return true
}
return now.Sub(newest) >= interval
}
func (r *AutoRunner) fail(ctx context.Context, message string) {
r.logger.Error("自动备份失败", "module", "BACKUP", "error", message)
if r.notify != nil {
r.notify.Alert(ctx, notify.EventBackupFailure, "自动备份失败", message)
}
}
+130
View File
@@ -0,0 +1,130 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestDueForBackup(t *testing.T) {
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
// 还没有备份 → 立刻做一份
if !dueForBackup(time.Time{}, now, AutoInterval) {
t.Fatal("没有备份时应立即备份")
}
// 刚备份过 → 跳过:这正是重启不会重复备份的原因
if dueForBackup(now.Add(-time.Hour), now, AutoInterval) {
t.Fatal("间隔内的备份不应重复执行")
}
// 正好到间隔 → 该做了
if !dueForBackup(now.Add(-AutoInterval), now, AutoInterval) {
t.Fatal("到达间隔应再备份一次")
}
// 超过间隔 → 该做
if !dueForBackup(now.Add(-25*time.Hour), now, AutoInterval) {
t.Fatal("超过间隔应再备份一次")
}
}
func TestExportVerifyRoundTrip(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "havline.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
svc := New(dir)
path, err := svc.Export(context.Background())
if err != nil {
t.Fatalf("export: %v", err)
}
entries, err := svc.Verify(context.Background(), path)
if err != nil {
t.Fatalf("verify: %v", err)
}
if entries < 1 {
t.Fatalf("条目数应至少为 1,实际 %d", entries)
}
archives, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(archives) != 1 {
t.Fatalf("应列出 1 份备份,实际 %d", len(archives))
}
if archives[0].Size == 0 || !archiveNamePattern.MatchString(archives[0].Name) {
t.Fatalf("备份条目异常:%+v", archives[0])
}
}
func TestVerifyRejectsGarbage(t *testing.T) {
svc := New(t.TempDir())
path := filepath.Join(svc.backupsDir(), "havline-backup-20260925-000000-1.tar.gz")
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("not a gzip stream"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := svc.Verify(context.Background(), path); err == nil {
t.Fatal("非 gzip 内容应校验失败")
}
}
func TestListPruneAndPath(t *testing.T) {
svc := New(t.TempDir())
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"havline-backup-20260923-100000-1.tar.gz",
"havline-backup-20260924-100000-2.tar.gz",
"havline-backup-20260925-100000-3.tar.gz",
} {
if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
// 不符合命名格式的文件不参与:备份目录里可能混着手工放进来的东西
if err := os.WriteFile(filepath.Join(svc.backupsDir(), "notes.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
archives, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(archives) != 3 {
t.Fatalf("应只列出 3 份备份,实际 %d", len(archives))
}
if archives[0].Name != "havline-backup-20260925-100000-3.tar.gz" {
t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name)
}
removed, err := svc.Prune(2)
if err != nil {
t.Fatalf("prune: %v", err)
}
if removed != 1 {
t.Fatalf("应删除 1 份,实际 %d", removed)
}
if archives, _ = svc.List(); len(archives) != 2 {
t.Fatalf("剪枝后应剩 2 份,实际 %d", len(archives))
}
// 名称校验:不能让备份名把读写带出备份目录
for _, name := range []string{"../havline.db", "notes.txt", "", "havline-backup-x.tar.gz"} {
if _, err := svc.Path(name); err == nil {
t.Fatalf("非法备份名应拒绝:%q", name)
}
}
if err := svc.Remove("havline-backup-20260920-100000-9.tar.gz"); err == nil {
t.Fatal("删除不存在的备份应报错")
}
if _, err := svc.Prune(0); err != nil {
t.Fatalf("keep<=0 应直接返回:%v", err)
}
}
+194
View File
@@ -0,0 +1,194 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type Service struct {
dataDir string
}
// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
const (
restoreFileMode = 0o640
restoreDirMode = 0o750
)
func New(dataDir string) *Service {
return &Service{dataDir: dataDir}
}
// backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验
const backupTimeFmt = "20060102-150405"
// Export 导出一份归档并返回路径;文件留在 <DataDir>/backups 下,由调用方决定下载后删除还是长期保留。
func (s *Service) Export(ctx context.Context) (string, error) {
if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil {
return "", err
}
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
if err != nil {
return "", err
}
outPath := file.Name()
if err := s.writeArchive(ctx, file, outPath); err != nil {
return "", err
}
return outPath, nil
}
// writeArchive 写归档,逐层检查关闭错误。
//
// Close 的错误必须往上抛:tar/gzip 的尾部与文件系统的 flush 都发生在 Close,
// 忽略它就会出现「备份文件不完整、函数却返回成功」——这是最坏的一种假成功。
// 任一步失败都删掉半成品,不留一个看起来能用的备份。
func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath string) error {
discard := func() {
_ = file.Close()
_ = os.Remove(outPath)
}
gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw)
for _, entry := range []string{"havline.db", "certs", "nginx", "cloudflared/tunnels"} {
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard()
return err
}
}
if err := tw.Close(); err != nil {
discard()
return err
}
if err := gw.Close(); err != nil {
discard()
return err
}
if err := file.Close(); err != nil {
_ = os.Remove(outPath)
return err
}
return nil
}
func (s *Service) Restore(ctx context.Context, archivePath string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("无效的备份文件")
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
continue
}
target := filepath.Join(s.dataDir, header.Name)
// 归档条目必须落在 DataDir 内:前缀比较会被 ../data-other 这类同前缀路径绕过,用 Rel 判定
rel, err := filepath.Rel(s.dataDir, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("备份条目非法: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, restoreDirMode); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), restoreDirMode); err != nil {
return err
}
// 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode)
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
path := filepath.Join(baseDir, rel)
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return addDir(ctx, tw, baseDir, rel)
}
return addFile(tw, baseDir, rel, info)
}
func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
root := filepath.Join(baseDir, rel)
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
relPath, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
return addFile(tw, baseDir, relPath, info)
})
}
func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
path := filepath.Join(baseDir, rel)
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(rel)
if err := tw.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tw, file)
return err
}
+130
View File
@@ -0,0 +1,130 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
// writeArchive 造一个含指定条目的 tar.gz,用于验证还原侧的路径校验与权限处理。
func writeArchive(t *testing.T, entries map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "backup.tar.gz")
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gz := gzip.NewWriter(file)
tw := tar.NewWriter(gz)
for name, content := range entries {
header := &tar.Header{Name: name, Mode: 0o777, Size: int64(len(content)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return path
}
// TestRestoreRejectsEscapingEntries 覆盖「同前缀绕过」:旧实现用 strings.HasPrefix 判断,
// DataDir=/data 时 ../data-other/x 会通过前缀匹配写到 DataDir 之外。
func TestRestoreRejectsEscapingEntries(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatal(err)
}
archive := writeArchive(t, map[string]string{"../data-other/evil.txt": "x"})
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
t.Fatal("应当拒绝逃出 DataDir 的条目")
}
if _, err := os.Stat(filepath.Join(root, "data-other", "evil.txt")); err == nil {
t.Fatal("逃逸条目被写到了 DataDir 之外")
}
}
func TestRestoreRejectsParentEscape(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatal(err)
}
archive := writeArchive(t, map[string]string{"../../evil.txt": "x"})
if err := New(dataDir).Restore(context.Background(), archive); err == nil {
t.Fatal("应当拒绝带 .. 的条目")
}
}
// TestRestoreAppliesFixedMode:归档里的权限位不照搬(构造的归档可带 0777 / 执行位)。
func TestRestoreAppliesFixedMode(t *testing.T) {
dataDir := t.TempDir()
archive := writeArchive(t, map[string]string{"nginx/nginx.conf": "worker_processes 1;"})
if err := New(dataDir).Restore(context.Background(), archive); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dataDir, "nginx", "nginx.conf"))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); runtime.GOOS == "windows" {
// Windows 不保留 Unix 权限位(可写文件统一报 0666):只断言没带上执行位
if got&0o111 != 0 {
t.Fatalf("还原后的文件不应带执行位,实际 %o", got)
}
} else if got != restoreFileMode {
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
}
}
func TestExportIncludesCloudflareTunnelConfig(t *testing.T) {
dataDir := t.TempDir()
tunnelDir := filepath.Join(dataDir, "cloudflared", "tunnels", "home")
if err := os.MkdirAll(tunnelDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tunnelDir, "config.yml"), []byte("tunnel: test\n"), 0o640); err != nil {
t.Fatal(err)
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
found := false
for {
header, err := tr.Next()
if err != nil {
break
}
if header.Name == "cloudflared/tunnels/home/config.yml" {
found = true
break
}
}
if !found {
t.Fatal("备份应包含 Cloudflare 隧道配置")
}
}
+214
View File
@@ -0,0 +1,214 @@
package certificate
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"strings"
"time"
)
type ImportInfo struct {
Primary string
Domains []string
Wildcard bool
ExpiresAt time.Time
}
func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
cert, err := parseCertificate(certPEM)
if err != nil {
return ImportInfo{}, err
}
key, err := parsePrivateKey(keyPEM)
if err != nil {
return ImportInfo{}, err
}
if !publicKeysMatch(cert.PublicKey, key) {
return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
}
domains, err := ExtractCertDomains(cert)
if err != nil {
return ImportInfo{}, err
}
return ImportInfo{
Primary: primaryDomain(domains),
Domains: domains,
Wildcard: hasWildcardDomain(domains),
ExpiresAt: cert.NotAfter.UTC(),
}, nil
}
func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
info, err := InspectImport(certPEM, keyPEM)
if err != nil {
return time.Time{}, err
}
return info.ExpiresAt, nil
}
func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
seen := map[string]bool{}
var domains []string
add := func(name string) error {
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
if name == "" || net.ParseIP(name) != nil {
return nil
}
if seen[name] {
return nil
}
if strings.HasPrefix(name, "*.") {
if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
return err
}
} else if err := validateDomainLabel(name); err != nil {
return err
}
seen[name] = true
domains = append(domains, name)
return nil
}
for _, name := range cert.DNSNames {
if err := add(name); err != nil {
return nil, err
}
}
if err := add(cert.Subject.CommonName); err != nil {
return nil, err
}
if len(domains) == 0 {
return nil, fmt.Errorf("证书中未包含有效域名")
}
return domains, nil
}
func primaryDomain(domains []string) string {
for _, domain := range domains {
if !strings.HasPrefix(domain, "*.") {
return domain
}
}
return strings.TrimPrefix(domains[0], "*.")
}
func hasWildcardDomain(domains []string) bool {
for _, domain := range domains {
if strings.HasPrefix(domain, "*.") {
return true
}
}
return false
}
func validateDomainLabel(domain string) error {
if domain == "" {
return fmt.Errorf("域名格式无效")
}
parts := strings.Split(domain, ".")
for _, part := range parts {
if part == "" || len(part) > 63 {
return fmt.Errorf("域名格式无效")
}
}
return nil
}
func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
rest := certPEM
for {
block, remaining := pem.Decode(rest)
if block == nil {
return nil, fmt.Errorf("证书 PEM 格式无效")
}
if block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析证书失败:%w", err)
}
return cert, nil
}
rest = remaining
if len(rest) == 0 {
break
}
}
return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
}
func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, fmt.Errorf("私钥 PEM 格式无效")
}
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
return x509.ParsePKCS8PrivateKey(block.Bytes)
default:
return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
}
}
func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
switch pub := certKey.(type) {
case *rsa.PublicKey:
priv, ok := privateKey.(*rsa.PrivateKey)
return ok && pub.N.Cmp(priv.N) == 0
case *ecdsa.PublicKey:
priv, ok := privateKey.(*ecdsa.PrivateKey)
return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
default:
return false
}
}
func NormalizePEM(v string) []byte {
return []byte(strings.TrimSpace(v))
}
func ResolvePEMSource(content, path string) ([]byte, error) {
path = strings.TrimSpace(path)
if path != "" {
return ReadPEMFile(path)
}
data := NormalizePEM(content)
if len(data) == 0 {
return nil, fmt.Errorf("内容不能为空")
}
return data, nil
}
func ReadPEMFile(path string) ([]byte, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("文件路径不能为空")
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s 是目录,不是文件", path)
}
if info.Size() > 1<<20 {
return nil, fmt.Errorf("文件过大(最大 1MB)")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取文件失败:%w", err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, fmt.Errorf("文件内容为空")
}
return data, nil
}
+118
View File
@@ -0,0 +1,118 @@
package certificate
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"strings"
"testing"
"time"
)
func TestReadPEMFile(t *testing.T) {
certPEM, keyPEM := generateTestCert("example.com")
certPath := t.TempDir() + "/cert.pem"
keyPath := t.TempDir() + "/key.pem"
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatal(err)
}
readCert, err := ReadPEMFile(certPath)
if err != nil {
t.Fatal(err)
}
if string(readCert) != string(certPEM) {
t.Fatal("cert content mismatch")
}
got, err := ResolvePEMSource("", certPath)
if err != nil || string(got) != string(certPEM) {
t.Fatalf("ResolvePEMSource path: %v", err)
}
}
func TestInspectImport(t *testing.T) {
certPEM, keyPEM := generateTestCertWithSANs([]string{"example.com", "*.example.com", "nas.example.com"})
info, err := InspectImport(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
if info.Primary != "example.com" {
t.Fatalf("primary: %s", info.Primary)
}
if !info.Wildcard {
t.Fatal("expected wildcard")
}
if len(info.Domains) != 3 {
t.Fatalf("domains: %v", info.Domains)
}
}
func TestValidatePEM(t *testing.T) {
certPEM, keyPEM := generateTestCert("example.com")
expiresAt, err := ValidatePEM(certPEM, keyPEM)
if err != nil {
t.Fatalf("ValidatePEM: %v", err)
}
if expiresAt.Before(time.Now()) {
t.Fatalf("expected future expiry, got %v", expiresAt)
}
_, err = ValidatePEM(certPEM, []byte("not a key"))
if err == nil {
t.Fatal("expected error for invalid key")
}
_, wrongKeyPEM := generateTestCert("other.example.com")
_, err = ValidatePEM(certPEM, wrongKeyPEM)
if err == nil {
t.Fatal("expected mismatch error")
}
}
func generateTestCert(domain string) ([]byte, []byte) {
return generateTestCertWithSANs([]string{domain})
}
func generateTestCertWithSANs(domains []string) ([]byte, []byte) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
cn := domains[0]
if strings.HasPrefix(cn, "*.") {
cn = strings.TrimPrefix(cn, "*.")
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
DNSNames: domains,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
panic(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
+168
View File
@@ -0,0 +1,168 @@
package certificate
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Record struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
Wildcard bool `json:"wildcard"`
ACMECA string `json:"acme_ca,omitempty"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastRenewAt *time.Time `json:"last_renew_at,omitempty"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
DaysLeft int `json:"days_left"`
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) List(ctx context.Context) ([]Record, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
expires_at, last_renew_at, status, COALESCE(last_error, '')
FROM certificates ORDER BY id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []Record
for rows.Next() {
rec, err := scanRecord(rows)
if err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (s *Store) Upsert(ctx context.Context, domain string, wildcard bool, domainsJSON, acmeCA, certPath, keyPath string, expiresAt time.Time, status string, lastError string) (Record, error) {
_, err := s.db.ExecContext(ctx, `
INSERT INTO certificates(domain, domains, wildcard, acme_ca, cert_path, key_path, expires_at, last_renew_at, status, last_error, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, datetime('now'))
ON CONFLICT(domain) DO UPDATE SET
domains = excluded.domains,
wildcard = excluded.wildcard,
acme_ca = excluded.acme_ca,
cert_path = excluded.cert_path,
key_path = excluded.key_path,
expires_at = excluded.expires_at,
last_renew_at = datetime('now'),
status = excluded.status,
last_error = excluded.last_error,
updated_at = datetime('now')
`, domain, domainsJSON, boolInt(wildcard), acmeCA, certPath, keyPath, expiresAt.UTC().Format(time.RFC3339), status, lastError)
if err != nil {
return Record{}, err
}
return s.GetByDomain(ctx, domain)
}
func (s *Store) UpdateStatus(ctx context.Context, domain, status, lastError string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE certificates SET status = ?, last_error = ?, updated_at = datetime('now') WHERE domain = ?
`, status, lastError, domain)
return err
}
func (s *Store) Delete(ctx context.Context, domain string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE domain = ?`, domain)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("证书不存在")
}
return nil
}
func (s *Store) GetByDomain(ctx context.Context, domain string) (Record, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
expires_at, last_renew_at, status, COALESCE(last_error, '')
FROM certificates WHERE domain = ?
`, domain)
rec, err := scanRecord(row)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, fmt.Errorf("证书不存在")
}
return rec, err
}
func scanRecord(row interface{ Scan(dest ...any) error }) (Record, error) {
var rec Record
var wildcard int
var domainsJSON string
var expiresAt, lastRenewAt sql.NullString
if err := row.Scan(&rec.ID, &rec.Domain, &domainsJSON, &wildcard, &rec.ACMECA, &rec.CertPath, &rec.KeyPath, &expiresAt, &lastRenewAt, &rec.Status, &rec.LastError); err != nil {
return Record{}, err
}
rec.Wildcard = wildcard == 1
rec.Domains = decodeDomains(domainsJSON, rec.Domain, rec.Wildcard)
if expiresAt.Valid {
t := parseTime(expiresAt.String)
rec.ExpiresAt = &t
rec.DaysLeft = int(time.Until(t).Hours() / 24)
}
if lastRenewAt.Valid {
t := parseTime(lastRenewAt.String)
rec.LastRenewAt = &t
}
return rec, nil
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func decodeDomains(raw string, domain string, wildcard bool) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
if wildcard {
return []string{domain, "*." + domain}
}
return []string{domain}
}
var domains []string
if err := json.Unmarshal([]byte(raw), &domains); err != nil || len(domains) == 0 {
if wildcard {
return []string{domain, "*." + domain}
}
return []string{domain}
}
return domains
}
func parseTime(v string) time.Time {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
t, _ = time.Parse("2006-01-02 15:04:05", v)
}
return t.UTC()
}
@@ -0,0 +1,29 @@
package chinacidr
import (
"context"
"testing"
)
func TestDownloadDefaultV6Sources(t *testing.T) {
ctx := context.Background()
lines, err := downloadFromSources(ctx, defaultV6URLs...)
if err != nil {
t.Fatalf("download v6: %v", err)
}
if len(lines) < 10 {
t.Fatalf("expected at least 10 v6 entries, got %d", len(lines))
}
}
func TestGhfastProxyURL(t *testing.T) {
raw := "https://raw.githubusercontent.com/foo/bar.txt"
got := ghfastProxyURL(raw)
want := ghfastProxyPrefix + raw
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if ghfastProxyURL(want) != "" {
t.Fatal("expected no double proxy")
}
}
+333
View File
@@ -0,0 +1,333 @@
package chinacidr
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/nginx"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/settings"
)
const (
defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
ghfastProxyPrefix = "https://ghfast.top/"
)
// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
var defaultV6URLs = []string{
"https://gaoyifan.github.io/china-operator-ip/china6.txt",
"https://raw.githubusercontent.com/gaoyifan/china-operator-ip/china6.txt",
"https://raw.githubusercontent.com/carrnot/china-ip-list/release/ipv6.txt",
}
type Service struct {
cfg config.Config
settings *settings.Store
nginxMgr *nginx.Manager
proxyStore *proxy.Store
logger *slog.Logger
updating atomic.Bool
}
type Status struct {
UpdatedAt string `json:"updated_at,omitempty"`
EntryCountV4 int `json:"entry_count_v4"`
EntryCountV6 int `json:"entry_count_v6"`
LastError string `json:"last_error,omitempty"`
Updating bool `json:"updating"`
Ready bool `json:"ready"`
}
func New(cfg config.Config, settingsStore *settings.Store, nginxMgr *nginx.Manager, proxyStore *proxy.Store, logger *slog.Logger) *Service {
return &Service{cfg: cfg, settings: settingsStore, nginxMgr: nginxMgr, proxyStore: proxyStore, logger: logger}
}
func (s *Service) Tick(ctx context.Context) {
if err := s.Update(ctx); err != nil {
s.logger.Warn("china cidr update failed", "module", "CHINA_CIDR", "error", err.Error())
s.RecordLastError(ctx, err)
}
}
func (s *Service) RecordLastError(ctx context.Context, err error) {
if err == nil {
return
}
_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, err.Error())
}
func (s *Service) Update(ctx context.Context) error {
if !s.updating.CompareAndSwap(false, true) {
return fmt.Errorf("正在更新中,请稍后再试")
}
defer s.updating.Store(false)
v4URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV4)
customV6URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV6)
if isDeprecatedChinaCIDRSource(customV6URL) {
customV6URL = ""
_ = s.settings.Set(ctx, settings.KeyChinaCIDRSourceV6, "")
}
v4Lines, err := downloadFromSources(ctx, v4URL, defaultV4URL)
if err != nil {
return fmt.Errorf("下载 IPv4 CIDR 失败: %w", err)
}
v6Sources := append([]string{customV6URL}, defaultV6URLs...)
v6Lines, err := downloadFromSources(ctx, v6Sources...)
if err != nil {
return fmt.Errorf("下载 IPv6 CIDR 失败: %w", err)
}
body, v4Count, v6Count, err := buildCIDRFile(v4Lines, v6Lines)
if err != nil {
return err
}
tmpPath := s.cfg.ChinaCIDRTempPath()
if err := fsutil.WriteFileAtomic(tmpPath, []byte(body), 0o644); err != nil {
return err
}
rules, err := s.proxyStore.ListEnabledLocal(ctx)
if err != nil {
return err
}
opts := s.loadGenerateOptions(ctx)
validateOpts := opts
validateOpts.ChinaCIDRPathOverride = tmpPath
validateOpts.ChinaCIDRAvailable = true
if err := s.nginxMgr.ValidateOnlyWithOptions(ctx, rules, nil, validateOpts); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("nginx 校验失败: %w", err)
}
finalPath := s.cfg.ChinaCIDRPath()
if err := os.Rename(tmpPath, finalPath); err != nil {
return err
}
if _, err := s.nginxMgr.Apply(ctx, rules, nil); err != nil {
return fmt.Errorf("nginx 重载失败: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
_ = s.settings.Set(ctx, settings.KeyChinaCIDRUpdatedAt, now)
_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV4, fmt.Sprintf("%d", v4Count))
_ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV6, fmt.Sprintf("%d", v6Count))
_ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, "")
s.logger.Info("china cidr updated", "module", "CHINA_CIDR", "v4", v4Count, "v6", v6Count)
return nil
}
func (s *Service) Status(ctx context.Context) Status {
updatedAt, _ := s.settings.Get(ctx, settings.KeyChinaCIDRUpdatedAt)
v4, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV4)
v6, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV6)
lastErr, _ := s.settings.Get(ctx, settings.KeyChinaCIDRLastError)
ready := updatedAt != "" && lastErr == "" && v4 > 0 && v6 > 0
return Status{
UpdatedAt: updatedAt,
EntryCountV4: v4,
EntryCountV6: v6,
LastError: lastErr,
Updating: s.updating.Load(),
Ready: ready,
}
}
func isDeprecatedChinaCIDRSource(url string) bool {
url = strings.TrimSpace(url)
if url == "" {
return false
}
return strings.Contains(url, "metowolf/iplist") || strings.Contains(url, "iplist/meta")
}
func ghfastProxyURL(origin string) string {
origin = strings.TrimSpace(origin)
if origin == "" || strings.HasPrefix(origin, ghfastProxyPrefix) {
return ""
}
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
return ""
}
return ghfastProxyPrefix + origin
}
func expandDownloadSources(sources ...string) []string {
seen := make(map[string]struct{})
var out []string
add := func(url string) {
url = strings.TrimSpace(url)
if url == "" || isDeprecatedChinaCIDRSource(url) {
return
}
if _, ok := seen[url]; ok {
return
}
seen[url] = struct{}{}
out = append(out, url)
}
for _, raw := range sources {
add(raw)
if proxy := ghfastProxyURL(raw); proxy != "" {
add(proxy)
}
}
return out
}
func downloadFromSources(ctx context.Context, sources ...string) ([]string, error) {
var tried []string
var errs []string
for _, url := range expandDownloadSources(sources...) {
tried = append(tried, url)
lines, err := downloadLines(ctx, url)
if err == nil {
return lines, nil
}
errs = append(errs, fmt.Sprintf("%s (%v)", shortSourceURL(url), err))
}
if len(tried) == 0 {
return nil, fmt.Errorf("无可用的下载源")
}
return nil, fmt.Errorf("已尝试 %d 个源均失败: %s", len(tried), strings.Join(errs, "; "))
}
func shortSourceURL(url string) string {
if len(url) <= 72 {
return url
}
return url[:69] + "..."
}
func downloadLinesWithFallback(ctx context.Context, primary string, fallbacks ...string) ([]string, error) {
return downloadFromSources(ctx, append([]string{primary}, fallbacks...)...)
}
func downloadLines(ctx context.Context, url string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
var lines []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
lines = append(lines, line)
}
return lines, nil
}
func buildCIDRFile(v4Lines, v6Lines []string) (string, int, int, error) {
var b strings.Builder
v4Count := 0
v6Count := 0
for _, line := range v4Lines {
cidr, err := normalizeCIDR(line)
if err != nil {
continue
}
if cidrTo4(cidr) == nil {
continue
}
b.WriteString(cidr + " 1;\n")
v4Count++
}
for _, line := range v6Lines {
cidr, err := normalizeCIDR(line)
if err != nil {
continue
}
if cidrTo4(cidr) != nil {
continue
}
b.WriteString(cidr + " 1;\n")
v6Count++
}
if v4Count < 100 {
return "", 0, 0, fmt.Errorf("IPv4 条目过少 (%d),拒绝更新", v4Count)
}
if v6Count < 10 {
return "", 0, 0, fmt.Errorf("IPv6 条目过少 (%d),拒绝更新", v6Count)
}
return b.String(), v4Count, v6Count, nil
}
func normalizeCIDR(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if !strings.Contains(raw, "/") {
if ip := net.ParseIP(raw); ip != nil {
if v4 := ip.To4(); v4 != nil {
return v4.String() + "/32", nil
}
return ip.String() + "/128", nil
}
return "", fmt.Errorf("invalid")
}
_, ipNet, err := net.ParseCIDR(raw)
if err != nil {
return "", err
}
return ipNet.String(), nil
}
func cidrTo4(cidr string) net.IP {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return nil
}
return ip.To4()
}
func (s *Service) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
opts := nginx.GenerateOptions{}
raw, _ := s.settings.Get(ctx, settings.KeyTrustedProxy)
opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
blRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
opts.GlobalIPBlacklist = ParseGlobalIPList(blRaw)
wlRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
opts.GlobalIPWhitelist = ParseGlobalIPList(wlRaw)
return opts
}
func ParseGlobalIPList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return nil
}
var list []string
if err := json.Unmarshal([]byte(raw), &list); err != nil {
return nil
}
return list
}
+208
View File
@@ -0,0 +1,208 @@
package cloudflared
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const minBinarySize = 10 << 20
// Mirror 是 cloudflared 二进制下载镜像。
type Mirror struct {
ID string `json:"id"`
Name string `json:"name"`
Base string `json:"base"`
}
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
func DefaultMirrors() []Mirror {
return []Mirror{
{ID: "official", Name: "GitHub 官方源"},
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
}
}
func findMirror(id string) Mirror {
id = strings.TrimSpace(id)
for _, mirror := range DefaultMirrors() {
if mirror.ID == id {
return mirror
}
}
return DefaultMirrors()[0]
}
func binaryFileName() string {
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
func DownloadURL(mirror Mirror, version string) string {
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
if strings.TrimSpace(version) != "" {
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
}
if strings.TrimSpace(mirror.Base) == "" {
return official
}
return mirror.Base + official
}
func (s *Service) binaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
}
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
func (s *Service) BinaryVersion() string {
path := s.binaryPath()
if _, err := os.Stat(path); err != nil {
return ""
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
return s.DownloadBinaryVersion(ctx, mirrorID, "")
}
// DownloadBinaryVersion 下载指定版本;version 为空表示最新版。
func (s *Service) DownloadBinaryVersion(ctx context.Context, mirrorID, version string) (string, error) {
mirror := findMirror(mirrorID)
target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
tmp := target + ".download"
_ = os.Remove(tmp)
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
_ = os.Remove(tmp)
return "", err
}
info, err := os.Stat(tmp)
if err != nil || info.Size() < minBinarySize {
_ = os.Remove(tmp)
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
}
if runtime.GOOS != "windows" {
_ = os.Chmod(tmp, 0o755)
}
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
}
previous := target + ".previous"
if _, err := os.Stat(target); err == nil {
_ = os.Remove(previous)
if err := os.Rename(target, previous); err != nil {
_ = os.Remove(tmp)
return "", err
}
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Rename(previous, target)
_ = os.Remove(tmp)
return "", err
}
return s.BinaryVersion(), nil
}
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", err
}
version := strings.TrimSpace(payload.TagName)
if version == "" {
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
}
return version, nil
}
// RollbackBinary 恢复上一版 cloudflared 二进制。
func (s *Service) RollbackBinary() (string, error) {
target := s.binaryPath()
previous := target + ".previous"
if _, err := os.Stat(previous); err != nil {
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
}
failed := target + ".failed"
_ = os.Remove(failed)
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, failed); err != nil {
return "", err
}
}
if err := os.Rename(previous, target); err != nil {
_ = os.Rename(failed, target)
return "", err
}
_ = os.Remove(failed)
return s.BinaryVersion(), nil
}
func fetchFile(ctx context.Context, url, path string) error {
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载 cloudflared 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("写入 cloudflared 失败: %w", err)
}
return nil
}
@@ -0,0 +1,23 @@
package cloudflared
import (
"strings"
"testing"
)
func TestDownloadURL(t *testing.T) {
official := DownloadURL(Mirror{ID: "official"}, "")
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
t.Fatalf("官方下载地址不正确:%s", official)
}
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
}
}
func TestFindMirrorFallback(t *testing.T) {
if got := findMirror("not-exist"); got.ID != "official" {
t.Fatalf("未知镜像应回退官方源:%#v", got)
}
}
@@ -0,0 +1,255 @@
package cloudflared
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const cloudflareAPIBase = "https://api.cloudflare.com/client/v4"
// CloudflareAPI 用 Account ID + API Token 调用 Cloudflare API。
type CloudflareAPI struct {
AccountID string
APIToken string
client *http.Client
}
func NewCloudflareAPI(accountID, apiToken string) *CloudflareAPI {
return &CloudflareAPI{
AccountID: strings.TrimSpace(accountID),
APIToken: strings.TrimSpace(apiToken),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type cfAPIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfEnvelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
Errors []cfAPIError `json:"errors"`
}
func (a *CloudflareAPI) do(ctx context.Context, method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, cloudflareAPIBase+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.APIToken)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("请求 Cloudflare API 失败: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
var envelope cfEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
return fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
}
if !envelope.Success {
message := "Cloudflare API 调用失败"
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
message = envelope.Errors[0].Message
}
return fmt.Errorf("%s(HTTP %d)", message, resp.StatusCode)
}
if out != nil && len(envelope.Result) > 0 {
if err := json.Unmarshal(envelope.Result, out); err != nil {
return fmt.Errorf("Cloudflare API 结果解析失败: %w", err)
}
}
return nil
}
// VerifyToken 调用账号级 tokens/verify 接口验证 API Token。
func (a *CloudflareAPI) VerifyToken(ctx context.Context) (string, error) {
if a.AccountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
if a.APIToken == "" {
return "", fmt.Errorf("请先填写 API Token(cfat_...)")
}
var result struct {
Status string `json:"status"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/tokens/verify", nil, &result); err != nil {
return "", err
}
if result.Status != "active" {
return "", fmt.Errorf("API Token 状态为 %s,不是 active", result.Status)
}
return "API Token 有效(状态:active)", nil
}
// CreateTunnel 创建一条 config_src=local 的命名隧道,并返回隧道 ID 与凭据 token。
func (a *CloudflareAPI) CreateTunnel(ctx context.Context, name string) (tunnelID, token string, err error) {
var result struct {
ID string `json:"id"`
Token string `json:"token"`
}
payload := map[string]any{"name": name, "config_src": "local"}
if err := a.do(ctx, http.MethodPost, "/accounts/"+a.AccountID+"/cfd_tunnel", payload, &result); err != nil {
return "", "", err
}
if result.ID == "" {
return "", "", fmt.Errorf("Cloudflare 未返回隧道 ID")
}
if strings.TrimSpace(result.Token) != "" {
return result.ID, result.Token, nil
}
token, err = a.TunnelToken(ctx, result.ID)
return result.ID, token, err
}
// TunnelToken 读取指定隧道的凭据 token。
func (a *CloudflareAPI) TunnelToken(ctx context.Context, tunnelID string) (string, error) {
var token string
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/token", nil, &token); err != nil {
return "", err
}
if strings.TrimSpace(token) == "" {
return "", fmt.Errorf("Cloudflare 未返回隧道凭据")
}
return token, nil
}
// PutIngress 把本地 ingress 推送到 Cloudflare 云端配置。
func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress []IngressRule) error {
return a.do(ctx, http.MethodPut, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", map[string]any{
"config": map[string]any{"ingress": ingress},
}, nil)
}
// GetIngress 读取云端隧道的 ingress 配置。
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
var result struct {
Config struct {
Ingress []IngressRule `json:"ingress"`
} `json:"config"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
return nil, err
}
return result.Config.Ingress, nil
}
// DeleteTunnel 删除云端隧道。
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error {
return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil)
}
// Zone 是 Cloudflare 的域名区域。
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// ListZones 返回当前 API Token 可访问的 Zone。
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
return nil, err
}
return zones, nil
}
// FindZone 从 hostname 逐级向上查找所属 Zone。
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
labels := strings.Split(strings.Trim(hostname, "."), ".")
for i := 0; i < len(labels)-1; i++ {
candidate := strings.Join(labels[i:], ".")
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(candidate), nil, &zones); err != nil {
return "", "", err
}
if len(zones) > 0 {
return zones[0].ID, zones[0].Name, nil
}
}
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
}
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
var records []struct {
ID string `json:"id"`
Content string `json:"content"`
}
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
return "", err
}
payload := map[string]any{
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
}
if len(records) > 0 {
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
}
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
return "", err
}
return records[0].ID, nil
}
var created struct {
ID string `json:"id"`
}
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
return "", err
}
if strings.TrimSpace(created.ID) == "" {
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
}
return created.ID, nil
}
// DeleteDNSRecord 删除一条明确由 Havline 接管的 DNS 记录。
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
}
type accessApp struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
}
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
var apps []accessApp
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
return false, "", err
}
wanted := strings.ToLower(strings.TrimSpace(hostname))
for _, app := range apps {
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
return true, app.Name, nil
}
}
return false, "", nil
}
+198
View File
@@ -0,0 +1,198 @@
package cloudflared
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/havline/havline/internal/proxy"
)
type tokenPayload struct {
AccountTag string `json:"a"`
TunnelID string `json:"t"`
TunnelSecret string `json:"s"`
}
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
if clean == "" {
return "", "", "", fmt.Errorf("token 为空")
}
raw, err := decodeBase64(clean)
if err != nil {
return "", "", "", err
}
var payload tokenPayload
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
var alt struct {
AccountTag string `json:"AccountTag"`
TunnelID string `json:"TunnelID"`
TunnelSecret string `json:"TunnelSecret"`
}
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
}
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
}
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
}
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
}
// decodeBase64 同时兼容标准 base64 与 URL-safe base64,并兼容有无填充两种形式。
// Cloudflare 面板生成的 Tunnel Token 使用 URL-safe 编码,直接用标准 base64 会在 '-' / '_' 处失败。
func decodeBase64(input string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(input); err == nil {
return data, nil
}
}
padded := input
if remainder := len(padded) % 4; remainder != 0 {
padded += strings.Repeat("=", 4-remainder)
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(padded); err == nil {
return data, nil
}
}
return nil, fmt.Errorf("token base64 解码失败:请确认粘贴的是完整的 Cloudflare 隧道 token")
}
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
data, _ := json.MarshalIndent(map[string]string{
"AccountTag": accountTag,
"TunnelID": tunnelID,
"TunnelSecret": tunnelSecret,
}, "", " ")
return string(data)
}
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
var b strings.Builder
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
writeNetworkSection(&b, network)
b.WriteString("ingress:\n")
for _, rule := range ingress {
if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
if path := strings.TrimSpace(rule.Path); path != "" && path != "*" {
fmt.Fprintf(&b, " path: %s\n", path)
}
fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue
}
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
}
return b.String()
}
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
protocol := strings.TrimSpace(network.TransportProtocol)
edge := strings.TrimSpace(network.EdgeIPVersion)
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
if protocol != "" && protocol != "auto" {
fmt.Fprintf(b, "protocol: %s\n", protocol)
}
if edge != "" && edge != "auto" {
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
}
if network.HAConnections > 0 {
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
}
if hasOrigin {
b.WriteString("originRequest:\n")
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
fmt.Fprintf(b, " caPool: %s\n", pool)
}
if network.NoTLSVerify {
b.WriteString(" noTLSVerify: true\n")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
}
}
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
b.WriteString("\n")
}
}
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
func BuildIngress(hostnames []string, service string) []IngressRule {
seen := map[string]bool{}
cleaned := make([]string, 0, len(hostnames))
for _, host := range hostnames {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" || seen[host] {
continue
}
seen[host] = true
cleaned = append(cleaned, host)
}
sort.Strings(cleaned)
out := make([]IngressRule, 0, len(cleaned)+1)
for _, host := range cleaned {
out = append(out, IngressRule{Hostname: host, Service: service})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// BuildIngressForRules 从反向代理规则派生 ingress,每条域名使用自己的回源地址。
func BuildIngressForRules(rules []proxy.Rule) []IngressRule {
serviceByHost := map[string]string{}
for _, rule := range rules {
if !rule.CloudflareEnabled() {
continue
}
service := strings.TrimSpace(rule.Upstream)
if service == "" {
continue
}
for _, host := range rule.Hostnames() {
serviceByHost[host] = service
}
}
hosts := make([]string, 0, len(serviceByHost))
for host := range serviceByHost {
hosts = append(hosts, host)
}
sort.Strings(hosts)
out := make([]IngressRule, 0, len(hosts)+1)
for _, host := range hosts {
out = append(out, IngressRule{Hostname: host, Service: serviceByHost[host]})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
func NormalizeService(protocol, host string, port int) string {
host = strings.TrimSpace(host)
if host == "localhost" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
}
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
func MaskSecret(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) <= 8 {
return "********"
}
return value[:4] + "********" + value[len(value)-4:]
}
+138
View File
@@ -0,0 +1,138 @@
package cloudflared
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"github.com/havline/havline/internal/proxy"
)
func TestDecodeToken(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
account, tunnel, secret, err := DecodeToken(encoded)
if err != nil {
t.Fatal(err)
}
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
}
}
func TestDecodeTokenAlternativeFields(t *testing.T) {
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("兼容字段名解析失败: %v", err)
}
}
func TestDecodeTokenRejectsInvalid(t *testing.T) {
if _, _, _, err := DecodeToken("not-base64"); err == nil {
t.Fatal("非法 token 应报错")
}
}
func TestDecodeTokenURLSafe(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("URL-safe token 应可解码: %v", err)
}
}
func TestDecodeBase64Flexible(t *testing.T) {
raw := []byte{0xfb, 0xef, 0xbe, 0xad}
for _, encoded := range []string{
base64.RawURLEncoding.EncodeToString(raw),
base64.URLEncoding.EncodeToString(raw),
base64.RawStdEncoding.EncodeToString(raw),
} {
got, err := decodeBase64(encoded)
if err != nil || !bytes.Equal(got, raw) {
t.Fatalf("编码 %q 解码失败:%v", encoded, err)
}
}
}
func TestBuildIngressInvariants(t *testing.T) {
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
t.Fatalf("域名应去重并稳定排序:%#v", got)
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesUsesRuleUpstream(t *testing.T) {
rules := []proxy.Rule{
{
Enabled: true,
Upstream: "http://192.168.1.10:8080",
Hosts: []proxy.Host{{Hostname: "b.example.com"}},
Exits: []string{proxy.ExitCloudflare},
CFTunnelID: 9,
},
{
Enabled: true,
Upstream: "http://192.168.1.11:9090",
Hosts: []proxy.Host{{Hostname: "a.example.com"}},
Exits: []string{proxy.ExitLocal, proxy.ExitCloudflare},
CFTunnelID: 9,
},
}
got := BuildIngressForRules(rules)
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[0].Service != "http://192.168.1.11:9090" {
t.Fatalf("第一条 ingress 不符:%#v", got[0])
}
if got[1].Hostname != "b.example.com" || got[1].Service != "http://192.168.1.10:8080" {
t.Fatalf("第二条 ingress 不符:%#v", got[1])
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesSkipsDisabledAndManualRules(t *testing.T) {
rules := []proxy.Rule{
{Enabled: false, Upstream: "http://127.0.0.1:8080", Hosts: []proxy.Host{{Hostname: "off.example.com"}}, Exits: []string{proxy.ExitCloudflare}, CFTunnelID: 1},
{Enabled: true, Upstream: "http://127.0.0.1:8081", Hosts: []proxy.Host{{Hostname: "local.example.com"}}, Exits: []string{proxy.ExitLocal}},
}
got := BuildIngressForRules(rules)
if len(got) != 1 || got[0].Service != "http_status:404" {
t.Fatalf("停用规则和仅本机规则不应进入 Cloudflare ingress:%#v", got)
}
}
func TestBuildConfig(t *testing.T) {
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
for _, want := range []string{
"tunnel: tunnel-1",
"credentials-file: /data/creds.json",
"protocol: http2",
`edge-ip-version: "4"`,
"ha-connections: 2",
"- hostname: a.example.com",
"service: http://127.0.0.1:80",
"- service: http_status:404",
} {
if !strings.Contains(config, want) {
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
}
}
}
func TestNormalizeService(t *testing.T) {
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
t.Fatalf("NormalizeService() = %q", got)
}
}
+377
View File
@@ -0,0 +1,377 @@
package cloudflared
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
HealthRetention = 30 * 24 * time.Hour
)
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
type HealthStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// HealthSample 交给落库层的状态样本。
type HealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// HealthEvent 一条状态变更记录。
type HealthEvent struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// HealthIncident 一段 Cloudflare 隧道不可用区间。
type HealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
type UptimeStat struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []HealthIncident `json:"incidents"`
}
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
type TunnelSummary struct {
Count int
Online int
Status string
Message string
LastError string
}
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return nil, err
}
out := []HealthStatus{}
for i := range tunnels {
tunnel := tunnels[i]
s.attachConfig(&tunnel)
status := s.manager.Status(tunnel.ID)
for _, domain := range tunnel.Hostnames {
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
}
}
return out, nil
}
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
if !globalEnabled {
item.Reason = "disabled"
item.Detail = "Cloudflare 隧道全局托管已停用"
return item
}
if status.Running {
item.Known = true
if status.Status == "connected" {
item.State = HealthUp
return item
}
item.State = HealthDown
item.Reason = "connection"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
if tunnel.AutoStart {
item.Known = true
item.State = HealthDown
item.Reason = "process"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
item.Reason = "stopped"
item.Detail = "隧道未运行,且未启用自动启动"
return item
}
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
if len(samples) == 0 {
return nil
}
current, err := s.latestHealthStates(ctx)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
continue
}
key := healthKey(sample.TunnelID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx, `
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
VALUES (?, ?, ?, ?, ?, ?)
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return err
}
}
return nil
}
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.store.db.QueryContext(ctx, `
SELECT e.tunnel_id, e.domain, e.state
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var tunnelID int64
var domain, state string
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
return nil, err
}
states[healthKey(tunnelID, domain)] = state
}
return states, rows.Err()
}
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.healthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeHealthUptime(events, since, now), nil
}
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.store.db.QueryContext(ctx, `
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM cf_health_events WHERE at >= ?
UNION ALL
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events WHERE at < ?
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := []HealthEvent{}
for rows.Next() {
var event HealthEvent
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// PruneHealth 清理超过保留期的状态变更。
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Summary 返回隧道数量、在线数与最近错误。
func (s *Service) Summary(ctx context.Context) TunnelSummary {
tunnels, err := s.store.List(ctx)
if err != nil {
return TunnelSummary{Status: "error", Message: err.Error()}
}
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
if len(tunnels) == 0 {
summary.Status = "none"
summary.Message = "未创建隧道"
return summary
}
for _, tunnel := range tunnels {
status := s.manager.Status(tunnel.ID)
if status.Running && status.Status == "connected" {
summary.Online++
}
if strings.TrimSpace(tunnel.LastError) != "" {
summary.LastError = tunnel.LastError
}
}
if summary.Online == 0 {
summary.Status = "error"
summary.Message = "没有在线隧道"
} else if summary.Online < summary.Count {
summary.Status = "warning"
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
} else {
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
}
return summary
}
func healthKey(tunnelID int64, domain string) string {
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
}
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
type healthPoint struct {
at time.Time
tunnelID int64
domain string
state string
reason, detail string
}
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
byRule := map[string][]healthPoint{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := healthKey(event.TunnelID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], healthPoint{
at: at, tunnelID: event.TunnelID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]UptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
stat := computeHealthPoints(points, from, to)
if stat.Domain != "" {
stats = append(stats, stat)
}
}
return stats
}
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
start := from
index := 0
state, reason, detail := "", "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
return UptimeStat{}
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
upMinutes := 0
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case HealthUp:
upMinutes += minutes
case HealthDown:
stat.DownMinutes += minutes
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
stat.State = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
return stat
}
@@ -0,0 +1,57 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
)
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
to := from.Add(2 * time.Hour)
events := []HealthEvent{
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
}
stats := ComputeHealthUptime(events, from, to)
if len(stats) != 1 {
t.Fatalf("应返回一条统计:%#v", stats)
}
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
t.Fatalf("可用率计算不符:%#v", stats[0])
}
}
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
var count int
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return nil
}
@@ -0,0 +1,9 @@
//go:build windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{HideWindow: true}
}
+375
View File
@@ -0,0 +1,375 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/config"
)
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
type processState struct {
cmd *exec.Cmd
status string
lastError string
metricsPort int
metrics RuntimeStatus
logMu sync.Mutex
}
// Manager 托管 cloudflared 子进程、日志与 metrics。
type Manager struct {
cfg config.Config
logger *slog.Logger
mu sync.Mutex
processes map[int64]*processState
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
}
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote {
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run", "--token", token)
}
args = append(args, "--config", configPath)
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run")
}
func networkArgs(network NetworkSettings) []string {
args := []string{}
if network.HAConnections > 0 {
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
}
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
args = append(args, "--origin-ca-pool", pool)
}
if network.NoTLSVerify {
args = append(args, "--no-tls-verify")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
args = append(args, "--http-host-header", header)
}
return args
}
func buildEnv(network NetworkSettings) []string {
env := os.Environ()
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
}
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
}
if network.ProxyMode == "disabled" {
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
}
return env
}
func removeEnv(env []string, names []string) []string {
out := make([]string, 0, len(env))
for _, item := range env {
remove := false
for _, name := range names {
if strings.HasPrefix(item, name+"=") {
remove = true
break
}
}
if !remove {
out = append(out, item)
}
}
return out
}
// Start 启动一条隧道的 cloudflared 子进程。
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
m.mu.Lock()
if _, exists := m.processes[tunnel.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("隧道已在运行中")
}
state := &processState{status: "starting"}
m.processes[tunnel.ID] = state
m.mu.Unlock()
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
m.remove(tunnel.ID)
return err
}
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return fmt.Errorf("启动 cloudflared 失败: %w", err)
}
state.cmd = cmd
go m.pipeLogs(tunnel.ID, stdout, logFile)
go m.pipeLogs(tunnel.ID, stderr, logFile)
go m.wait(tunnel.ID, cmd, logFile)
return nil
}
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
m.writeLog(id, logFile, line)
m.detectMetrics(id, line)
}
}
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return
}
state.logMu.Lock()
defer state.logMu.Unlock()
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
}
func (m *Manager) detectMetrics(id int64, line string) {
match := metricsPortPattern.FindStringSubmatch(line)
if len(match) != 2 {
return
}
port, err := strconv.Atoi(match[1])
if err != nil || port <= 0 {
return
}
m.mu.Lock()
state := m.processes[id]
if state == nil || state.metricsPort == port {
m.mu.Unlock()
return
}
state.metricsPort = port
m.mu.Unlock()
go m.scrapeMetrics(id, port)
}
func (m *Manager) scrapeMetrics(id int64, port int) {
client := &http.Client{Timeout: 5 * time.Second}
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
lastIn, lastOut := 0.0, 0.0
for {
time.Sleep(2 * time.Second)
if !m.running(id) {
return
}
text, err := fetchText(client, metricsURL)
if err != nil {
continue
}
snapshot := parseMetrics(text)
status := RuntimeStatus{
Running: true, Status: "connecting", MetricsPort: port,
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
Transport: snapshot.transport,
}
if snapshot.hasQUIC {
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
} else {
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
}
if snapshot.connections > 0 {
status.Status = "connected"
} else if isReady(client, readyURL) {
status.Status = "reconnecting"
}
m.mu.Lock()
if state := m.processes[id]; state != nil {
state.metrics = status
state.status = status.Status
}
m.mu.Unlock()
}
}
func (m *Manager) running(id int64) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.processes[id] != nil
}
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
_ = logFile.Close()
m.mu.Lock()
state := m.processes[id]
if state != nil {
if err != nil {
state.status = "failed"
state.lastError = err.Error()
} else {
state.status = "stopped"
}
}
m.mu.Unlock()
}
func (m *Manager) remove(id int64) {
m.mu.Lock()
delete(m.processes, id)
m.mu.Unlock()
}
// Stop 停止隧道子进程。
func (m *Manager) Stop(id int64) error {
m.mu.Lock()
state := m.processes[id]
delete(m.processes, id)
m.mu.Unlock()
if state == nil || state.cmd == nil || state.cmd.Process == nil {
return nil
}
_ = state.cmd.Process.Kill()
return nil
}
// Status 返回运行状态与最近一次 metrics 快照。
func (m *Manager) Status(id int64) RuntimeStatus {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return RuntimeStatus{Status: "stopped"}
}
out := state.metrics
out.Status = state.status
out.LastError = state.lastError
out.MetricsPort = state.metricsPort
out.Running = state.status != "stopped" && state.status != "failed"
return out
}
// Logs 读取日志文件末尾若干行。
func (m *Manager) Logs(logPath string, lines int) (string, error) {
if lines <= 0 {
lines = 200
}
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
return strings.Join(all, "\n"), nil
}
type metricsSnapshot struct {
connections int
latencyMS float64
bytesIn float64
bytesOut float64
transport string
hasQUIC bool
}
func parseMetrics(text string) metricsSnapshot {
snapshot := metricsSnapshot{transport: "http2"}
var rttSum, rttCount float64
for _, line := range strings.Split(text, "\n") {
switch {
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
snapshot.connections = int(parseMetricValue(line))
case strings.HasPrefix(line, "quic_client_receive_bytes"):
snapshot.bytesIn += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_sent_bytes"):
snapshot.bytesOut += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
rttSum += parseMetricValue(line)
rttCount++
snapshot.hasQUIC = true
}
}
if snapshot.hasQUIC {
snapshot.transport = "quic"
if rttCount > 0 {
snapshot.latencyMS = rttSum / rttCount
}
}
return snapshot
}
func parseMetricValue(line string) float64 {
fields := strings.Fields(line)
if len(fields) == 0 {
return 0
}
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
return value
}
func fetchText(client *http.Client, url string) (string, error) {
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
return string(data), nil
}
func isReady(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
@@ -0,0 +1,59 @@
package cloudflared
import (
"strings"
"testing"
)
func TestBuildRunArgs(t *testing.T) {
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
if !strings.Contains(local, want) {
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
}
}
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
}
}
func TestParseMetrics(t *testing.T) {
text := strings.Join([]string{
"cloudflared_tunnel_ha_connections 2",
"quic_client_receive_bytes 1000",
"quic_client_sent_bytes 500",
"quic_client_smoothed_rtt 0.02",
}, "\n")
snapshot := parseMetrics(text)
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
}
if snapshot.latencyMS <= 0 {
t.Fatalf("RTT 解析失败:%#v", snapshot)
}
}
func TestParseMetricsHTTP2(t *testing.T) {
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
}
}
func TestMetricsPortPatternCaseInsensitive(t *testing.T) {
match := metricsPortPattern.FindStringSubmatch("2026-09-30T18:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics")
if len(match) != 2 || match[1] != "20241" {
t.Fatalf("应识别大写 Starting 的 metrics 端口:%#v", match)
}
}
func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
for _, item := range env {
if strings.HasPrefix(item, "HTTP_PROXY=") {
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package cloudflared
import "time"
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
const (
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Havline 生成
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
)
// NetworkSettings 是 cloudflared 的网络调优参数。
type NetworkSettings struct {
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
HAConnections int `json:"ha_connections"`
ProxyMode string `json:"proxy_mode"` // system / disabled
OriginCAPool string `json:"origin_ca_pool"`
NoTLSVerify bool `json:"no_tls_verify"`
HTTPHostHeader string `json:"http_host_header"`
}
// DefaultNetworkSettings 返回国内环境更稳的默认值。
func DefaultNetworkSettings() NetworkSettings {
return NetworkSettings{
TransportProtocol: "http2",
EdgeIPVersion: "4",
HAConnections: 2,
ProxyMode: "system",
}
}
// IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Service string `json:"service" yaml:"service"`
}
// Tunnel 是一条 Cloudflare 隧道。
type Tunnel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
TunnelID string `json:"tunnel_id"`
AccountTag string `json:"account_tag"`
CredentialsEnc string `json:"-"`
CertPEMEnc string `json:"-"`
ConfigPath string `json:"config_path"`
CredsPath string `json:"creds_path"`
LogPath string `json:"log_path"`
Network NetworkSettings `json:"network"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
DNSWarning string `json:"dns_warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateInput 是创建 / 更新隧道时的入参。
type CreateInput struct {
Name string `json:"name"`
Mode string `json:"mode"`
Token string `json:"token"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
}
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
type AppSettings struct {
AccountID string `json:"account_id"`
Enabled bool `json:"enabled"`
DefaultTokenConfigured bool `json:"default_token_configured"`
DefaultTokenMasked string `json:"default_token_masked"`
APITokenConfigured bool `json:"api_token_configured"`
APITokenMasked string `json:"api_token_masked"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
type AppSettingsInput struct {
AccountID string `json:"account_id"`
Enabled *bool `json:"enabled,omitempty"`
Token string `json:"token"`
APIToken string `json:"api_token"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// RuntimeStatus 是隧道运行状态与指标快照。
type RuntimeStatus struct {
Running bool `json:"running"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
Connections int `json:"connections"`
LatencyMS float64 `json:"latency_ms"`
BandwidthIn float64 `json:"bandwidth_in"`
BandwidthOut float64 `json:"bandwidth_out"`
Transport string `json:"transport"`
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
}
+323
View File
@@ -0,0 +1,323 @@
package cloudflared
import (
"context"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
)
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
type DriftResult struct {
TunnelID int64 `json:"tunnel_id"`
Managed bool `json:"managed"`
HasDrift bool `json:"has_drift"`
Items []string `json:"items"`
}
// DiagnosticCheck 一项诊断结果。
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
type DiagnosticsResult struct {
TunnelID int64 `json:"tunnel_id"`
GeneratedAt string `json:"generated_at"`
Checks []DiagnosticCheck `json:"checks"`
}
// PreflightCheck 是接入向导的一项预检结果。
type PreflightCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
type PreflightResult struct {
Ready bool `json:"ready"`
Checks []PreflightCheck `json:"checks"`
}
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
func (s *Service) Preflight(ctx context.Context) PreflightResult {
binary := s.binaryCheck()
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
}
checks = append(checks, PreflightCheck{
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
})
checks = append(checks, PreflightCheck{
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
})
if row.APITokenEnc == "" {
return PreflightResult{Ready: false, Checks: checks}
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
}
zones, err := api.ListZones(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
}
checks = append(checks, PreflightCheck{
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
})
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
}
func statusBool(ok bool) string {
if ok {
return "ok"
}
return "error"
}
func boolText(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func hasPreflightError(checks []PreflightCheck) bool {
for _, check := range checks {
if check.Status == "error" {
return true
}
}
return false
}
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DriftResult{}, err
}
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
if !tunnel.Managed {
return result, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return DriftResult{}, err
}
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DriftResult{}, err
}
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
if err != nil {
return DriftResult{}, err
}
result.Items = compareIngress(expected, actual)
result.HasDrift = len(result.Items) > 0
return result, nil
}
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if !tunnel.Managed {
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
}
return s.syncManagedTunnel(ctx, tunnel)
}
func compareIngress(expected, actual []IngressRule) []string {
expectedMap := ingressMap(expected)
actualMap := ingressMap(actual)
items := []string{}
keys := unionKeys(expectedMap, actualMap)
for _, key := range keys {
want, wantOK := expectedMap[key]
got, gotOK := actualMap[key]
switch {
case wantOK && !gotOK:
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
case !wantOK && gotOK:
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
case want != got:
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
}
}
return items
}
func ingressMap(ingress []IngressRule) map[string]string {
out := map[string]string{}
for _, item := range ingress {
key := strings.ToLower(strings.TrimSpace(item.Hostname))
if key == "" {
key = "(catch-all)"
}
out[key] = strings.TrimSpace(item.Service)
}
return out
}
func unionKeys(a, b map[string]string) []string {
seen := map[string]struct{}{}
for key := range a {
seen[key] = struct{}{}
}
for key := range b {
seen[key] = struct{}{}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DiagnosticsResult{}, err
}
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
result.Checks = append(result.Checks, s.binaryCheck())
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
api := s.apiDiagnostic(ctx, tunnel)
result.Checks = append(result.Checks, api)
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
return result, nil
}
func (s *Service) binaryCheck() DiagnosticCheck {
version := s.BinaryVersion()
if strings.TrimSpace(version) == "" {
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
}
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
}
func configCheck(path string) DiagnosticCheck {
if _, err := os.Stat(path); err != nil {
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
}
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
}
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
if status.Running && status.Status == "connected" {
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
}
if tunnel.AutoStart {
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
}
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
}
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
if tunnel.TunnelID == "" {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
}
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
}
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil {
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
}
if len(records) == 0 {
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Havline 接管的 DNS 记录"}
}
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
}
func networkCheck(name, address string) DiagnosticCheck {
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
}
_ = conn.Close()
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
}
// RouteTestResult 是单条路由源服务的连通性测试结果。
type RouteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
LatencyMS float64 `json:"latency_ms"`
}
// TestService 测试一条路由 service 的 TCP 连通性。
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
address, err := serviceAddress(service)
if err != nil {
return RouteTestResult{}, err
}
start := time.Now()
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return RouteTestResult{OK: false, Message: err.Error()}, nil
}
_ = conn.Close()
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
}
func serviceAddress(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, ":") {
return "", fmt.Errorf("service 格式无效:%s", raw)
}
return raw, nil
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
switch parsed.Scheme {
case "http":
port = "80"
case "https":
port = "443"
case "ssh":
port = "22"
default:
return "", fmt.Errorf("service 缺少端口:%s", raw)
}
}
return net.JoinHostPort(host, port), nil
}
@@ -0,0 +1,28 @@
package cloudflared
import "testing"
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
{Service: "http_status:404"},
}
items := compareIngress(expected, actual)
if len(items) != 3 {
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
}
}
func TestCompareIngressIgnoresOrder(t *testing.T) {
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Service: "http_status:404"},
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
}
if items := compareIngress(expected, actual); len(items) != 0 {
t.Fatalf("顺序不同不应算漂移:%#v", items)
}
}
+185
View File
@@ -0,0 +1,185 @@
package cloudflared
import (
"context"
"fmt"
"os"
"strings"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/validate"
)
// RouteView 是配置页展示的一条 ingress 路由。
type RouteView struct {
ID int64 `json:"id,omitempty"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
Source string `json:"source"`
}
// TunnelRoutes 是配置页需要的路由列表。
type TunnelRoutes struct {
Managed bool `json:"managed"`
Routes []RouteView `json:"routes"`
CatchAll string `json:"catch_all"`
}
// RouteInput 是手工隧道路由保存入参。
type RouteInput struct {
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
}
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return s.managedRoutes(ctx, tunnel)
}
return manualRoutes(tunnel.ConfigPath)
}
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
if s.rules == nil {
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return TunnelRoutes{}, err
}
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
for _, host := range rule.Hostnames() {
out.Routes = append(out.Routes, RouteView{
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
})
}
}
return out, nil
}
func manualRoutes(configPath string) (TunnelRoutes, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
return TunnelRoutes{}, err
}
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
hostname := strings.TrimSpace(host)
if hostname == "" || i+1 >= len(lines) {
continue
}
path := "*"
next := i + 1
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
path = strings.TrimSpace(value)
next++
}
if next >= len(lines) {
continue
}
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
if !ok {
continue
}
out.Routes = append(out.Routes, RouteView{
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
})
}
if len(out.Routes) > 0 {
catchAll, err := catchAllService(data)
if err == nil && catchAll != "" {
out.CatchAll = catchAll
}
}
return out, nil
}
func catchAllService(data []byte) (string, error) {
lines := strings.Split(string(data), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- service:") {
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
}
}
return "", fmt.Errorf("未找到 catch-all")
}
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
}
ingress, hostnames, err := buildManualIngress(routes)
if err != nil {
return TunnelRoutes{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
return TunnelRoutes{}, err
}
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return TunnelRoutes{}, err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return TunnelRoutes{}, err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
return TunnelRoutes{}, err
}
}
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
return TunnelRoutes{}, err
}
return s.Routes(ctx, id)
}
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
seen := map[string]struct{}{}
ingress := make([]IngressRule, 0, len(routes)+1)
hostnames := make([]string, 0, len(routes))
for _, route := range routes {
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
if err := validate.Domain(hostname); err != nil {
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
}
if _, ok := seen[hostname]; ok {
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
}
seen[hostname] = struct{}{}
service := strings.TrimSpace(route.Service)
if service == "" || !strings.Contains(service, "://") {
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
}
path := strings.TrimSpace(route.Path)
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
if path == "" || path == "*" {
path = ""
}
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
hostnames = append(hostnames, hostname)
}
ingress = append(ingress, IngressRule{Service: "http_status:404"})
return ingress, hostnames, nil
}
@@ -0,0 +1,51 @@
package cloudflared
import (
"os"
"path/filepath"
"testing"
)
func TestManualRoutesFromConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
content := `tunnel: test
ingress:
- hostname: a.example.com
service: http://127.0.0.1:8080
- hostname: b.example.com
service: http://127.0.0.1:9090
- service: http_status:404
`
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
result, err := manualRoutes(path)
if err != nil {
t.Fatal(err)
}
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
t.Fatalf("手工路由解析不符:%#v", result)
}
if result.CatchAll != "http_status:404" {
t.Fatalf("catch-all 不符:%q", result.CatchAll)
}
}
func TestBuildManualIngress(t *testing.T) {
ingress, hostnames, err := buildManualIngress([]RouteInput{
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
})
if err != nil {
t.Fatal(err)
}
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
t.Fatalf("ingress 结果不符:%#v", ingress)
}
if ingress[0].Path != "" || ingress[1].Path != "" {
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
}
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
t.Fatalf("hostnames 结果不符:%#v", hostnames)
}
}
+828
View File
@@ -0,0 +1,828 @@
package cloudflared
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/secret"
)
const defaultOriginService = "http://127.0.0.1:80"
// RuleSource 提供本机反代规则,用于推导 ingress 候选域名。
type RuleSource interface {
List(ctx context.Context) ([]proxy.Rule, error)
}
// Service 是 Cloudflare 隧道模块的门面。
type Service struct {
cfg config.Config
store *Store
secretBox *secret.Box
logger *slog.Logger
manager *Manager
rules RuleSource
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, rules RuleSource) *Service {
moduleLogger := logger.With("module", "CLOUDFLARED")
return &Service{
cfg: cfg,
store: store,
secretBox: secretBox,
logger: moduleLogger,
manager: NewManager(cfg, moduleLogger),
rules: rules,
}
}
func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
}
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
items, err := s.store.List(ctx)
if err != nil {
return nil, err
}
for i := range items {
s.attachConfig(&items[i])
}
return items, nil
}
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
s.attachConfig(&item)
return item, nil
}
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
if strings.TrimSpace(in.Mode) == "" {
in.Mode = ModeAccountLocal
}
if err := validateInput(in); err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
in.Network = s.applyDefaultNetwork(ctx, in.Network)
tunnelID, tunnelToken, err := api.CreateTunnel(ctx, in.Name)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, fmt.Errorf("Cloudflare 返回的隧道凭据无法解析: %w", err)
}
dir := s.tunnelDir(in.Name)
if err := os.MkdirAll(dir, 0o750); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
configPath := filepath.Join(dir, "config.yml")
credsPath := filepath.Join(dir, "creds.json")
logPath := filepath.Join(dir, "cloudflared.log")
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(configPath, []byte(BuildConfig(decodedID, credsPath, in.Network, ingress)), 0o640); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
tunnel, err := s.store.Create(ctx, in, decodedID, accountTag, encrypted, "", configPath, credsPath, logPath)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if tunnel.Managed {
if err := s.syncManagedTunnel(ctx, tunnel); err != nil {
s.logger.Warn("创建托管隧道后同步规则失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但规则或 DNS 同步失败:" + err.Error()
}
return tunnel, nil
}
_ = api.PutIngress(ctx, decodedID, ingress)
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, decodedID, in.Hostnames); err != nil {
s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + err.Error()
}
return tunnel, nil
}
// Update 更新隧道配置;凭据由 Cloudflare API 创建时生成,编辑不修改。
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
if current.Managed && !in.Managed {
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return Tunnel{}, fmt.Errorf("仍有 %d 条反向代理规则使用该托管隧道,请先移除 Cloudflare 出口", count)
}
}
_ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err
}
updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
if err != nil {
return Tunnel{}, err
}
dnsWarning := ""
if updated.Managed {
if err := s.syncManagedTunnel(ctx, updated); err != nil {
dnsWarning = "托管隧道已保存,但规则或 DNS 同步失败:" + err.Error()
}
} else if updated.Mode == ModeAccountLocal && updated.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, updated.TunnelID, ingress)
if err := s.syncTunnelDNS(ctx, api, updated.ID, updated.TunnelID, in.Hostnames); err != nil {
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", updated.TunnelID, "error", err.Error())
dnsWarning = "DNS 记录同步失败:" + err.Error()
}
}
}
updated.DNSWarning = dnsWarning
return updated, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
current, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return fmt.Errorf("仍有 %d 条反向代理规则使用该隧道,请先移除这些规则的 Cloudflare 出口", count)
}
_ = s.manager.Stop(id)
if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
s.deleteTunnelDNS(ctx, api, current.ID)
_ = api.DeleteTunnel(ctx, current.TunnelID)
}
}
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
_ = os.RemoveAll(dir)
}
return s.store.Delete(ctx, id)
}
func (s *Service) tunnelUsageCount(ctx context.Context, id int64) int {
if s.rules == nil {
return 0
}
rules, err := s.rules.List(ctx)
if err != nil {
return 0
}
count := 0
for _, rule := range rules {
if rule.UsesExit(proxy.ExitCloudflare) && rule.CFTunnelID == id {
count++
}
}
return count
}
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return Tunnel{}, err
}
if !settings.Enabled {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道全局已停用,请先在页面顶部重新启用")
}
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
binaryPath := s.binaryPath()
if _, err := os.Stat(binaryPath); err != nil {
return Tunnel{}, fmt.Errorf("cloudflared 未安装,请先下载二进制")
}
token := ""
if tunnel.Mode == ModeTokenRemote {
plain, err := s.secretBox.Decrypt(tunnel.CredentialsEnc)
if err != nil {
return Tunnel{}, fmt.Errorf("凭据解密失败: %w", err)
}
token = plain
}
if err := s.manager.Start(tunnel, binaryPath, tunnel.ConfigPath, token); err != nil {
_ = s.store.SetRuntime(ctx, id, "failed", err.Error(), 0)
return Tunnel{}, err
}
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
_ = s.manager.Stop(id)
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
_, _ = s.Stop(ctx, id)
return s.Start(ctx, id)
}
// RefreshCredentials 重新从 Cloudflare 获取隧道凭据并更新本地 creds.json。
func (s *Service) RefreshCredentials(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
token, err := api.TunnelToken(ctx, tunnel.TunnelID)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.CredsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := s.store.UpdateCredentials(ctx, id, encrypted); err != nil {
return Tunnel{}, err
}
if s.manager.Status(id).Running {
return s.Restart(ctx, id)
}
return s.Get(ctx, id)
}
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
if _, err := s.store.Get(ctx, id); err != nil {
return RuntimeStatus{}, err
}
return s.manager.Status(id), nil
}
// AppSettings 返回模块级配置;默认 token 只回显掩码。
func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
out := AppSettings{AccountID: row.AccountID, Enabled: row.Enabled, Mirror: row.Mirror, Network: row.Network}
if row.TokenEnc != "" {
out.DefaultTokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil {
out.DefaultTokenMasked = MaskSecret(plain)
}
}
if row.APITokenEnc != "" {
out.APITokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.APITokenEnc); err == nil {
out.APITokenMasked = MaskSecret(plain)
}
}
return out, nil
}
// SaveAppSettings 保存默认 token、下载镜像与默认网络设置;Token 留空表示保留原值。
func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
accountID := strings.TrimSpace(in.AccountID)
if accountID == "" {
accountID = row.AccountID
}
enabled := row.Enabled
if in.Enabled != nil {
enabled = *in.Enabled
}
tokenEnc := row.TokenEnc
apiTokenEnc := row.APITokenEnc
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
apiTokenEnc, err = s.secretBox.Encrypt(apiToken)
if err != nil {
return AppSettings{}, err
}
}
mirror := strings.TrimSpace(in.Mirror)
if mirror == "" {
mirror = row.Mirror
}
if mirror == "" {
mirror = "official"
}
network := in.Network
if strings.TrimSpace(network.TransportProtocol) == "" {
network = row.Network
}
if err := s.store.SaveSettings(ctx, accountID, enabled, tokenEnc, apiTokenEnc, mirror, network); err != nil {
return AppSettings{}, err
}
return s.AppSettings(ctx)
}
// SetEnabled 切换全局托管开关;关闭时停止所有隧道,开启时恢复 auto_start 的隧道。
func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
if err := s.store.SaveSettings(ctx, row.AccountID, enabled, row.TokenEnc, row.APITokenEnc, row.Mirror, row.Network); err != nil {
return AppSettings{}, err
}
if enabled {
s.StartAll(ctx)
} else {
s.StopAll(ctx)
}
return s.AppSettings(ctx)
}
// StopAll 停止所有隧道进程,但保留配置、凭据与云端隧道。
func (s *Service) StopAll(ctx context.Context) {
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if err := s.manager.Stop(tunnel.ID); err != nil {
s.logger.Warn("停止 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
_ = s.store.SetRuntime(ctx, tunnel.ID, "stopped", "", 0)
}
}
func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettings) NetworkSettings {
if strings.TrimSpace(network.TransportProtocol) != "" {
return network
}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return DefaultNetworkSettings()
}
return row.Network
}
func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
accountID := strings.TrimSpace(row.AccountID)
if accountID == "" {
return nil, fmt.Errorf("请先在「应用配置」中填写 Account ID")
}
if row.APITokenEnc == "" {
return nil, fmt.Errorf("请先在「应用配置」中保存 API Token")
}
token, err := s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return nil, fmt.Errorf("API Token 解密失败: %w", err)
}
return NewCloudflareAPI(accountID, token), nil
}
// SyncRules 让所有托管隧道从反向代理规则重新派生 ingress 与 DNS。
func (s *Service) SyncRules(ctx context.Context) error {
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return err
}
var firstErr error
for _, tunnel := range tunnels {
if !tunnel.Managed {
continue
}
assigned := rulesForTunnel(rules, tunnel.ID)
dnsRecords, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil && firstErr == nil {
firstErr = err
continue
}
if len(assigned) == 0 && len(dnsRecords) == 0 {
continue
}
if err := s.syncManagedTunnel(ctx, tunnel); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func rulesForTunnel(rules []proxy.Rule, tunnelID int64) []proxy.Rule {
out := make([]proxy.Rule, 0)
for _, rule := range rules {
if !rule.CloudflareEnabled() || rule.CFTunnelID != tunnelID {
continue
}
out = append(out, rule)
}
return out
}
func (s *Service) syncManagedTunnel(ctx context.Context, tunnel Tunnel) error {
if !tunnel.Managed {
return nil
}
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
assigned := rulesForTunnel(rules, tunnel.ID)
ingress := BuildIngressForRules(assigned)
content := BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(content), 0o640); err != nil {
return err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnamesFromIngress(ingress)); err != nil {
return err
}
return s.restartIfRunning(ctx, tunnel.ID)
}
func hostnamesFromIngress(ingress []IngressRule) []string {
out := make([]string, 0, len(ingress))
for _, item := range ingress {
if strings.TrimSpace(item.Hostname) != "" {
out = append(out, item.Hostname)
}
}
return out
}
func (s *Service) restartIfRunning(ctx context.Context, id int64) error {
if !s.manager.Status(id).Running {
return nil
}
if _, err := s.Restart(ctx, id); err != nil {
return fmt.Errorf("配置已同步,但隧道重启失败:%w", err)
}
return nil
}
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Havline 自己接管的记录。
func (s *Service) syncTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, remoteTunnelID string, hostnames []string) error {
desired := make(map[string]struct{}, len(hostnames))
for _, raw := range hostnames {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname != "" {
desired[hostname] = struct{}{}
}
}
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
return err
}
var firstErr error
for _, record := range records {
if _, ok := desired[record.Hostname]; ok {
continue
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil && firstErr == nil {
firstErr = err
}
}
target := remoteTunnelID + ".cfargotunnel.com"
for hostname := range desired {
zoneID, _, err := api.FindZone(ctx, hostname)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
recordID, err := api.EnsureCNAME(ctx, zoneID, hostname, target)
if err != nil {
if transferredID, transferErr := s.transferTrackedDNS(ctx, api, localTunnelID, zoneID, hostname, target); transferErr == nil {
recordID = transferredID
err = nil
}
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if strings.TrimSpace(recordID) == "" {
if firstErr == nil {
firstErr = fmt.Errorf("Cloudflare 未返回 %s 的 DNS 记录 ID", hostname)
}
continue
}
if err := s.store.UpsertDNSRecord(ctx, localTunnelID, zoneID, recordID, hostname); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) transferTrackedDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, zoneID, hostname, target string) (string, error) {
record, err := s.store.FindDNSRecord(ctx, hostname)
if err != nil {
return "", err
}
if record.TunnelID == localTunnelID {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
func (s *Service) deleteTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64) {
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
s.logger.Warn("读取 Cloudflare DNS 记录失败", "tunnel_id", localTunnelID, "error", err.Error())
return
}
for _, record := range records {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
s.logger.Warn("删除 Cloudflare DNS 记录失败", "hostname", record.Hostname, "error", err.Error())
continue
}
_ = s.store.DeleteDNSRecord(ctx, record.ID)
}
_ = s.store.DeleteDNSRecordsForTunnel(ctx, localTunnelID)
}
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
// accountID / input 为空时回退到应用配置中已保存的值。
func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return "", err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
accountID = strings.TrimSpace(row.AccountID)
}
if accountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
token := strings.TrimSpace(input)
if token == "" {
if row.APITokenEnc == "" {
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
}
token, err = s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return "", fmt.Errorf("API Token 解密失败: %w", err)
}
}
return NewCloudflareAPI(accountID, token).VerifyToken(ctx)
}
// TestToken 验证 Account ID + API Token 是否可用。
func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (string, error) {
return s.VerifyAPIToken(ctx, accountID, input)
}
// Zones 返回当前 API Token 可访问的 Cloudflare Zone。
func (s *Service) Zones(ctx context.Context) ([]Zone, error) {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return nil, err
}
return api.ListZones(ctx)
}
// AccessStatus 只读检测域名是否启用了 Cloudflare Access。
func (s *Service) AccessStatus(ctx context.Context, hostname string) (bool, string, error) {
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return false, "", fmt.Errorf("域名为空")
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return false, "", err
}
return api.HasAccessForHostname(ctx, hostname)
}
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
func (s *Service) attachConfig(item *Tunnel) {
data, err := os.ReadFile(item.ConfigPath)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
host = strings.TrimSpace(host)
if host == "" {
continue
}
item.Hostnames = append(item.Hostnames, host)
if i+1 >= len(lines) {
continue
}
if service, ok := strings.CutPrefix(strings.TrimSpace(lines[i+1]), "service:"); ok && item.OriginService == "" {
item.OriginService = strings.TrimSpace(service)
}
}
}
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
return s.manager.Logs(tunnel.LogPath, lines)
}
func (s *Service) PreviewConfig(ctx context.Context, id int64) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
data, err := os.ReadFile(tunnel.ConfigPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// IngressCandidates 从本机反代规则推导可暴露的域名候选。
func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
if s.rules == nil {
return []string{}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return nil, err
}
hosts := []string{}
for _, rule := range rules {
if !rule.Enabled {
continue
}
for _, group := range rule.PortGroups() {
hosts = append(hosts, group.Hostnames...)
}
}
return hosts, nil
}
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if !tunnel.AutoStart {
continue
}
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
// StartAll 启动全部隧道;用于全局总开关重新开启时立即恢复所有出口。
func (s *Service) StartAll(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
func (s *Service) tunnelDir(name string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
return r
}
return '_'
}, strings.TrimSpace(name))
return filepath.Join(s.cfg.DataDir, "cloudflared", "tunnels", safe)
}
func validateInput(in CreateInput) error {
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("隧道名称不能为空")
}
switch in.Mode {
case ModeAccountLocal, ModeTokenLocal, ModeTokenRemote:
default:
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
}
if !in.Managed && len(in.Hostnames) == 0 {
return fmt.Errorf("手动隧道至少需要一个暴露域名")
}
if in.Managed && in.Mode != ModeAccountLocal {
return fmt.Errorf("仅 API 管理隧道支持自动托管")
}
return nil
}
@@ -0,0 +1,102 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/secret"
)
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-settings-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SaveAppSettings(context.Background(), AppSettingsInput{
AccountID: "acc-1", APIToken: "cfat_test_token", Mirror: "gh-proxy-org", Network: DefaultNetworkSettings(),
})
if err != nil {
t.Fatal(err)
}
if settings.AccountID != "acc-1" || !settings.APITokenConfigured || settings.APITokenMasked == "" {
t.Fatalf("Account ID 与 API Token 应保存并回显掩码:%#v", settings)
}
api, err := svc.cloudflareAPI(context.Background())
if err != nil || api.AccountID != "acc-1" || api.APIToken != "cfat_test_token" {
t.Fatalf("cloudflareAPI 构造失败:%v %#v", err, api)
}
}
func TestSetEnabledBlocksStart(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-enabled-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SetEnabled(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if settings.Enabled {
t.Fatal("全局托管应为停用")
}
if _, err := svc.Start(context.Background(), 1); err == nil || !strings.Contains(err.Error(), "全局已停用") {
t.Fatalf("全局停用时启动应被拒绝:%v", err)
}
settings, err = svc.SetEnabled(context.Background(), true)
if err != nil || !settings.Enabled {
t.Fatalf("重新启用失败:%v %#v", err, settings)
}
}
func TestManagedTunnelPersists(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
store := NewStore(conn)
item, err := store.Create(context.Background(), CreateInput{
Name: "managed",
Mode: ModeAccountLocal,
Network: DefaultNetworkSettings(),
Managed: true,
AutoStart: true,
}, "remote-tunnel", "account", "encrypted", "", "/tmp/config.yml", "/tmp/creds.json", "/tmp/cloudflared.log")
if err != nil {
t.Fatal(err)
}
if !item.Managed || !item.AutoStart || item.TunnelID != "remote-tunnel" {
t.Fatalf("托管隧道字段未持久化:%#v", item)
}
}
+252
View File
@@ -0,0 +1,252 @@
package cloudflared
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
const tunnelSelect = `
SELECT id, name, mode, COALESCE(tunnel_id, ''), COALESCE(account_tag, ''),
COALESCE(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''),
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), COALESCE(managed, 0), created_at, updated_at
FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
rows, err := s.db.QueryContext(ctx, tunnelSelect+` ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Tunnel{}
for rows.Next() {
item, err := scanTunnel(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := scanTunnel(s.db.QueryRowContext(ctx, tunnelSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道不存在")
}
return item, err
}
func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
result, err := s.db.ExecContext(ctx, `
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc,
config_path, creds_path, log_path, network_json, status, auto_start, managed, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed))
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
id, _ := result.LastInsertId()
return s.Get(ctx, id)
}
func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?,
network_json = ?, auto_start = ?, managed = ?, updated_at = datetime('now')
WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id)
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_tunnels WHERE id = ?`, id)
return err
}
func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError string, metricsPort int) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET status = ?, last_error = ?, metrics_port = ?, updated_at = datetime('now')
WHERE id = ?
`, status, lastError, metricsPort, id)
return err
}
func (s *Store) UpdateCredentials(ctx context.Context, id int64, credentialsEnc string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET credentials_enc = ?, updated_at = datetime('now') WHERE id = ?
`, credentialsEnc, id)
return err
}
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
type settingsRow struct {
AccountID string
Enabled bool
TokenEnc string
APITokenEnc string
Mirror string
Network NetworkSettings
}
// DNSRecord 是 Havline 自动创建或接管的 Cloudflare DNS 记录。
type DNSRecord struct {
ID int64 `json:"id"`
TunnelID int64 `json:"tunnel_id"`
ZoneID string `json:"zone_id"`
RecordID string `json:"record_id"`
Hostname string `json:"hostname"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Store) ListDNSRecords(ctx context.Context, tunnelID int64) ([]DNSRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE tunnel_id = ? ORDER BY hostname ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DNSRecord{}
for rows.Next() {
var item DNSRecord
if err := rows.Scan(&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) FindDNSRecord(ctx context.Context, hostname string) (DNSRecord, error) {
var item DNSRecord
err := s.db.QueryRowContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE hostname = ?
`, strings.ToLower(strings.TrimSpace(hostname))).Scan(
&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("Cloudflare DNS 记录不存在")
}
return item, err
}
func (s *Store) UpsertDNSRecord(ctx context.Context, tunnelID int64, zoneID, recordID, hostname string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO cf_dns_records(tunnel_id, zone_id, record_id, hostname, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
tunnel_id = excluded.tunnel_id,
zone_id = excluded.zone_id,
record_id = excluded.record_id,
updated_at = datetime('now')
`, tunnelID, zoneID, recordID, strings.ToLower(strings.TrimSpace(hostname)))
return err
}
func (s *Store) DeleteDNSRecord(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE id = ?`, id)
return err
}
func (s *Store) DeleteDNSRecordsForTunnel(ctx context.Context, tunnelID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE tunnel_id = ?`, tunnelID)
return err
}
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
var enabled int
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(account_id, ''), COALESCE(enabled, 1), COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
FROM cf_settings WHERE id = 1
`).Scan(&accountID, &enabled, &tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
if errors.Is(err, sql.ErrNoRows) {
return settingsRow{Enabled: true, Mirror: "official", Network: DefaultNetworkSettings()}, nil
}
if err != nil {
return settingsRow{}, err
}
row := settingsRow{AccountID: accountID, Enabled: enabled == 1, TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
return row, nil
}
func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
networkJSON, _ := json.Marshal(network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_settings
SET account_id = ?, enabled = ?, default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
WHERE id = 1
`, accountID, boolInt(enabled), tokenEnc, apiTokenEnc, mirror, string(networkJSON))
return err
}
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel
var networkJSON, created, updated string
var autoStart, managed int
err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath,
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &managed, &created, &updated)
if err != nil {
return Tunnel{}, err
}
item.AutoStart = autoStart == 1
item.Managed = managed == 1
item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
return item, nil
}
func parseTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func mapUniqueName(err error) error {
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("Cloudflare 隧道名称已存在")
}
return err
}
+238
View File
@@ -0,0 +1,238 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
ListenAddr string
GatewaySocket string
GatewayPrefix string
DataDir string
SessionSecret string
NginxBin string
NginxPIDFile string
NginxMimeTypes string
NginxDefaultHTTPPort int
NginxDefaultHTTPSPort int
FrpcBin string
FrpPIDFile string
UpdaterSocket string
}
func Load() Config {
dataDir := envOr("HAVLINE_DATA_DIR", defaultDataDir())
if abs, err := filepath.Abs(dataDir); err == nil {
dataDir = abs
}
return Config{
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
GatewaySocket: envOr("HAVLINE_GATEWAY_SOCKET", ""),
GatewayPrefix: envOr("HAVLINE_GATEWAY_PREFIX", ""),
DataDir: dataDir,
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
NginxPIDFile: envOr("HAVLINE_NGINX_PID", dataDir+"/nginx/nginx.pid"),
NginxMimeTypes: envOr("HAVLINE_NGINX_MIME_TYPES", defaultMimeTypes()),
NginxDefaultHTTPPort: envIntOr("HAVLINE_NGINX_HTTP_PORT", 80),
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
}
}
func (c Config) DefaultListenPort(httpsEnabled bool) int {
if httpsEnabled {
return c.NginxDefaultHTTPSPort
}
return c.NginxDefaultHTTPPort
}
func (c Config) DBPath() string {
return c.DataDir + "/havline.db"
}
func (c Config) NginxDir() string {
return c.DataDir + "/nginx"
}
func (c Config) NginxConfigPath() string {
return c.NginxDir() + "/nginx.conf"
}
func (c Config) NginxCustomDir() string {
return c.NginxDir() + "/custom"
}
func (c Config) NginxGlobalCustomPath() string {
return c.NginxCustomDir() + "/global.conf"
}
func (c Config) NginxRulesDir() string {
return c.NginxDir() + "/rules"
}
func (c Config) NginxRuleCustomPath(ruleID int64) string {
return filepath.Join(c.NginxRulesDir(), fmt.Sprintf("rule_%d.conf", ruleID))
}
func (c Config) NginxBackupsDir() string {
return c.NginxDir() + "/backups"
}
func (c Config) NginxGlobalBackupsDir() string {
return c.NginxBackupsDir() + "/global"
}
func (c Config) NginxRuleBackupsDir(ruleID int64) string {
return filepath.Join(c.NginxBackupsDir(), "rules", fmt.Sprintf("%d", ruleID))
}
func (c Config) LogsDir() string {
return c.DataDir + "/logs"
}
func (c Config) CertsDir() string {
return c.DataDir + "/certs"
}
// SSHKnownHostsPath 是 SSH 主机密钥的 TOFU 记录文件(首次连接记录指纹,之后必须一致)。
func (c Config) SSHKnownHostsPath() string {
return filepath.Join(c.DataDir, "ssh_known_hosts")
}
func (c Config) ErrorsDir() string {
return c.NginxDir() + "/errors"
}
func (c Config) ChinaCIDRPath() string {
return c.NginxDir() + "/china_cidr.conf"
}
func (c Config) ChinaCIDRTempPath() string {
return c.NginxDir() + "/china_cidr.conf.tmp"
}
func (c Config) FrpDir() string {
return c.DataDir + "/frp"
}
func (c Config) FrpConfigPath() string {
return c.FrpDir() + "/frpc.toml"
}
func (c Config) FrpLogPath() string {
return filepath.Join(c.LogsDir(), "frpc.log")
}
// LegacySessionSecret 是历史版本在未配置 HAVLINE_SESSION_SECRET 时使用的固定值。
// 它同时是 secret box 的加密密钥,所以只提醒、不自动替换:
// 换掉它会让库里已加密的凭据(frp token、通知密码等)再也解不开。
const LegacySessionSecret = "change-me-in-production"
// sessionSecretPath 会话密钥的持久化位置(与数据库同在数据目录,重建容器/升级都不受影响)
func (c Config) sessionSecretPath() string {
return filepath.Join(c.DataDir, "session_secret")
}
// ResolveSessionSecret 补齐会话密钥并返回它的来源(写进启动日志,便于确认到底用的哪一个):
// - 显式配置了(包括历史默认值)→ 原样使用,与既有数据保持兼容;
// - 没配置 → 读数据目录里的 session_secret;不存在或内容明显无效就生成一个(0600)。
//
// 这样 Docker 与原生部署都不必手工准备密钥,也不会出现「忘配就用固定弱密钥跑在公网」。
func (c *Config) ResolveSessionSecret() (string, error) {
if secret := strings.TrimSpace(c.SessionSecret); secret != "" {
if secret == LegacySessionSecret {
return "环境变量(仍是历史默认值,建议改成随机值,注意更换后需重新登录)", nil
}
return "环境变量", nil
}
path := c.sessionSecretPath()
if data, err := os.ReadFile(path); err == nil {
// 长度不足或带空白的视为损坏(人工改坏、写了一半):重新生成,而不是带着坏密钥启动
if secret := strings.TrimSpace(string(data)); len(secret) >= 32 && !strings.ContainsAny(secret, " \t\r\n") {
c.SessionSecret = secret
return path, nil
}
}
secret, err := randomSessionSecret()
if err != nil {
return "", err
}
if err := os.MkdirAll(c.DataDir, 0o755); err != nil {
return "", err
}
// 先写临时文件再改名:避免中途失败留下半个密钥
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(secret+"\n"), 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return "", err
}
// WriteFile 的权限会被 umask 干扰,这里再显式收一道(密钥文件不该让同机其他用户读到)
if err := os.Chmod(path, 0o600); err != nil {
return "", err
}
c.SessionSecret = secret
return path, nil
}
// randomSessionSecret 32 字节随机数的十六进制表示(64 字符)
func randomSessionSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成会话密钥失败: %w", err)
}
return hex.EncodeToString(buf), nil
}
func defaultDataDir() string {
if _, err := os.Stat("/data"); err == nil {
return "/data"
}
if cwd, err := os.Getwd(); err == nil {
return filepath.Join(cwd, ".data")
}
return "/data"
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func defaultMimeTypes() string {
candidates := []string{
"/etc/nginx/mime.types",
"deploy/nginx/mime.types",
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return "/etc/nginx/mime.types"
}
func envIntOr(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}

Some files were not shown because too many files have changed in this diff Show More