feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+208
View File
@@ -0,0 +1,208 @@
package cloudflared
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const minBinarySize = 10 << 20
// Mirror 是 cloudflared 二进制下载镜像。
type Mirror struct {
ID string `json:"id"`
Name string `json:"name"`
Base string `json:"base"`
}
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
func DefaultMirrors() []Mirror {
return []Mirror{
{ID: "official", Name: "GitHub 官方源"},
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
}
}
func findMirror(id string) Mirror {
id = strings.TrimSpace(id)
for _, mirror := range DefaultMirrors() {
if mirror.ID == id {
return mirror
}
}
return DefaultMirrors()[0]
}
func binaryFileName() string {
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
func DownloadURL(mirror Mirror, version string) string {
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
if strings.TrimSpace(version) != "" {
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
}
if strings.TrimSpace(mirror.Base) == "" {
return official
}
return mirror.Base + official
}
func (s *Service) binaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
}
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
func (s *Service) BinaryVersion() string {
path := s.binaryPath()
if _, err := os.Stat(path); err != nil {
return ""
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
return s.DownloadBinaryVersion(ctx, mirrorID, "")
}
// DownloadBinaryVersion 下载指定版本;version 为空表示最新版。
func (s *Service) DownloadBinaryVersion(ctx context.Context, mirrorID, version string) (string, error) {
mirror := findMirror(mirrorID)
target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
tmp := target + ".download"
_ = os.Remove(tmp)
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
_ = os.Remove(tmp)
return "", err
}
info, err := os.Stat(tmp)
if err != nil || info.Size() < minBinarySize {
_ = os.Remove(tmp)
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
}
if runtime.GOOS != "windows" {
_ = os.Chmod(tmp, 0o755)
}
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
}
previous := target + ".previous"
if _, err := os.Stat(target); err == nil {
_ = os.Remove(previous)
if err := os.Rename(target, previous); err != nil {
_ = os.Remove(tmp)
return "", err
}
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Rename(previous, target)
_ = os.Remove(tmp)
return "", err
}
return s.BinaryVersion(), nil
}
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", err
}
version := strings.TrimSpace(payload.TagName)
if version == "" {
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
}
return version, nil
}
// RollbackBinary 恢复上一版 cloudflared 二进制。
func (s *Service) RollbackBinary() (string, error) {
target := s.binaryPath()
previous := target + ".previous"
if _, err := os.Stat(previous); err != nil {
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
}
failed := target + ".failed"
_ = os.Remove(failed)
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, failed); err != nil {
return "", err
}
}
if err := os.Rename(previous, target); err != nil {
_ = os.Rename(failed, target)
return "", err
}
_ = os.Remove(failed)
return s.BinaryVersion(), nil
}
func fetchFile(ctx context.Context, url, path string) error {
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载 cloudflared 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("写入 cloudflared 失败: %w", err)
}
return nil
}
@@ -0,0 +1,23 @@
package cloudflared
import (
"strings"
"testing"
)
func TestDownloadURL(t *testing.T) {
official := DownloadURL(Mirror{ID: "official"}, "")
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
t.Fatalf("官方下载地址不正确:%s", official)
}
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
}
}
func TestFindMirrorFallback(t *testing.T) {
if got := findMirror("not-exist"); got.ID != "official" {
t.Fatalf("未知镜像应回退官方源:%#v", got)
}
}
@@ -0,0 +1,255 @@
package cloudflared
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const cloudflareAPIBase = "https://api.cloudflare.com/client/v4"
// CloudflareAPI 用 Account ID + API Token 调用 Cloudflare API。
type CloudflareAPI struct {
AccountID string
APIToken string
client *http.Client
}
func NewCloudflareAPI(accountID, apiToken string) *CloudflareAPI {
return &CloudflareAPI{
AccountID: strings.TrimSpace(accountID),
APIToken: strings.TrimSpace(apiToken),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type cfAPIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfEnvelope struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result"`
Errors []cfAPIError `json:"errors"`
}
func (a *CloudflareAPI) do(ctx context.Context, method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, cloudflareAPIBase+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.APIToken)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("请求 Cloudflare API 失败: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
var envelope cfEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
return fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
}
if !envelope.Success {
message := "Cloudflare API 调用失败"
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
message = envelope.Errors[0].Message
}
return fmt.Errorf("%s(HTTP %d)", message, resp.StatusCode)
}
if out != nil && len(envelope.Result) > 0 {
if err := json.Unmarshal(envelope.Result, out); err != nil {
return fmt.Errorf("Cloudflare API 结果解析失败: %w", err)
}
}
return nil
}
// VerifyToken 调用账号级 tokens/verify 接口验证 API Token。
func (a *CloudflareAPI) VerifyToken(ctx context.Context) (string, error) {
if a.AccountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
if a.APIToken == "" {
return "", fmt.Errorf("请先填写 API Token(cfat_...)")
}
var result struct {
Status string `json:"status"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/tokens/verify", nil, &result); err != nil {
return "", err
}
if result.Status != "active" {
return "", fmt.Errorf("API Token 状态为 %s,不是 active", result.Status)
}
return "API Token 有效(状态:active)", nil
}
// CreateTunnel 创建一条 config_src=local 的命名隧道,并返回隧道 ID 与凭据 token。
func (a *CloudflareAPI) CreateTunnel(ctx context.Context, name string) (tunnelID, token string, err error) {
var result struct {
ID string `json:"id"`
Token string `json:"token"`
}
payload := map[string]any{"name": name, "config_src": "local"}
if err := a.do(ctx, http.MethodPost, "/accounts/"+a.AccountID+"/cfd_tunnel", payload, &result); err != nil {
return "", "", err
}
if result.ID == "" {
return "", "", fmt.Errorf("Cloudflare 未返回隧道 ID")
}
if strings.TrimSpace(result.Token) != "" {
return result.ID, result.Token, nil
}
token, err = a.TunnelToken(ctx, result.ID)
return result.ID, token, err
}
// TunnelToken 读取指定隧道的凭据 token。
func (a *CloudflareAPI) TunnelToken(ctx context.Context, tunnelID string) (string, error) {
var token string
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/token", nil, &token); err != nil {
return "", err
}
if strings.TrimSpace(token) == "" {
return "", fmt.Errorf("Cloudflare 未返回隧道凭据")
}
return token, nil
}
// PutIngress 把本地 ingress 推送到 Cloudflare 云端配置。
func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress []IngressRule) error {
return a.do(ctx, http.MethodPut, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", map[string]any{
"config": map[string]any{"ingress": ingress},
}, nil)
}
// GetIngress 读取云端隧道的 ingress 配置。
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
var result struct {
Config struct {
Ingress []IngressRule `json:"ingress"`
} `json:"config"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
return nil, err
}
return result.Config.Ingress, nil
}
// DeleteTunnel 删除云端隧道。
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error {
return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil)
}
// Zone 是 Cloudflare 的域名区域。
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// ListZones 返回当前 API Token 可访问的 Zone。
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
return nil, err
}
return zones, nil
}
// FindZone 从 hostname 逐级向上查找所属 Zone。
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
labels := strings.Split(strings.Trim(hostname, "."), ".")
for i := 0; i < len(labels)-1; i++ {
candidate := strings.Join(labels[i:], ".")
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(candidate), nil, &zones); err != nil {
return "", "", err
}
if len(zones) > 0 {
return zones[0].ID, zones[0].Name, nil
}
}
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
}
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
var records []struct {
ID string `json:"id"`
Content string `json:"content"`
}
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
return "", err
}
payload := map[string]any{
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
}
if len(records) > 0 {
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
}
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
return "", err
}
return records[0].ID, nil
}
var created struct {
ID string `json:"id"`
}
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
return "", err
}
if strings.TrimSpace(created.ID) == "" {
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
}
return created.ID, nil
}
// DeleteDNSRecord 删除一条明确由 Havline 接管的 DNS 记录。
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
}
type accessApp struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
}
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
var apps []accessApp
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
return false, "", err
}
wanted := strings.ToLower(strings.TrimSpace(hostname))
for _, app := range apps {
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
return true, app.Name, nil
}
}
return false, "", nil
}
+198
View File
@@ -0,0 +1,198 @@
package cloudflared
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/havline/havline/internal/proxy"
)
type tokenPayload struct {
AccountTag string `json:"a"`
TunnelID string `json:"t"`
TunnelSecret string `json:"s"`
}
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
if clean == "" {
return "", "", "", fmt.Errorf("token 为空")
}
raw, err := decodeBase64(clean)
if err != nil {
return "", "", "", err
}
var payload tokenPayload
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
var alt struct {
AccountTag string `json:"AccountTag"`
TunnelID string `json:"TunnelID"`
TunnelSecret string `json:"TunnelSecret"`
}
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
}
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
}
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
}
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
}
// decodeBase64 同时兼容标准 base64 与 URL-safe base64,并兼容有无填充两种形式。
// Cloudflare 面板生成的 Tunnel Token 使用 URL-safe 编码,直接用标准 base64 会在 '-' / '_' 处失败。
func decodeBase64(input string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(input); err == nil {
return data, nil
}
}
padded := input
if remainder := len(padded) % 4; remainder != 0 {
padded += strings.Repeat("=", 4-remainder)
}
for _, encoding := range encodings {
if data, err := encoding.DecodeString(padded); err == nil {
return data, nil
}
}
return nil, fmt.Errorf("token base64 解码失败:请确认粘贴的是完整的 Cloudflare 隧道 token")
}
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
data, _ := json.MarshalIndent(map[string]string{
"AccountTag": accountTag,
"TunnelID": tunnelID,
"TunnelSecret": tunnelSecret,
}, "", " ")
return string(data)
}
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
var b strings.Builder
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
writeNetworkSection(&b, network)
b.WriteString("ingress:\n")
for _, rule := range ingress {
if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
if path := strings.TrimSpace(rule.Path); path != "" && path != "*" {
fmt.Fprintf(&b, " path: %s\n", path)
}
fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue
}
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
}
return b.String()
}
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
protocol := strings.TrimSpace(network.TransportProtocol)
edge := strings.TrimSpace(network.EdgeIPVersion)
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
if protocol != "" && protocol != "auto" {
fmt.Fprintf(b, "protocol: %s\n", protocol)
}
if edge != "" && edge != "auto" {
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
}
if network.HAConnections > 0 {
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
}
if hasOrigin {
b.WriteString("originRequest:\n")
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
fmt.Fprintf(b, " caPool: %s\n", pool)
}
if network.NoTLSVerify {
b.WriteString(" noTLSVerify: true\n")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
}
}
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
b.WriteString("\n")
}
}
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
func BuildIngress(hostnames []string, service string) []IngressRule {
seen := map[string]bool{}
cleaned := make([]string, 0, len(hostnames))
for _, host := range hostnames {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" || seen[host] {
continue
}
seen[host] = true
cleaned = append(cleaned, host)
}
sort.Strings(cleaned)
out := make([]IngressRule, 0, len(cleaned)+1)
for _, host := range cleaned {
out = append(out, IngressRule{Hostname: host, Service: service})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// BuildIngressForRules 从反向代理规则派生 ingress,每条域名使用自己的回源地址。
func BuildIngressForRules(rules []proxy.Rule) []IngressRule {
serviceByHost := map[string]string{}
for _, rule := range rules {
if !rule.CloudflareEnabled() {
continue
}
service := strings.TrimSpace(rule.Upstream)
if service == "" {
continue
}
for _, host := range rule.Hostnames() {
serviceByHost[host] = service
}
}
hosts := make([]string, 0, len(serviceByHost))
for host := range serviceByHost {
hosts = append(hosts, host)
}
sort.Strings(hosts)
out := make([]IngressRule, 0, len(hosts)+1)
for _, host := range hosts {
out = append(out, IngressRule{Hostname: host, Service: serviceByHost[host]})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
func NormalizeService(protocol, host string, port int) string {
host = strings.TrimSpace(host)
if host == "localhost" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
}
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
func MaskSecret(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) <= 8 {
return "********"
}
return value[:4] + "********" + value[len(value)-4:]
}
+138
View File
@@ -0,0 +1,138 @@
package cloudflared
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"github.com/havline/havline/internal/proxy"
)
func TestDecodeToken(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
account, tunnel, secret, err := DecodeToken(encoded)
if err != nil {
t.Fatal(err)
}
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
}
}
func TestDecodeTokenAlternativeFields(t *testing.T) {
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("兼容字段名解析失败: %v", err)
}
}
func TestDecodeTokenRejectsInvalid(t *testing.T) {
if _, _, _, err := DecodeToken("not-base64"); err == nil {
t.Fatal("非法 token 应报错")
}
}
func TestDecodeTokenURLSafe(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("URL-safe token 应可解码: %v", err)
}
}
func TestDecodeBase64Flexible(t *testing.T) {
raw := []byte{0xfb, 0xef, 0xbe, 0xad}
for _, encoded := range []string{
base64.RawURLEncoding.EncodeToString(raw),
base64.URLEncoding.EncodeToString(raw),
base64.RawStdEncoding.EncodeToString(raw),
} {
got, err := decodeBase64(encoded)
if err != nil || !bytes.Equal(got, raw) {
t.Fatalf("编码 %q 解码失败:%v", encoded, err)
}
}
}
func TestBuildIngressInvariants(t *testing.T) {
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
t.Fatalf("域名应去重并稳定排序:%#v", got)
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesUsesRuleUpstream(t *testing.T) {
rules := []proxy.Rule{
{
Enabled: true,
Upstream: "http://192.168.1.10:8080",
Hosts: []proxy.Host{{Hostname: "b.example.com"}},
Exits: []string{proxy.ExitCloudflare},
CFTunnelID: 9,
},
{
Enabled: true,
Upstream: "http://192.168.1.11:9090",
Hosts: []proxy.Host{{Hostname: "a.example.com"}},
Exits: []string{proxy.ExitLocal, proxy.ExitCloudflare},
CFTunnelID: 9,
},
}
got := BuildIngressForRules(rules)
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[0].Service != "http://192.168.1.11:9090" {
t.Fatalf("第一条 ingress 不符:%#v", got[0])
}
if got[1].Hostname != "b.example.com" || got[1].Service != "http://192.168.1.10:8080" {
t.Fatalf("第二条 ingress 不符:%#v", got[1])
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesSkipsDisabledAndManualRules(t *testing.T) {
rules := []proxy.Rule{
{Enabled: false, Upstream: "http://127.0.0.1:8080", Hosts: []proxy.Host{{Hostname: "off.example.com"}}, Exits: []string{proxy.ExitCloudflare}, CFTunnelID: 1},
{Enabled: true, Upstream: "http://127.0.0.1:8081", Hosts: []proxy.Host{{Hostname: "local.example.com"}}, Exits: []string{proxy.ExitLocal}},
}
got := BuildIngressForRules(rules)
if len(got) != 1 || got[0].Service != "http_status:404" {
t.Fatalf("停用规则和仅本机规则不应进入 Cloudflare ingress:%#v", got)
}
}
func TestBuildConfig(t *testing.T) {
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
for _, want := range []string{
"tunnel: tunnel-1",
"credentials-file: /data/creds.json",
"protocol: http2",
`edge-ip-version: "4"`,
"ha-connections: 2",
"- hostname: a.example.com",
"service: http://127.0.0.1:80",
"- service: http_status:404",
} {
if !strings.Contains(config, want) {
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
}
}
}
func TestNormalizeService(t *testing.T) {
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
t.Fatalf("NormalizeService() = %q", got)
}
}
+377
View File
@@ -0,0 +1,377 @@
package cloudflared
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
HealthRetention = 30 * 24 * time.Hour
)
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
type HealthStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// HealthSample 交给落库层的状态样本。
type HealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// HealthEvent 一条状态变更记录。
type HealthEvent struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// HealthIncident 一段 Cloudflare 隧道不可用区间。
type HealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
type UptimeStat struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []HealthIncident `json:"incidents"`
}
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
type TunnelSummary struct {
Count int
Online int
Status string
Message string
LastError string
}
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return nil, err
}
out := []HealthStatus{}
for i := range tunnels {
tunnel := tunnels[i]
s.attachConfig(&tunnel)
status := s.manager.Status(tunnel.ID)
for _, domain := range tunnel.Hostnames {
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
}
}
return out, nil
}
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
if !globalEnabled {
item.Reason = "disabled"
item.Detail = "Cloudflare 隧道全局托管已停用"
return item
}
if status.Running {
item.Known = true
if status.Status == "connected" {
item.State = HealthUp
return item
}
item.State = HealthDown
item.Reason = "connection"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
if tunnel.AutoStart {
item.Known = true
item.State = HealthDown
item.Reason = "process"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
item.Reason = "stopped"
item.Detail = "隧道未运行,且未启用自动启动"
return item
}
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
if len(samples) == 0 {
return nil
}
current, err := s.latestHealthStates(ctx)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
continue
}
key := healthKey(sample.TunnelID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx, `
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
VALUES (?, ?, ?, ?, ?, ?)
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return err
}
}
return nil
}
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.store.db.QueryContext(ctx, `
SELECT e.tunnel_id, e.domain, e.state
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var tunnelID int64
var domain, state string
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
return nil, err
}
states[healthKey(tunnelID, domain)] = state
}
return states, rows.Err()
}
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.healthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeHealthUptime(events, since, now), nil
}
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.store.db.QueryContext(ctx, `
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM cf_health_events WHERE at >= ?
UNION ALL
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events WHERE at < ?
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := []HealthEvent{}
for rows.Next() {
var event HealthEvent
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// PruneHealth 清理超过保留期的状态变更。
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Summary 返回隧道数量、在线数与最近错误。
func (s *Service) Summary(ctx context.Context) TunnelSummary {
tunnels, err := s.store.List(ctx)
if err != nil {
return TunnelSummary{Status: "error", Message: err.Error()}
}
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
if len(tunnels) == 0 {
summary.Status = "none"
summary.Message = "未创建隧道"
return summary
}
for _, tunnel := range tunnels {
status := s.manager.Status(tunnel.ID)
if status.Running && status.Status == "connected" {
summary.Online++
}
if strings.TrimSpace(tunnel.LastError) != "" {
summary.LastError = tunnel.LastError
}
}
if summary.Online == 0 {
summary.Status = "error"
summary.Message = "没有在线隧道"
} else if summary.Online < summary.Count {
summary.Status = "warning"
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
} else {
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
}
return summary
}
func healthKey(tunnelID int64, domain string) string {
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
}
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
type healthPoint struct {
at time.Time
tunnelID int64
domain string
state string
reason, detail string
}
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
byRule := map[string][]healthPoint{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := healthKey(event.TunnelID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], healthPoint{
at: at, tunnelID: event.TunnelID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]UptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
stat := computeHealthPoints(points, from, to)
if stat.Domain != "" {
stats = append(stats, stat)
}
}
return stats
}
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
start := from
index := 0
state, reason, detail := "", "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
return UptimeStat{}
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
upMinutes := 0
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case HealthUp:
upMinutes += minutes
case HealthDown:
stat.DownMinutes += minutes
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
stat.State = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
return stat
}
@@ -0,0 +1,57 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
)
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
to := from.Add(2 * time.Hour)
events := []HealthEvent{
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
}
stats := ComputeHealthUptime(events, from, to)
if len(stats) != 1 {
t.Fatalf("应返回一条统计:%#v", stats)
}
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
t.Fatalf("可用率计算不符:%#v", stats[0])
}
}
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
var count int
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return nil
}
@@ -0,0 +1,9 @@
//go:build windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{HideWindow: true}
}
+375
View File
@@ -0,0 +1,375 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/config"
)
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
type processState struct {
cmd *exec.Cmd
status string
lastError string
metricsPort int
metrics RuntimeStatus
logMu sync.Mutex
}
// Manager 托管 cloudflared 子进程、日志与 metrics。
type Manager struct {
cfg config.Config
logger *slog.Logger
mu sync.Mutex
processes map[int64]*processState
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
}
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote {
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run", "--token", token)
}
args = append(args, "--config", configPath)
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run")
}
func networkArgs(network NetworkSettings) []string {
args := []string{}
if network.HAConnections > 0 {
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
}
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
args = append(args, "--origin-ca-pool", pool)
}
if network.NoTLSVerify {
args = append(args, "--no-tls-verify")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
args = append(args, "--http-host-header", header)
}
return args
}
func buildEnv(network NetworkSettings) []string {
env := os.Environ()
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
}
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
}
if network.ProxyMode == "disabled" {
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
}
return env
}
func removeEnv(env []string, names []string) []string {
out := make([]string, 0, len(env))
for _, item := range env {
remove := false
for _, name := range names {
if strings.HasPrefix(item, name+"=") {
remove = true
break
}
}
if !remove {
out = append(out, item)
}
}
return out
}
// Start 启动一条隧道的 cloudflared 子进程。
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
m.mu.Lock()
if _, exists := m.processes[tunnel.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("隧道已在运行中")
}
state := &processState{status: "starting"}
m.processes[tunnel.ID] = state
m.mu.Unlock()
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
m.remove(tunnel.ID)
return err
}
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return fmt.Errorf("启动 cloudflared 失败: %w", err)
}
state.cmd = cmd
go m.pipeLogs(tunnel.ID, stdout, logFile)
go m.pipeLogs(tunnel.ID, stderr, logFile)
go m.wait(tunnel.ID, cmd, logFile)
return nil
}
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
m.writeLog(id, logFile, line)
m.detectMetrics(id, line)
}
}
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return
}
state.logMu.Lock()
defer state.logMu.Unlock()
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
}
func (m *Manager) detectMetrics(id int64, line string) {
match := metricsPortPattern.FindStringSubmatch(line)
if len(match) != 2 {
return
}
port, err := strconv.Atoi(match[1])
if err != nil || port <= 0 {
return
}
m.mu.Lock()
state := m.processes[id]
if state == nil || state.metricsPort == port {
m.mu.Unlock()
return
}
state.metricsPort = port
m.mu.Unlock()
go m.scrapeMetrics(id, port)
}
func (m *Manager) scrapeMetrics(id int64, port int) {
client := &http.Client{Timeout: 5 * time.Second}
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
lastIn, lastOut := 0.0, 0.0
for {
time.Sleep(2 * time.Second)
if !m.running(id) {
return
}
text, err := fetchText(client, metricsURL)
if err != nil {
continue
}
snapshot := parseMetrics(text)
status := RuntimeStatus{
Running: true, Status: "connecting", MetricsPort: port,
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
Transport: snapshot.transport,
}
if snapshot.hasQUIC {
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
} else {
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
}
if snapshot.connections > 0 {
status.Status = "connected"
} else if isReady(client, readyURL) {
status.Status = "reconnecting"
}
m.mu.Lock()
if state := m.processes[id]; state != nil {
state.metrics = status
state.status = status.Status
}
m.mu.Unlock()
}
}
func (m *Manager) running(id int64) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.processes[id] != nil
}
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
_ = logFile.Close()
m.mu.Lock()
state := m.processes[id]
if state != nil {
if err != nil {
state.status = "failed"
state.lastError = err.Error()
} else {
state.status = "stopped"
}
}
m.mu.Unlock()
}
func (m *Manager) remove(id int64) {
m.mu.Lock()
delete(m.processes, id)
m.mu.Unlock()
}
// Stop 停止隧道子进程。
func (m *Manager) Stop(id int64) error {
m.mu.Lock()
state := m.processes[id]
delete(m.processes, id)
m.mu.Unlock()
if state == nil || state.cmd == nil || state.cmd.Process == nil {
return nil
}
_ = state.cmd.Process.Kill()
return nil
}
// Status 返回运行状态与最近一次 metrics 快照。
func (m *Manager) Status(id int64) RuntimeStatus {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return RuntimeStatus{Status: "stopped"}
}
out := state.metrics
out.Status = state.status
out.LastError = state.lastError
out.MetricsPort = state.metricsPort
out.Running = state.status != "stopped" && state.status != "failed"
return out
}
// Logs 读取日志文件末尾若干行。
func (m *Manager) Logs(logPath string, lines int) (string, error) {
if lines <= 0 {
lines = 200
}
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
return strings.Join(all, "\n"), nil
}
type metricsSnapshot struct {
connections int
latencyMS float64
bytesIn float64
bytesOut float64
transport string
hasQUIC bool
}
func parseMetrics(text string) metricsSnapshot {
snapshot := metricsSnapshot{transport: "http2"}
var rttSum, rttCount float64
for _, line := range strings.Split(text, "\n") {
switch {
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
snapshot.connections = int(parseMetricValue(line))
case strings.HasPrefix(line, "quic_client_receive_bytes"):
snapshot.bytesIn += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_sent_bytes"):
snapshot.bytesOut += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
rttSum += parseMetricValue(line)
rttCount++
snapshot.hasQUIC = true
}
}
if snapshot.hasQUIC {
snapshot.transport = "quic"
if rttCount > 0 {
snapshot.latencyMS = rttSum / rttCount
}
}
return snapshot
}
func parseMetricValue(line string) float64 {
fields := strings.Fields(line)
if len(fields) == 0 {
return 0
}
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
return value
}
func fetchText(client *http.Client, url string) (string, error) {
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
return string(data), nil
}
func isReady(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
@@ -0,0 +1,59 @@
package cloudflared
import (
"strings"
"testing"
)
func TestBuildRunArgs(t *testing.T) {
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
if !strings.Contains(local, want) {
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
}
}
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
}
}
func TestParseMetrics(t *testing.T) {
text := strings.Join([]string{
"cloudflared_tunnel_ha_connections 2",
"quic_client_receive_bytes 1000",
"quic_client_sent_bytes 500",
"quic_client_smoothed_rtt 0.02",
}, "\n")
snapshot := parseMetrics(text)
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
}
if snapshot.latencyMS <= 0 {
t.Fatalf("RTT 解析失败:%#v", snapshot)
}
}
func TestParseMetricsHTTP2(t *testing.T) {
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
}
}
func TestMetricsPortPatternCaseInsensitive(t *testing.T) {
match := metricsPortPattern.FindStringSubmatch("2026-09-30T18:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics")
if len(match) != 2 || match[1] != "20241" {
t.Fatalf("应识别大写 Starting 的 metrics 端口:%#v", match)
}
}
func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
for _, item := range env {
if strings.HasPrefix(item, "HTTP_PROXY=") {
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package cloudflared
import "time"
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
const (
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Havline 生成
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
)
// NetworkSettings 是 cloudflared 的网络调优参数。
type NetworkSettings struct {
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
HAConnections int `json:"ha_connections"`
ProxyMode string `json:"proxy_mode"` // system / disabled
OriginCAPool string `json:"origin_ca_pool"`
NoTLSVerify bool `json:"no_tls_verify"`
HTTPHostHeader string `json:"http_host_header"`
}
// DefaultNetworkSettings 返回国内环境更稳的默认值。
func DefaultNetworkSettings() NetworkSettings {
return NetworkSettings{
TransportProtocol: "http2",
EdgeIPVersion: "4",
HAConnections: 2,
ProxyMode: "system",
}
}
// IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Service string `json:"service" yaml:"service"`
}
// Tunnel 是一条 Cloudflare 隧道。
type Tunnel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
TunnelID string `json:"tunnel_id"`
AccountTag string `json:"account_tag"`
CredentialsEnc string `json:"-"`
CertPEMEnc string `json:"-"`
ConfigPath string `json:"config_path"`
CredsPath string `json:"creds_path"`
LogPath string `json:"log_path"`
Network NetworkSettings `json:"network"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
DNSWarning string `json:"dns_warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateInput 是创建 / 更新隧道时的入参。
type CreateInput struct {
Name string `json:"name"`
Mode string `json:"mode"`
Token string `json:"token"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
}
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
type AppSettings struct {
AccountID string `json:"account_id"`
Enabled bool `json:"enabled"`
DefaultTokenConfigured bool `json:"default_token_configured"`
DefaultTokenMasked string `json:"default_token_masked"`
APITokenConfigured bool `json:"api_token_configured"`
APITokenMasked string `json:"api_token_masked"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
type AppSettingsInput struct {
AccountID string `json:"account_id"`
Enabled *bool `json:"enabled,omitempty"`
Token string `json:"token"`
APIToken string `json:"api_token"`
Mirror string `json:"mirror"`
Network NetworkSettings `json:"network"`
}
// RuntimeStatus 是隧道运行状态与指标快照。
type RuntimeStatus struct {
Running bool `json:"running"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
Connections int `json:"connections"`
LatencyMS float64 `json:"latency_ms"`
BandwidthIn float64 `json:"bandwidth_in"`
BandwidthOut float64 `json:"bandwidth_out"`
Transport string `json:"transport"`
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
}
+323
View File
@@ -0,0 +1,323 @@
package cloudflared
import (
"context"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
)
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
type DriftResult struct {
TunnelID int64 `json:"tunnel_id"`
Managed bool `json:"managed"`
HasDrift bool `json:"has_drift"`
Items []string `json:"items"`
}
// DiagnosticCheck 一项诊断结果。
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
type DiagnosticsResult struct {
TunnelID int64 `json:"tunnel_id"`
GeneratedAt string `json:"generated_at"`
Checks []DiagnosticCheck `json:"checks"`
}
// PreflightCheck 是接入向导的一项预检结果。
type PreflightCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
type PreflightResult struct {
Ready bool `json:"ready"`
Checks []PreflightCheck `json:"checks"`
}
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
func (s *Service) Preflight(ctx context.Context) PreflightResult {
binary := s.binaryCheck()
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
}
checks = append(checks, PreflightCheck{
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
})
checks = append(checks, PreflightCheck{
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
})
if row.APITokenEnc == "" {
return PreflightResult{Ready: false, Checks: checks}
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
}
zones, err := api.ListZones(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
}
checks = append(checks, PreflightCheck{
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
})
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
}
func statusBool(ok bool) string {
if ok {
return "ok"
}
return "error"
}
func boolText(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func hasPreflightError(checks []PreflightCheck) bool {
for _, check := range checks {
if check.Status == "error" {
return true
}
}
return false
}
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DriftResult{}, err
}
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
if !tunnel.Managed {
return result, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return DriftResult{}, err
}
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DriftResult{}, err
}
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
if err != nil {
return DriftResult{}, err
}
result.Items = compareIngress(expected, actual)
result.HasDrift = len(result.Items) > 0
return result, nil
}
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if !tunnel.Managed {
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
}
return s.syncManagedTunnel(ctx, tunnel)
}
func compareIngress(expected, actual []IngressRule) []string {
expectedMap := ingressMap(expected)
actualMap := ingressMap(actual)
items := []string{}
keys := unionKeys(expectedMap, actualMap)
for _, key := range keys {
want, wantOK := expectedMap[key]
got, gotOK := actualMap[key]
switch {
case wantOK && !gotOK:
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
case !wantOK && gotOK:
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
case want != got:
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
}
}
return items
}
func ingressMap(ingress []IngressRule) map[string]string {
out := map[string]string{}
for _, item := range ingress {
key := strings.ToLower(strings.TrimSpace(item.Hostname))
if key == "" {
key = "(catch-all)"
}
out[key] = strings.TrimSpace(item.Service)
}
return out
}
func unionKeys(a, b map[string]string) []string {
seen := map[string]struct{}{}
for key := range a {
seen[key] = struct{}{}
}
for key := range b {
seen[key] = struct{}{}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DiagnosticsResult{}, err
}
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
result.Checks = append(result.Checks, s.binaryCheck())
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
api := s.apiDiagnostic(ctx, tunnel)
result.Checks = append(result.Checks, api)
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
return result, nil
}
func (s *Service) binaryCheck() DiagnosticCheck {
version := s.BinaryVersion()
if strings.TrimSpace(version) == "" {
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
}
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
}
func configCheck(path string) DiagnosticCheck {
if _, err := os.Stat(path); err != nil {
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
}
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
}
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
if status.Running && status.Status == "connected" {
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
}
if tunnel.AutoStart {
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
}
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
}
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
if tunnel.TunnelID == "" {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
}
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
}
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil {
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
}
if len(records) == 0 {
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Havline 接管的 DNS 记录"}
}
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
}
func networkCheck(name, address string) DiagnosticCheck {
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
}
_ = conn.Close()
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
}
// RouteTestResult 是单条路由源服务的连通性测试结果。
type RouteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
LatencyMS float64 `json:"latency_ms"`
}
// TestService 测试一条路由 service 的 TCP 连通性。
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
address, err := serviceAddress(service)
if err != nil {
return RouteTestResult{}, err
}
start := time.Now()
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return RouteTestResult{OK: false, Message: err.Error()}, nil
}
_ = conn.Close()
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
}
func serviceAddress(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, ":") {
return "", fmt.Errorf("service 格式无效:%s", raw)
}
return raw, nil
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
switch parsed.Scheme {
case "http":
port = "80"
case "https":
port = "443"
case "ssh":
port = "22"
default:
return "", fmt.Errorf("service 缺少端口:%s", raw)
}
}
return net.JoinHostPort(host, port), nil
}
@@ -0,0 +1,28 @@
package cloudflared
import "testing"
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
{Service: "http_status:404"},
}
items := compareIngress(expected, actual)
if len(items) != 3 {
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
}
}
func TestCompareIngressIgnoresOrder(t *testing.T) {
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Service: "http_status:404"},
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
}
if items := compareIngress(expected, actual); len(items) != 0 {
t.Fatalf("顺序不同不应算漂移:%#v", items)
}
}
+185
View File
@@ -0,0 +1,185 @@
package cloudflared
import (
"context"
"fmt"
"os"
"strings"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/validate"
)
// RouteView 是配置页展示的一条 ingress 路由。
type RouteView struct {
ID int64 `json:"id,omitempty"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
Source string `json:"source"`
}
// TunnelRoutes 是配置页需要的路由列表。
type TunnelRoutes struct {
Managed bool `json:"managed"`
Routes []RouteView `json:"routes"`
CatchAll string `json:"catch_all"`
}
// RouteInput 是手工隧道路由保存入参。
type RouteInput struct {
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
}
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return s.managedRoutes(ctx, tunnel)
}
return manualRoutes(tunnel.ConfigPath)
}
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
if s.rules == nil {
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return TunnelRoutes{}, err
}
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
for _, host := range rule.Hostnames() {
out.Routes = append(out.Routes, RouteView{
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
})
}
}
return out, nil
}
func manualRoutes(configPath string) (TunnelRoutes, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
return TunnelRoutes{}, err
}
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
hostname := strings.TrimSpace(host)
if hostname == "" || i+1 >= len(lines) {
continue
}
path := "*"
next := i + 1
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
path = strings.TrimSpace(value)
next++
}
if next >= len(lines) {
continue
}
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
if !ok {
continue
}
out.Routes = append(out.Routes, RouteView{
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
})
}
if len(out.Routes) > 0 {
catchAll, err := catchAllService(data)
if err == nil && catchAll != "" {
out.CatchAll = catchAll
}
}
return out, nil
}
func catchAllService(data []byte) (string, error) {
lines := strings.Split(string(data), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- service:") {
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
}
}
return "", fmt.Errorf("未找到 catch-all")
}
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
}
ingress, hostnames, err := buildManualIngress(routes)
if err != nil {
return TunnelRoutes{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
return TunnelRoutes{}, err
}
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return TunnelRoutes{}, err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return TunnelRoutes{}, err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
return TunnelRoutes{}, err
}
}
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
return TunnelRoutes{}, err
}
return s.Routes(ctx, id)
}
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
seen := map[string]struct{}{}
ingress := make([]IngressRule, 0, len(routes)+1)
hostnames := make([]string, 0, len(routes))
for _, route := range routes {
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
if err := validate.Domain(hostname); err != nil {
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
}
if _, ok := seen[hostname]; ok {
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
}
seen[hostname] = struct{}{}
service := strings.TrimSpace(route.Service)
if service == "" || !strings.Contains(service, "://") {
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
}
path := strings.TrimSpace(route.Path)
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
if path == "" || path == "*" {
path = ""
}
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
hostnames = append(hostnames, hostname)
}
ingress = append(ingress, IngressRule{Service: "http_status:404"})
return ingress, hostnames, nil
}
@@ -0,0 +1,51 @@
package cloudflared
import (
"os"
"path/filepath"
"testing"
)
func TestManualRoutesFromConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
content := `tunnel: test
ingress:
- hostname: a.example.com
service: http://127.0.0.1:8080
- hostname: b.example.com
service: http://127.0.0.1:9090
- service: http_status:404
`
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
result, err := manualRoutes(path)
if err != nil {
t.Fatal(err)
}
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
t.Fatalf("手工路由解析不符:%#v", result)
}
if result.CatchAll != "http_status:404" {
t.Fatalf("catch-all 不符:%q", result.CatchAll)
}
}
func TestBuildManualIngress(t *testing.T) {
ingress, hostnames, err := buildManualIngress([]RouteInput{
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
})
if err != nil {
t.Fatal(err)
}
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
t.Fatalf("ingress 结果不符:%#v", ingress)
}
if ingress[0].Path != "" || ingress[1].Path != "" {
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
}
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
t.Fatalf("hostnames 结果不符:%#v", hostnames)
}
}
+828
View File
@@ -0,0 +1,828 @@
package cloudflared
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/secret"
)
const defaultOriginService = "http://127.0.0.1:80"
// RuleSource 提供本机反代规则,用于推导 ingress 候选域名。
type RuleSource interface {
List(ctx context.Context) ([]proxy.Rule, error)
}
// Service 是 Cloudflare 隧道模块的门面。
type Service struct {
cfg config.Config
store *Store
secretBox *secret.Box
logger *slog.Logger
manager *Manager
rules RuleSource
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, rules RuleSource) *Service {
moduleLogger := logger.With("module", "CLOUDFLARED")
return &Service{
cfg: cfg,
store: store,
secretBox: secretBox,
logger: moduleLogger,
manager: NewManager(cfg, moduleLogger),
rules: rules,
}
}
func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
}
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
items, err := s.store.List(ctx)
if err != nil {
return nil, err
}
for i := range items {
s.attachConfig(&items[i])
}
return items, nil
}
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
s.attachConfig(&item)
return item, nil
}
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
if strings.TrimSpace(in.Mode) == "" {
in.Mode = ModeAccountLocal
}
if err := validateInput(in); err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
in.Network = s.applyDefaultNetwork(ctx, in.Network)
tunnelID, tunnelToken, err := api.CreateTunnel(ctx, in.Name)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, fmt.Errorf("Cloudflare 返回的隧道凭据无法解析: %w", err)
}
dir := s.tunnelDir(in.Name)
if err := os.MkdirAll(dir, 0o750); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
configPath := filepath.Join(dir, "config.yml")
credsPath := filepath.Join(dir, "creds.json")
logPath := filepath.Join(dir, "cloudflared.log")
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(configPath, []byte(BuildConfig(decodedID, credsPath, in.Network, ingress)), 0o640); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
tunnel, err := s.store.Create(ctx, in, decodedID, accountTag, encrypted, "", configPath, credsPath, logPath)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if tunnel.Managed {
if err := s.syncManagedTunnel(ctx, tunnel); err != nil {
s.logger.Warn("创建托管隧道后同步规则失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但规则或 DNS 同步失败:" + err.Error()
}
return tunnel, nil
}
_ = api.PutIngress(ctx, decodedID, ingress)
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, decodedID, in.Hostnames); err != nil {
s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + err.Error()
}
return tunnel, nil
}
// Update 更新隧道配置;凭据由 Cloudflare API 创建时生成,编辑不修改。
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
if current.Managed && !in.Managed {
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return Tunnel{}, fmt.Errorf("仍有 %d 条反向代理规则使用该托管隧道,请先移除 Cloudflare 出口", count)
}
}
_ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err
}
updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
if err != nil {
return Tunnel{}, err
}
dnsWarning := ""
if updated.Managed {
if err := s.syncManagedTunnel(ctx, updated); err != nil {
dnsWarning = "托管隧道已保存,但规则或 DNS 同步失败:" + err.Error()
}
} else if updated.Mode == ModeAccountLocal && updated.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, updated.TunnelID, ingress)
if err := s.syncTunnelDNS(ctx, api, updated.ID, updated.TunnelID, in.Hostnames); err != nil {
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", updated.TunnelID, "error", err.Error())
dnsWarning = "DNS 记录同步失败:" + err.Error()
}
}
}
updated.DNSWarning = dnsWarning
return updated, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
current, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return fmt.Errorf("仍有 %d 条反向代理规则使用该隧道,请先移除这些规则的 Cloudflare 出口", count)
}
_ = s.manager.Stop(id)
if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
s.deleteTunnelDNS(ctx, api, current.ID)
_ = api.DeleteTunnel(ctx, current.TunnelID)
}
}
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
_ = os.RemoveAll(dir)
}
return s.store.Delete(ctx, id)
}
func (s *Service) tunnelUsageCount(ctx context.Context, id int64) int {
if s.rules == nil {
return 0
}
rules, err := s.rules.List(ctx)
if err != nil {
return 0
}
count := 0
for _, rule := range rules {
if rule.UsesExit(proxy.ExitCloudflare) && rule.CFTunnelID == id {
count++
}
}
return count
}
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return Tunnel{}, err
}
if !settings.Enabled {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道全局已停用,请先在页面顶部重新启用")
}
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
binaryPath := s.binaryPath()
if _, err := os.Stat(binaryPath); err != nil {
return Tunnel{}, fmt.Errorf("cloudflared 未安装,请先下载二进制")
}
token := ""
if tunnel.Mode == ModeTokenRemote {
plain, err := s.secretBox.Decrypt(tunnel.CredentialsEnc)
if err != nil {
return Tunnel{}, fmt.Errorf("凭据解密失败: %w", err)
}
token = plain
}
if err := s.manager.Start(tunnel, binaryPath, tunnel.ConfigPath, token); err != nil {
_ = s.store.SetRuntime(ctx, id, "failed", err.Error(), 0)
return Tunnel{}, err
}
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
_ = s.manager.Stop(id)
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
return s.Get(ctx, id)
}
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
_, _ = s.Stop(ctx, id)
return s.Start(ctx, id)
}
// RefreshCredentials 重新从 Cloudflare 获取隧道凭据并更新本地 creds.json。
func (s *Service) RefreshCredentials(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
token, err := api.TunnelToken(ctx, tunnel.TunnelID)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.CredsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := s.store.UpdateCredentials(ctx, id, encrypted); err != nil {
return Tunnel{}, err
}
if s.manager.Status(id).Running {
return s.Restart(ctx, id)
}
return s.Get(ctx, id)
}
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
if _, err := s.store.Get(ctx, id); err != nil {
return RuntimeStatus{}, err
}
return s.manager.Status(id), nil
}
// AppSettings 返回模块级配置;默认 token 只回显掩码。
func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
out := AppSettings{AccountID: row.AccountID, Enabled: row.Enabled, Mirror: row.Mirror, Network: row.Network}
if row.TokenEnc != "" {
out.DefaultTokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil {
out.DefaultTokenMasked = MaskSecret(plain)
}
}
if row.APITokenEnc != "" {
out.APITokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.APITokenEnc); err == nil {
out.APITokenMasked = MaskSecret(plain)
}
}
return out, nil
}
// SaveAppSettings 保存默认 token、下载镜像与默认网络设置;Token 留空表示保留原值。
func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
accountID := strings.TrimSpace(in.AccountID)
if accountID == "" {
accountID = row.AccountID
}
enabled := row.Enabled
if in.Enabled != nil {
enabled = *in.Enabled
}
tokenEnc := row.TokenEnc
apiTokenEnc := row.APITokenEnc
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
apiTokenEnc, err = s.secretBox.Encrypt(apiToken)
if err != nil {
return AppSettings{}, err
}
}
mirror := strings.TrimSpace(in.Mirror)
if mirror == "" {
mirror = row.Mirror
}
if mirror == "" {
mirror = "official"
}
network := in.Network
if strings.TrimSpace(network.TransportProtocol) == "" {
network = row.Network
}
if err := s.store.SaveSettings(ctx, accountID, enabled, tokenEnc, apiTokenEnc, mirror, network); err != nil {
return AppSettings{}, err
}
return s.AppSettings(ctx)
}
// SetEnabled 切换全局托管开关;关闭时停止所有隧道,开启时恢复 auto_start 的隧道。
func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return AppSettings{}, err
}
if err := s.store.SaveSettings(ctx, row.AccountID, enabled, row.TokenEnc, row.APITokenEnc, row.Mirror, row.Network); err != nil {
return AppSettings{}, err
}
if enabled {
s.StartAll(ctx)
} else {
s.StopAll(ctx)
}
return s.AppSettings(ctx)
}
// StopAll 停止所有隧道进程,但保留配置、凭据与云端隧道。
func (s *Service) StopAll(ctx context.Context) {
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if err := s.manager.Stop(tunnel.ID); err != nil {
s.logger.Warn("停止 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
_ = s.store.SetRuntime(ctx, tunnel.ID, "stopped", "", 0)
}
}
func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettings) NetworkSettings {
if strings.TrimSpace(network.TransportProtocol) != "" {
return network
}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return DefaultNetworkSettings()
}
return row.Network
}
func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
accountID := strings.TrimSpace(row.AccountID)
if accountID == "" {
return nil, fmt.Errorf("请先在「应用配置」中填写 Account ID")
}
if row.APITokenEnc == "" {
return nil, fmt.Errorf("请先在「应用配置」中保存 API Token")
}
token, err := s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return nil, fmt.Errorf("API Token 解密失败: %w", err)
}
return NewCloudflareAPI(accountID, token), nil
}
// SyncRules 让所有托管隧道从反向代理规则重新派生 ingress 与 DNS。
func (s *Service) SyncRules(ctx context.Context) error {
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return err
}
var firstErr error
for _, tunnel := range tunnels {
if !tunnel.Managed {
continue
}
assigned := rulesForTunnel(rules, tunnel.ID)
dnsRecords, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil && firstErr == nil {
firstErr = err
continue
}
if len(assigned) == 0 && len(dnsRecords) == 0 {
continue
}
if err := s.syncManagedTunnel(ctx, tunnel); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func rulesForTunnel(rules []proxy.Rule, tunnelID int64) []proxy.Rule {
out := make([]proxy.Rule, 0)
for _, rule := range rules {
if !rule.CloudflareEnabled() || rule.CFTunnelID != tunnelID {
continue
}
out = append(out, rule)
}
return out
}
func (s *Service) syncManagedTunnel(ctx context.Context, tunnel Tunnel) error {
if !tunnel.Managed {
return nil
}
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
assigned := rulesForTunnel(rules, tunnel.ID)
ingress := BuildIngressForRules(assigned)
content := BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(content), 0o640); err != nil {
return err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnamesFromIngress(ingress)); err != nil {
return err
}
return s.restartIfRunning(ctx, tunnel.ID)
}
func hostnamesFromIngress(ingress []IngressRule) []string {
out := make([]string, 0, len(ingress))
for _, item := range ingress {
if strings.TrimSpace(item.Hostname) != "" {
out = append(out, item.Hostname)
}
}
return out
}
func (s *Service) restartIfRunning(ctx context.Context, id int64) error {
if !s.manager.Status(id).Running {
return nil
}
if _, err := s.Restart(ctx, id); err != nil {
return fmt.Errorf("配置已同步,但隧道重启失败:%w", err)
}
return nil
}
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Havline 自己接管的记录。
func (s *Service) syncTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, remoteTunnelID string, hostnames []string) error {
desired := make(map[string]struct{}, len(hostnames))
for _, raw := range hostnames {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname != "" {
desired[hostname] = struct{}{}
}
}
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
return err
}
var firstErr error
for _, record := range records {
if _, ok := desired[record.Hostname]; ok {
continue
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil && firstErr == nil {
firstErr = err
}
}
target := remoteTunnelID + ".cfargotunnel.com"
for hostname := range desired {
zoneID, _, err := api.FindZone(ctx, hostname)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
recordID, err := api.EnsureCNAME(ctx, zoneID, hostname, target)
if err != nil {
if transferredID, transferErr := s.transferTrackedDNS(ctx, api, localTunnelID, zoneID, hostname, target); transferErr == nil {
recordID = transferredID
err = nil
}
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if strings.TrimSpace(recordID) == "" {
if firstErr == nil {
firstErr = fmt.Errorf("Cloudflare 未返回 %s 的 DNS 记录 ID", hostname)
}
continue
}
if err := s.store.UpsertDNSRecord(ctx, localTunnelID, zoneID, recordID, hostname); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) transferTrackedDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, zoneID, hostname, target string) (string, error) {
record, err := s.store.FindDNSRecord(ctx, hostname)
if err != nil {
return "", err
}
if record.TunnelID == localTunnelID {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
func (s *Service) deleteTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64) {
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
s.logger.Warn("读取 Cloudflare DNS 记录失败", "tunnel_id", localTunnelID, "error", err.Error())
return
}
for _, record := range records {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
s.logger.Warn("删除 Cloudflare DNS 记录失败", "hostname", record.Hostname, "error", err.Error())
continue
}
_ = s.store.DeleteDNSRecord(ctx, record.ID)
}
_ = s.store.DeleteDNSRecordsForTunnel(ctx, localTunnelID)
}
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
// accountID / input 为空时回退到应用配置中已保存的值。
func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return "", err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
accountID = strings.TrimSpace(row.AccountID)
}
if accountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
token := strings.TrimSpace(input)
if token == "" {
if row.APITokenEnc == "" {
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
}
token, err = s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return "", fmt.Errorf("API Token 解密失败: %w", err)
}
}
return NewCloudflareAPI(accountID, token).VerifyToken(ctx)
}
// TestToken 验证 Account ID + API Token 是否可用。
func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (string, error) {
return s.VerifyAPIToken(ctx, accountID, input)
}
// Zones 返回当前 API Token 可访问的 Cloudflare Zone。
func (s *Service) Zones(ctx context.Context) ([]Zone, error) {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return nil, err
}
return api.ListZones(ctx)
}
// AccessStatus 只读检测域名是否启用了 Cloudflare Access。
func (s *Service) AccessStatus(ctx context.Context, hostname string) (bool, string, error) {
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return false, "", fmt.Errorf("域名为空")
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return false, "", err
}
return api.HasAccessForHostname(ctx, hostname)
}
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
func (s *Service) attachConfig(item *Tunnel) {
data, err := os.ReadFile(item.ConfigPath)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
host = strings.TrimSpace(host)
if host == "" {
continue
}
item.Hostnames = append(item.Hostnames, host)
if i+1 >= len(lines) {
continue
}
if service, ok := strings.CutPrefix(strings.TrimSpace(lines[i+1]), "service:"); ok && item.OriginService == "" {
item.OriginService = strings.TrimSpace(service)
}
}
}
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
return s.manager.Logs(tunnel.LogPath, lines)
}
func (s *Service) PreviewConfig(ctx context.Context, id int64) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
data, err := os.ReadFile(tunnel.ConfigPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// IngressCandidates 从本机反代规则推导可暴露的域名候选。
func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
if s.rules == nil {
return []string{}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return nil, err
}
hosts := []string{}
for _, rule := range rules {
if !rule.Enabled {
continue
}
for _, group := range rule.PortGroups() {
hosts = append(hosts, group.Hostnames...)
}
}
return hosts, nil
}
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if !tunnel.AutoStart {
continue
}
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
// StartAll 启动全部隧道;用于全局总开关重新开启时立即恢复所有出口。
func (s *Service) StartAll(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
func (s *Service) tunnelDir(name string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
return r
}
return '_'
}, strings.TrimSpace(name))
return filepath.Join(s.cfg.DataDir, "cloudflared", "tunnels", safe)
}
func validateInput(in CreateInput) error {
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("隧道名称不能为空")
}
switch in.Mode {
case ModeAccountLocal, ModeTokenLocal, ModeTokenRemote:
default:
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
}
if !in.Managed && len(in.Hostnames) == 0 {
return fmt.Errorf("手动隧道至少需要一个暴露域名")
}
if in.Managed && in.Mode != ModeAccountLocal {
return fmt.Errorf("仅 API 管理隧道支持自动托管")
}
return nil
}
@@ -0,0 +1,102 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/secret"
)
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-settings-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SaveAppSettings(context.Background(), AppSettingsInput{
AccountID: "acc-1", APIToken: "cfat_test_token", Mirror: "gh-proxy-org", Network: DefaultNetworkSettings(),
})
if err != nil {
t.Fatal(err)
}
if settings.AccountID != "acc-1" || !settings.APITokenConfigured || settings.APITokenMasked == "" {
t.Fatalf("Account ID 与 API Token 应保存并回显掩码:%#v", settings)
}
api, err := svc.cloudflareAPI(context.Background())
if err != nil || api.AccountID != "acc-1" || api.APIToken != "cfat_test_token" {
t.Fatalf("cloudflareAPI 构造失败:%v %#v", err, api)
}
}
func TestSetEnabledBlocksStart(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
box, err := secret.NewBox("cloudflared-enabled-test")
if err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
settings, err := svc.SetEnabled(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if settings.Enabled {
t.Fatal("全局托管应为停用")
}
if _, err := svc.Start(context.Background(), 1); err == nil || !strings.Contains(err.Error(), "全局已停用") {
t.Fatalf("全局停用时启动应被拒绝:%v", err)
}
settings, err = svc.SetEnabled(context.Background(), true)
if err != nil || !settings.Enabled {
t.Fatalf("重新启用失败:%v %#v", err, settings)
}
}
func TestManagedTunnelPersists(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
store := NewStore(conn)
item, err := store.Create(context.Background(), CreateInput{
Name: "managed",
Mode: ModeAccountLocal,
Network: DefaultNetworkSettings(),
Managed: true,
AutoStart: true,
}, "remote-tunnel", "account", "encrypted", "", "/tmp/config.yml", "/tmp/creds.json", "/tmp/cloudflared.log")
if err != nil {
t.Fatal(err)
}
if !item.Managed || !item.AutoStart || item.TunnelID != "remote-tunnel" {
t.Fatalf("托管隧道字段未持久化:%#v", item)
}
}
+252
View File
@@ -0,0 +1,252 @@
package cloudflared
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
const tunnelSelect = `
SELECT id, name, mode, COALESCE(tunnel_id, ''), COALESCE(account_tag, ''),
COALESCE(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''),
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), COALESCE(managed, 0), created_at, updated_at
FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
rows, err := s.db.QueryContext(ctx, tunnelSelect+` ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Tunnel{}
for rows.Next() {
item, err := scanTunnel(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := scanTunnel(s.db.QueryRowContext(ctx, tunnelSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道不存在")
}
return item, err
}
func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
result, err := s.db.ExecContext(ctx, `
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc,
config_path, creds_path, log_path, network_json, status, auto_start, managed, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed))
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
id, _ := result.LastInsertId()
return s.Get(ctx, id)
}
func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?,
network_json = ?, auto_start = ?, managed = ?, updated_at = datetime('now')
WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id)
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_tunnels WHERE id = ?`, id)
return err
}
func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError string, metricsPort int) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET status = ?, last_error = ?, metrics_port = ?, updated_at = datetime('now')
WHERE id = ?
`, status, lastError, metricsPort, id)
return err
}
func (s *Store) UpdateCredentials(ctx context.Context, id int64, credentialsEnc string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET credentials_enc = ?, updated_at = datetime('now') WHERE id = ?
`, credentialsEnc, id)
return err
}
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
type settingsRow struct {
AccountID string
Enabled bool
TokenEnc string
APITokenEnc string
Mirror string
Network NetworkSettings
}
// DNSRecord 是 Havline 自动创建或接管的 Cloudflare DNS 记录。
type DNSRecord struct {
ID int64 `json:"id"`
TunnelID int64 `json:"tunnel_id"`
ZoneID string `json:"zone_id"`
RecordID string `json:"record_id"`
Hostname string `json:"hostname"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Store) ListDNSRecords(ctx context.Context, tunnelID int64) ([]DNSRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE tunnel_id = ? ORDER BY hostname ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DNSRecord{}
for rows.Next() {
var item DNSRecord
if err := rows.Scan(&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) FindDNSRecord(ctx context.Context, hostname string) (DNSRecord, error) {
var item DNSRecord
err := s.db.QueryRowContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE hostname = ?
`, strings.ToLower(strings.TrimSpace(hostname))).Scan(
&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("Cloudflare DNS 记录不存在")
}
return item, err
}
func (s *Store) UpsertDNSRecord(ctx context.Context, tunnelID int64, zoneID, recordID, hostname string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO cf_dns_records(tunnel_id, zone_id, record_id, hostname, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
tunnel_id = excluded.tunnel_id,
zone_id = excluded.zone_id,
record_id = excluded.record_id,
updated_at = datetime('now')
`, tunnelID, zoneID, recordID, strings.ToLower(strings.TrimSpace(hostname)))
return err
}
func (s *Store) DeleteDNSRecord(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE id = ?`, id)
return err
}
func (s *Store) DeleteDNSRecordsForTunnel(ctx context.Context, tunnelID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE tunnel_id = ?`, tunnelID)
return err
}
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
var enabled int
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(account_id, ''), COALESCE(enabled, 1), COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
FROM cf_settings WHERE id = 1
`).Scan(&accountID, &enabled, &tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
if errors.Is(err, sql.ErrNoRows) {
return settingsRow{Enabled: true, Mirror: "official", Network: DefaultNetworkSettings()}, nil
}
if err != nil {
return settingsRow{}, err
}
row := settingsRow{AccountID: accountID, Enabled: enabled == 1, TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
return row, nil
}
func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
networkJSON, _ := json.Marshal(network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_settings
SET account_id = ?, enabled = ?, default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
WHERE id = 1
`, accountID, boolInt(enabled), tokenEnc, apiTokenEnc, mirror, string(networkJSON))
return err
}
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel
var networkJSON, created, updated string
var autoStart, managed int
err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath,
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &managed, &created, &updated)
if err != nil {
return Tunnel{}, err
}
item.AutoStart = autoStart == 1
item.Managed = managed == 1
item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
return item, nil
}
func parseTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func mapUniqueName(err error) error {
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("Cloudflare 隧道名称已存在")
}
return err
}