feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/acme"
|
||||
"github.com/havline/havline/internal/api"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/chinacidr"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/logstore"
|
||||
"github.com/havline/havline/internal/metrics"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
"github.com/havline/havline/internal/nginx"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/scheduler"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
server *http.Server
|
||||
nginx *nginx.Manager
|
||||
frpMulti *frp.Service
|
||||
cloudflare *cloudflared.Service
|
||||
scheduler *scheduler.Scheduler
|
||||
shutdownFn context.CancelFunc
|
||||
db interface{ Close() error }
|
||||
}
|
||||
|
||||
func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
|
||||
appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
|
||||
logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 会话密钥:显式配置优先;未配置则在数据目录里读/生成并持久化
|
||||
// (它同时是 secret box 的加密密钥,所以只在未配置时才生成,不替换已配置的值)
|
||||
secretSource, err := cfg.ResolveSessionSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
|
||||
|
||||
conn, err := db.Open(cfg.DBPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretBox, err := secret.NewBox(cfg.SessionSecret)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authSvc := auth.New(conn)
|
||||
if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
proxyStore := proxy.NewStore(conn)
|
||||
settingsStore := settings.NewStore(conn)
|
||||
ddnsStore := ddns.NewStore(conn)
|
||||
certStore := certificate.NewStore(conn)
|
||||
nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
|
||||
if err := nginxMgr.EnsureDirs(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
|
||||
chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
|
||||
notifySvc := notify.New(settingsStore, secretBox, logger)
|
||||
proxySvc.SetNotify(notifySvc)
|
||||
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
|
||||
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
|
||||
backupSvc := backup.New(cfg.DataDir)
|
||||
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
|
||||
metricsStore := metrics.NewStore(conn)
|
||||
discoverySvc := discovery.New()
|
||||
// 新增:多服务端 FRP 模块(接管原网关模式)
|
||||
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
|
||||
if err := frpMultiSvc.EnsureDirs(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
|
||||
if err := cloudflareSvc.EnsureDirs(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
proxySvc.SetExitSync(cloudflareSvc)
|
||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
||||
|
||||
handler := api.NewRouter(api.Deps{
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
Auth: authSvc,
|
||||
Proxy: proxySvc,
|
||||
DDNS: ddnsSvc,
|
||||
ACME: acmeSvc,
|
||||
Settings: settingsStore,
|
||||
Notify: notifySvc,
|
||||
ChinaCIDR: chinaCIDRSvc,
|
||||
Backup: backupSvc,
|
||||
Metrics: metricsStore,
|
||||
Discovery: discoverySvc,
|
||||
FRPMulti: frpMultiSvc,
|
||||
Cloudflare: cloudflareSvc,
|
||||
Traffic: trafficCollector,
|
||||
Updater: updaterClient,
|
||||
StaticFS: staticFS,
|
||||
StartedAt: startedAt,
|
||||
})
|
||||
if prefix := strings.TrimSpace(cfg.GatewayPrefix); prefix != "" {
|
||||
handler = stripPrefixIfPresent(prefix, handler)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
// 读超时放到 5 分钟:备份恢复要上传最大 64MB;不设 WriteTimeout,否则日志 SSE 会被中途切断
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
frpMultiSvc.SetBaseContext(ctx)
|
||||
go frpMultiSvc.StartAgentTunnels(ctx)
|
||||
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
|
||||
go cloudflareSvc.StartAll(ctx)
|
||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 5
|
||||
}
|
||||
chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
|
||||
if chinaHours <= 0 {
|
||||
chinaHours = 24
|
||||
}
|
||||
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
|
||||
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
|
||||
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
|
||||
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
|
||||
sched := scheduler.New(
|
||||
scheduler.Job{
|
||||
Name: "ddns",
|
||||
Interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
Run: ddnsSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "acme",
|
||||
Interval: 24 * time.Hour,
|
||||
Run: acmeSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "china_cidr",
|
||||
Interval: time.Duration(chinaHours) * time.Hour,
|
||||
Run: chinaCIDRSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "monitor",
|
||||
Interval: 2 * time.Minute,
|
||||
Run: monitorChecker.Run,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "backup",
|
||||
Interval: backup.AutoInterval,
|
||||
Run: backupRunner.Run,
|
||||
},
|
||||
)
|
||||
sched.Start(ctx)
|
||||
trafficCollector.Start(ctx)
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
server: server,
|
||||
nginx: nginxMgr,
|
||||
frpMulti: frpMultiSvc,
|
||||
scheduler: sched,
|
||||
shutdownFn: cancel,
|
||||
db: conn,
|
||||
}
|
||||
|
||||
if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
|
||||
cancel()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
|
||||
logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
|
||||
logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
|
||||
logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
|
||||
if err := proxySvc.ReloadAll(ctx); err != nil {
|
||||
a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
errCh <- a.server.ListenAndServe()
|
||||
}()
|
||||
|
||||
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
|
||||
if err := os.Remove(socket); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(socket, 0o660); err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
errCh <- a.server.Serve(listener)
|
||||
}()
|
||||
}
|
||||
|
||||
err := <-errCh
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) error {
|
||||
a.logger.Info("application shutting down", "module", "SYSTEM")
|
||||
if a.shutdownFn != nil {
|
||||
a.shutdownFn()
|
||||
}
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if socket := strings.TrimSpace(a.cfg.GatewaySocket); socket != "" {
|
||||
_ = os.Remove(socket)
|
||||
}
|
||||
if err := a.nginx.Stop(ctx); err != nil {
|
||||
a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
|
||||
}
|
||||
if a.frpMulti != nil {
|
||||
if err := a.frpMulti.Stop(ctx); err != nil {
|
||||
a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
|
||||
}
|
||||
}
|
||||
if a.db != nil {
|
||||
return a.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripPrefixIfPresent(prefix string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if r.URL.Path == "" {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveMigrationsDir() (string, error) {
|
||||
candidates := []string{
|
||||
"migrations",
|
||||
filepath.Join("..", "migrations"),
|
||||
filepath.Join("..", "..", "migrations"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if info, err := os.Stat(c); err == nil && info.IsDir() {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("migrations directory not found")
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
)
|
||||
|
||||
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
|
||||
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
|
||||
type monitorSource struct {
|
||||
frp *frp.Service
|
||||
cloudflare *cloudflared.Service
|
||||
logger *slog.Logger
|
||||
traffic *traffic.Collector
|
||||
}
|
||||
|
||||
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
|
||||
if s.cloudflare == nil {
|
||||
return []monitor.CloudflareRouteStatus{}, nil
|
||||
}
|
||||
statuses, err := s.cloudflare.HealthStatuses(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
|
||||
for _, item := range statuses {
|
||||
out = append(out, monitor.CloudflareRouteStatus{
|
||||
TunnelID: item.TunnelID,
|
||||
Name: item.Name,
|
||||
Domain: item.Domain,
|
||||
State: item.State,
|
||||
Reason: item.Reason,
|
||||
Detail: item.Detail,
|
||||
Known: item.Known,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
|
||||
if s.cloudflare == nil {
|
||||
return nil
|
||||
}
|
||||
converted := make([]cloudflared.HealthSample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
converted = append(converted, cloudflared.HealthSample{
|
||||
TunnelID: sample.TunnelID,
|
||||
Name: sample.Name,
|
||||
Domain: sample.Domain,
|
||||
State: sample.State,
|
||||
Reason: sample.Reason,
|
||||
Detail: sample.Detail,
|
||||
})
|
||||
}
|
||||
return s.cloudflare.RecordHealth(ctx, converted)
|
||||
}
|
||||
|
||||
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
|
||||
if s.cloudflare == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.cloudflare.PruneHealth(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
|
||||
servers, err := s.enabledServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes := make([]monitor.RouteStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
// 单台服务端探测失败不影响其它服务端:它本轮跳过,等下一轮
|
||||
health, err := s.frp.RouteHealth(ctx, server.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("巡检跳过服务端隧道", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
for _, item := range health {
|
||||
routes = append(routes, monitor.RouteStatus{
|
||||
ServerID: server.ID,
|
||||
Domain: item.Domain,
|
||||
TunnelOK: item.TunnelOK,
|
||||
DNSOK: item.DNSOK,
|
||||
ServiceOK: item.ServiceOK,
|
||||
Known: item.TunnelKnown,
|
||||
Detail: item.TunnelDetail,
|
||||
})
|
||||
}
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func (s monitorSource) Servers(ctx context.Context) ([]monitor.ServerStatus, error) {
|
||||
servers, err := s.enabledServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]monitor.ServerStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
out = append(out, monitor.ServerStatus{
|
||||
ID: server.ID,
|
||||
Name: server.Name,
|
||||
DesiredState: server.DesiredState,
|
||||
ProcessState: server.ProcessState,
|
||||
ConnectionState: server.ConnectionState,
|
||||
Detail: server.LastError,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s monitorSource) Agents(ctx context.Context) ([]monitor.AgentStatus, error) {
|
||||
servers, err := s.enabledServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]monitor.AgentStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
// AgentClient 只在「地址与令牌都可用」时才返回 ok:没配 agent 的服务端不参与可达性判定
|
||||
client, ok := s.frp.AgentClient(ctx, server.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status := monitor.AgentStatus{ID: server.ID, Name: server.Name, Reachable: true}
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
status.Reachable = false
|
||||
status.Detail = err.Error()
|
||||
}
|
||||
out = append(out, status)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s monitorSource) Drift(ctx context.Context) ([]monitor.DriftStatus, error) {
|
||||
servers, err := s.enabledServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]monitor.DriftStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
report, err := s.frp.RouteDrift(ctx, server.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("巡检跳过漂移检测", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
status := monitor.DriftStatus{ID: server.ID, Name: server.Name, Count: len(report.Items)}
|
||||
for _, item := range report.Items {
|
||||
status.Items = append(status.Items, fmt.Sprintf("%s %s", item.Domain, driftKindText(item.Kind)))
|
||||
}
|
||||
out = append(out, status)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Traffic 返回若干域名的当前上下行速率:流量采集器内部按 host:port 累计,这里按域名合并。
|
||||
func (s monitorSource) Traffic(ctx context.Context, domains []string) (map[string]monitor.TrafficRate, error) {
|
||||
if s.traffic == nil {
|
||||
return nil, nil
|
||||
}
|
||||
snapshot := s.traffic.SnapshotByHost(domains)
|
||||
rates := make(map[string]monitor.TrafficRate, len(snapshot))
|
||||
for host, item := range snapshot {
|
||||
rates[host] = monitor.TrafficRate{Upload: item.UploadRate, Download: item.DownloadRate}
|
||||
}
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
func (s monitorSource) enabledServers(ctx context.Context) ([]frp.Server, error) {
|
||||
servers, err := s.frp.ListServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := make([]frp.Server, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
// 停用中的服务端是用户主动关掉的,不该参与任何告警判定
|
||||
if server.Enabled {
|
||||
enabled = append(enabled, server)
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// driftKindText 与公网反代页漂移清单的文案保持一致,通知里才不用回页面去对照。
|
||||
func driftKindText(kind string) string {
|
||||
switch kind {
|
||||
case frp.RouteDriftMissing:
|
||||
return "未部署"
|
||||
case frp.RouteDriftOrphan:
|
||||
return "孤儿配置"
|
||||
case frp.RouteDriftNotLoaded:
|
||||
return "未生效"
|
||||
case frp.RouteDriftContentMismatch:
|
||||
return "内容不一致"
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
// healthRecorder 把 monitor 的健康结论翻成 frp 侧样本并落库(
|
||||
// 两侧的状态字面量由 healthStateValue 显式翻译,改动一侧会在这里编译期报错)。
|
||||
type healthRecorder struct {
|
||||
frp *frp.Service
|
||||
}
|
||||
|
||||
func (r healthRecorder) RecordRouteHealth(ctx context.Context, samples []monitor.HealthSample) error {
|
||||
converted := make([]frp.RouteHealthSample, 0, len(samples))
|
||||
for _, sample := range samples {
|
||||
converted = append(converted, frp.RouteHealthSample{
|
||||
ServerID: sample.ServerID,
|
||||
Domain: sample.Domain,
|
||||
State: healthStateValue(sample.State),
|
||||
Reason: sample.Reason,
|
||||
Detail: sample.Detail,
|
||||
})
|
||||
}
|
||||
_, err := r.frp.RecordRouteHealth(ctx, converted)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r healthRecorder) PruneRouteHealth(ctx context.Context) error {
|
||||
_, err := r.frp.PruneRouteHealth(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func healthStateValue(state string) string {
|
||||
switch state {
|
||||
case monitor.HealthUp:
|
||||
return frp.RouteHealthUp
|
||||
case monitor.HealthDown:
|
||||
return frp.RouteHealthDown
|
||||
default:
|
||||
return frp.RouteHealthUnknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
)
|
||||
|
||||
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
|
||||
// 这个测试把翻译钉住,避免以后改一侧的常量而另一侧悄悄写错值。
|
||||
func TestHealthStateValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
monitor.HealthUp: frp.RouteHealthUp,
|
||||
monitor.HealthDown: frp.RouteHealthDown,
|
||||
monitor.HealthUnknown: frp.RouteHealthUnknown,
|
||||
"其他": frp.RouteHealthUnknown,
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := healthStateValue(input); got != want {
|
||||
t.Fatalf("%q 应翻译为 %q,实际 %q", input, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user