This commit is contained in:
@@ -2,6 +2,7 @@ package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -86,6 +87,11 @@ func (s *Service) BinaryVersion() string {
|
||||
|
||||
// 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 {
|
||||
@@ -93,7 +99,7 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string,
|
||||
}
|
||||
tmp := target + ".download"
|
||||
_ = os.Remove(tmp)
|
||||
if err := fetchFile(ctx, DownloadURL(mirror, ""), tmp); err != nil {
|
||||
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
@@ -109,13 +115,73 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string,
|
||||
_ = 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)
|
||||
|
||||
@@ -144,6 +144,19 @@ func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, 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)
|
||||
@@ -155,6 +168,15 @@ type Zone struct {
|
||||
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, "."), ".")
|
||||
@@ -168,23 +190,66 @@ func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string,
|
||||
return zones[0].ID, zones[0].Name, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认 API Token 具备 Zone:Read 权限", hostname)
|
||||
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) error {
|
||||
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
|
||||
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
|
||||
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
|
||||
var records []struct {
|
||||
ID string `json:"id"`
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
|
||||
}
|
||||
if len(records) > 0 {
|
||||
return a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil)
|
||||
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
|
||||
}
|
||||
return a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, 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 删除一条明确由 Fonu 接管的 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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
type tokenPayload struct {
|
||||
@@ -86,6 +88,9 @@ func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []
|
||||
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
|
||||
}
|
||||
@@ -144,6 +149,33 @@ func BuildIngress(hostnames []string, service string) []IngressRule {
|
||||
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)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDecodeToken(t *testing.T) {
|
||||
@@ -68,6 +70,49 @@ func TestBuildIngressInvariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@@ -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/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/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)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
var metricsPortPattern = regexp.MustCompile(`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
|
||||
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
|
||||
|
||||
type processState struct {
|
||||
cmd *exec.Cmd
|
||||
|
||||
@@ -41,6 +41,13 @@ func TestParseMetricsHTTP2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
|
||||
@@ -33,6 +33,7 @@ func DefaultNetworkSettings() NetworkSettings {
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ type Tunnel struct {
|
||||
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"`
|
||||
@@ -69,6 +71,7 @@ type CreateInput struct {
|
||||
OriginService string `json:"origin_service"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
|
||||
|
||||
@@ -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: "没有 Fonu 接管的 DNS 记录"}
|
||||
}
|
||||
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
|
||||
}
|
||||
|
||||
func networkCheck(name, address string) DiagnosticCheck {
|
||||
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
|
||||
if err != nil {
|
||||
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
|
||||
}
|
||||
_ = conn.Close()
|
||||
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
|
||||
}
|
||||
|
||||
// RouteTestResult 是单条路由源服务的连通性测试结果。
|
||||
type RouteTestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// TestService 测试一条路由 service 的 TCP 连通性。
|
||||
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
|
||||
address, err := serviceAddress(service)
|
||||
if err != nil {
|
||||
return RouteTestResult{}, err
|
||||
}
|
||||
start := time.Now()
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return RouteTestResult{OK: false, Message: err.Error()}, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
|
||||
}
|
||||
|
||||
func serviceAddress(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" {
|
||||
if !strings.Contains(raw, ":") {
|
||||
return "", fmt.Errorf("service 格式无效:%s", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
switch parsed.Scheme {
|
||||
case "http":
|
||||
port = "80"
|
||||
case "https":
|
||||
port = "443"
|
||||
case "ssh":
|
||||
port = "22"
|
||||
default:
|
||||
return "", fmt.Errorf("service 缺少端口:%s", raw)
|
||||
}
|
||||
}
|
||||
return net.JoinHostPort(host, port), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cloudflared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
|
||||
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
|
||||
actual := []IngressRule{
|
||||
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
|
||||
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
|
||||
{Service: "http_status:404"},
|
||||
}
|
||||
items := compareIngress(expected, actual)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIngressIgnoresOrder(t *testing.T) {
|
||||
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
|
||||
actual := []IngressRule{
|
||||
{Service: "http_status:404"},
|
||||
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
|
||||
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
|
||||
}
|
||||
if items := compareIngress(expected, actual); len(items) != 0 {
|
||||
t.Fatalf("顺序不同不应算漂移:%#v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/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)
|
||||
}
|
||||
}
|
||||
+320
-19
@@ -102,6 +102,9 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
||||
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
|
||||
@@ -120,8 +123,15 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
||||
_ = 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.ensureTunnelDNS(ctx, api, decodedID, in.Hostnames); err != nil {
|
||||
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()
|
||||
}
|
||||
@@ -134,6 +144,11 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
|
||||
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)
|
||||
@@ -141,24 +156,31 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
|
||||
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
|
||||
}
|
||||
dnsWarning := ""
|
||||
if current.Mode == ModeAccountLocal && current.TunnelID != "" {
|
||||
if api, err := s.cloudflareAPI(ctx); err == nil {
|
||||
_ = api.PutIngress(ctx, current.TunnelID, ingress)
|
||||
if err := s.ensureTunnelDNS(ctx, api, current.TunnelID, in.Hostnames); err != nil {
|
||||
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", current.TunnelID, "error", err.Error())
|
||||
dnsWarning = "DNS 记录同步失败:" + err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -168,9 +190,13 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +206,23 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
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 {
|
||||
@@ -223,6 +266,40 @@ func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
|
||||
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
|
||||
@@ -301,7 +378,7 @@ func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, er
|
||||
return AppSettings{}, err
|
||||
}
|
||||
if enabled {
|
||||
s.StartAuto(ctx)
|
||||
s.StartAll(ctx)
|
||||
} else {
|
||||
s.StopAll(ctx)
|
||||
}
|
||||
@@ -353,15 +430,131 @@ func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
|
||||
return NewCloudflareAPI(accountID, token), nil
|
||||
}
|
||||
|
||||
// ensureTunnelDNS 为所有 hostname 幂等创建或更新指向隧道的 CNAME 记录。
|
||||
func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunnelID string, hostnames []string) error {
|
||||
target := tunnelID + ".cfargotunnel.com"
|
||||
// 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 _, raw := range hostnames {
|
||||
hostname := strings.ToLower(strings.TrimSpace(raw))
|
||||
if hostname == "" {
|
||||
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 保持一致,并只清理 Fonu 自己接管的记录。
|
||||
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 {
|
||||
@@ -369,13 +562,71 @@ func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunne
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := api.EnsureCNAME(ctx, zoneID, hostname, target); err != nil && firstErr == nil {
|
||||
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) {
|
||||
@@ -408,6 +659,28 @@ func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (st
|
||||
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)
|
||||
@@ -504,6 +777,28 @@ func (s *Service) StartAuto(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == '.' {
|
||||
@@ -523,5 +818,11 @@ func validateInput(in CreateInput) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -74,3 +74,29 @@ func TestSetEnabledBlocksStart(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ 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), created_at, updated_at
|
||||
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) {
|
||||
@@ -55,10 +55,10 @@ func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag
|
||||
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, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, datetime('now'))
|
||||
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))
|
||||
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed))
|
||||
if err != nil {
|
||||
return Tunnel{}, mapUniqueName(err)
|
||||
}
|
||||
@@ -72,10 +72,10 @@ func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID,
|
||||
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 = ?, updated_at = datetime('now')
|
||||
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), id)
|
||||
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id)
|
||||
if err != nil {
|
||||
return Tunnel{}, mapUniqueName(err)
|
||||
}
|
||||
@@ -95,6 +95,13 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
|
||||
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
|
||||
@@ -105,6 +112,74 @@ type settingsRow struct {
|
||||
Network NetworkSettings
|
||||
}
|
||||
|
||||
// DNSRecord 是 Fonu 自动创建或接管的 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
|
||||
@@ -137,14 +212,15 @@ func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool
|
||||
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
|
||||
var item Tunnel
|
||||
var networkJSON, created, updated string
|
||||
var autoStart int
|
||||
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, &created, &updated)
|
||||
&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)
|
||||
|
||||
Reference in New Issue
Block a user