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