Files
Havline/internal/frp/service.go
T
mrlee 211d8e66e0
CI / verify (push) Canceled after 0s
添加自定义404页面
2026-10-17 20:22:27 +08:00

2848 lines
94 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package frp
import (
"context"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/internal/secret"
)
// maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值
// (对齐官方网关模式的「脱敏令牌闭环」,防止掩码被当新密钥覆盖真实凭据)。
const maskedToken = "********"
type Service struct {
cfg config.Config
store *Store
secretBox *secret.Box
manager *Manager
logger *slog.Logger
certStore *certificate.Store // 用于 HTTPS 规则部署时把 Havline 本地证书推送到公网 agent
managersMu sync.RWMutex
managers map[int64]*Manager
// 穿透流量速率采样缓存:key 为规则 ID,用于在服务端计算两次采集之间的速率
trafficMu sync.Mutex
trafficClient *frpsTrafficClient
trafficLast map[int64]trafficSample
downloadMu sync.RWMutex
downloadTask DownloadTask
// baseCtx 是应用级 ctx:后台下载随之取消(之前用 context.Background(),App 关闭后仍在跑)
baseCtx context.Context
locationMu sync.Mutex
locations map[string]locationCache
// agentClients 缓存每台服务端的 agent HTTP 客户端(含连接池),按 URL+Token 指纹失效
agentMu sync.Mutex
agentClients map[int64]cachedAgentClient
tunnelMu sync.Mutex
tunnels map[int64]*agentTunnel
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
diagMu sync.Mutex
diagCache map[int64]diagnosticsCacheEntry
diagInFlight int
// applyMu 保证 FRP 配置文件写入与批量启动/重载的原子边界(23.5):
// SQLite 事务只保护关系数据,不保护 frpc-server-*.toml 与 frpc 进程。
applyMu sync.Mutex
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, certStore *certificate.Store) *Service {
bin := cfg.FrpcBin
if active, err := readActiveBinary(cfg.FrpDir()); err == nil && active != "" {
bin = active
}
service := &Service{
cfg: cfg,
store: store,
secretBox: secretBox,
manager: NewManager(bin, cfg.FrpConfigPath(), cfg.FrpLogPath(), logger),
managers: map[int64]*Manager{},
logger: logger.With("module", "FRP"),
certStore: certStore,
downloadTask: DownloadTask{
Status: "idle",
},
locations: map[string]locationCache{},
trafficClient: newFRPSTrafficClient(),
trafficLast: map[int64]trafficSample{},
diagCache: map[int64]diagnosticsCacheEntry{},
tunnels: map[int64]*agentTunnel{},
}
return service
}
func (s *Service) Runtime(ctx context.Context) RuntimeInfo {
s.manager.RefreshVersion(ctx)
bin, _ := s.manager.BinaryPath()
info := RuntimeInfo{
Platform: runtimePlatform(), ConfiguredBin: s.cfg.FrpcBin, ActiveBin: bin,
ConfigPath: s.cfg.FrpConfigPath(),
}
servers, _ := s.store.ListServers(ctx)
for _, server := range servers {
if !server.Enabled {
continue
}
info.ConfigPaths = append(info.ConfigPaths, s.serverConfigPath(server.ID))
if fileExists(s.serverConfigPath(server.ID)) {
info.ConfigExists = true
}
}
if len(info.ConfigPaths) == 0 {
info.ConfigExists = fileExists(info.ConfigPath)
}
info.DetectedVersion = s.manager.Status().Version
info.ActiveVersion = versionFromBinaryPath(s.cfg.FrpDir(), bin)
info.DownloadProxy, _ = readDownloadProxy(s.cfg.FrpDir())
return info
}
func (s *Service) ListReleases(ctx context.Context) ([]Release, error) {
proxy, err := readDownloadProxy(s.cfg.FrpDir())
if err != nil {
return nil, err
}
return fetchOfficialReleases(ctx, proxy)
}
func (s *Service) ListBinaries() ([]Binary, error) {
active, _ := s.manager.BinaryPath()
return listBinaries(s.cfg.FrpDir(), active)
}
func (s *Service) ServerDetail(ctx context.Context, id int64) (ServerDetail, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return ServerDetail{}, err
}
s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server)
status, err := s.store.ServerDiagnosticDetail(ctx, id)
if err != nil {
return ServerDetail{}, err
}
proxies, err := s.store.ListProxiesByServer(ctx, id)
if err != nil {
return ServerDetail{}, err
}
return ServerDetail{Server: server, Status: status, Proxies: proxies}, nil
}
func diagnoseDialError(err error) string {
message := err.Error()
if strings.Contains(strings.ToLower(message), "access permissions") {
return "本机网络策略阻止连接:" + message
}
return "连接失败:" + message
}
func (s *Service) DeleteBinary(version string) error {
path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
if err != nil {
return err
}
active, _ := s.manager.BinaryPath()
if filepath.Clean(path) == filepath.Clean(active) {
return fmt.Errorf("不能删除当前正在使用的 FRP 客户端")
}
return removeBinaryVersion(s.cfg.FrpDir(), version, path)
}
func (s *Service) StartDownload(version string) (DownloadTask, error) {
if !validVersion(version) {
return DownloadTask{}, fmt.Errorf("FRP 版本格式无效")
}
s.downloadMu.Lock()
defer s.downloadMu.Unlock()
if downloadInProgress(s.downloadTask.Status) {
return DownloadTask{}, fmt.Errorf("FRP %s 正在下载,请等待当前任务结束", s.downloadTask.Version)
}
s.downloadTask = DownloadTask{Version: version, Status: "queued", Message: "正在准备下载", UpdatedAt: time.Now().UTC().Format(time.RFC3339)}
go s.runDownload(version)
return s.downloadTask, nil
}
// SetBaseContext 注入应用级 ctx,供后台下载继承;由 app 启动时调用。
func (s *Service) SetBaseContext(ctx context.Context) {
s.downloadMu.Lock()
defer s.downloadMu.Unlock()
s.baseCtx = ctx
}
func (s *Service) DownloadStatus() DownloadTask {
s.downloadMu.RLock()
defer s.downloadMu.RUnlock()
return s.downloadTask
}
func (s *Service) runDownload(version string) {
s.downloadMu.RLock()
base := s.baseCtx
s.downloadMu.RUnlock()
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, 10*time.Minute)
defer cancel()
binary, err := s.downloadRelease(ctx, version, s.updateDownload)
if err != nil {
s.updateDownload("failed", "下载失败", 0, 0, err.Error())
return
}
s.updateDownload("completed", "下载、校验和解压完成", binary.Size, binary.Size, "")
}
func (s *Service) downloadRelease(ctx context.Context, version string, report downloadReporter) (Binary, error) {
report("queued", "正在获取官方版本信息", 0, 0, "")
releases, err := s.ListReleases(ctx)
if err != nil {
return Binary{}, err
}
release, ok := findRelease(releases, version)
if !ok {
return Binary{}, fmt.Errorf("未找到适用于当前系统的官方 FRP 版本")
}
proxy, err := readDownloadProxy(s.cfg.FrpDir())
if err != nil {
return Binary{}, err
}
path, err := downloadOfficialBinary(ctx, s.cfg.FrpDir(), release, proxy, report)
if err != nil {
return Binary{}, err
}
info, err := os.Stat(path)
if err != nil {
return Binary{}, err
}
return Binary{Version: release.Version, Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339)}, nil
}
func (s *Service) updateDownload(status, message string, downloaded, total int64, failure string) {
s.downloadMu.Lock()
defer s.downloadMu.Unlock()
s.downloadTask.Status = status
s.downloadTask.Message = message
if downloaded > 0 {
s.downloadTask.Downloaded = downloaded
}
if total > 0 {
s.downloadTask.Total = total
}
s.downloadTask.Error = failure
s.downloadTask.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
func downloadInProgress(status string) bool {
return status == "queued" || status == "downloading" || status == "verifying" || status == "extracting"
}
func (s *Service) SetDownloadProxy(ctx context.Context, proxy string) (RuntimeInfo, error) {
if err := writeDownloadProxy(s.cfg.FrpDir(), proxy); err != nil {
return RuntimeInfo{}, err
}
return s.Runtime(ctx), nil
}
func (s *Service) ActivateBinary(ctx context.Context, version string) (RuntimeStatus, error) {
path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
if err != nil {
return RuntimeStatus{}, err
}
old, _ := s.manager.BinaryPath()
s.setManagerBinary(path)
if err := s.verifyActiveBinary(ctx); err != nil {
s.setManagerBinary(old)
return RuntimeStatus{}, err
}
wasRunning := map[int64]bool{}
for id, manager := range s.managerSnapshot() {
wasRunning[id] = manager.Status().Status == "running"
if wasRunning[id] {
if err := manager.Stop(ctx); err != nil {
s.setManagerBinary(old)
return RuntimeStatus{}, err
}
}
}
if err := writeActiveBinary(s.cfg.FrpDir(), path); err != nil {
s.setManagerBinary(old)
return RuntimeStatus{}, err
}
for id, running := range wasRunning {
if running {
if err := s.managerForServer(id).Start(); err != nil {
return RuntimeStatus{}, err
}
}
}
s.manager.RefreshVersion(ctx)
return s.Status(ctx), nil
}
func (s *Service) verifyActiveBinary(ctx context.Context) error {
servers, err := s.store.ListServers(ctx)
if err != nil {
return err
}
verified := false
for _, server := range servers {
if !server.Enabled {
continue
}
verified = true
manager := s.managerForServer(server.ID)
if !fileExists(s.serverConfigPath(server.ID)) {
if err := manager.VerifyBinary(ctx); err != nil {
return err
}
continue
}
if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
return err
}
}
if !verified {
return s.manager.VerifyBinary(ctx)
}
return nil
}
func (s *Service) EnsureDirs() error {
return os.MkdirAll(s.cfg.FrpDir(), 0o700)
}
func (s *Service) ListServers(ctx context.Context) ([]Server, error) {
servers, err := s.store.ListServers(ctx)
if err != nil {
return nil, err
}
for index := range servers {
s.enrichTLSStatus(&servers[index])
s.enrichServerRuntime(ctx, &servers[index])
}
return servers, nil
}
func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
runtime, err := s.store.ServerRuntimeStatus(ctx, server.ID)
if err != nil {
runtime = ServerRuntimeStatus{Status: "error", DesiredState: "error", ProcessState: "error", ConnectionState: "unknown", LastError: "读取启动状态失败"}
} else {
runtime = s.observeServerRuntime(ctx, server.ID, runtime)
}
if runtime.DesiredState == "" {
runtime.DesiredState = runtime.Status
}
server.BootStatus = runtime.DesiredState
server.DesiredState = runtime.DesiredState
server.ProcessState = runtime.ProcessState
server.ConnectionState = runtime.ConnectionState
server.StateSource = runtime.StateSource
server.ProcessPID = runtime.ProcessPID
server.ExitCode = runtime.ExitCode
server.LastConnectedAt = runtime.LastConnectedAt
server.LastDisconnectedAt = runtime.LastDisconnectedAt
server.LastError = runtime.LastError
server.LastStartedAt = runtime.LastStartedAt
if state, lastError, ok := s.tunnelState(server.ID); ok {
server.AgentTransportState = state
server.AgentTransportError = lastError
}
}
func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) {
runtime := ServerRuntimeStatus{ServerID: serverID, Status: status, DesiredState: status, LastError: lastError}
if status == "starting" || status == "running" {
runtime.LastStartedAt = time.Now().UTC().Format(time.RFC3339)
}
if err := s.store.SetServerDesiredState(ctx, runtime); err != nil {
s.logger.Warn("保存服务端启动状态失败", "server_id", serverID, "error", err.Error())
}
}
func (s *Service) observeServerRuntime(ctx context.Context, serverID int64, runtime ServerRuntimeStatus) ServerRuntimeStatus {
manager := s.managerForServer(serverID)
process := manager.Status()
processState := process.Status
if processState == "stopped" && process.LastError != "" {
processState = "error"
}
connectionState, connectionError := inspectConnectionLog(s.serverLogPath(serverID), processState)
if connectionError != "" && process.LastError == "" {
process.LastError = connectionError
}
if connectionState != runtime.ConnectionState {
now := time.Now().UTC().Format(time.RFC3339)
if connectionState == "connected" {
runtime.LastConnectedAt = now
}
if connectionState == "disconnected" || connectionState == "error" {
runtime.LastDisconnectedAt = now
}
}
runtime.ProcessState = processState
runtime.ConnectionState = connectionState
runtime.StateSource = "manager+log"
runtime.ProcessPID = process.PID
runtime.LastError = process.LastError
if connectionError != "" {
runtime.LastError = connectionError
}
if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
s.logger.Warn("保存 FRP 实际状态失败", "server_id", serverID, "error", err.Error())
}
return runtime
}
func (s *Service) StartServer(ctx context.Context, id int64) (Server, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return Server{}, err
}
hasProxies, err := s.hasEnabledProxies(ctx, id)
if err != nil {
return Server{}, err
}
if !hasProxies {
// 空服务端没有 frpc 需要运行,保持 stopped,避免后续监控把它误判为异常退出。
s.setServerRuntime(ctx, id, "stopped", "")
s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server)
return server, nil
}
s.setServerRuntime(ctx, id, "running", "")
s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server)
return server, nil
}
func (s *Service) StopServer(ctx context.Context, id int64) (Server, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return Server{}, err
}
s.setServerRuntime(ctx, id, "stopped", "")
s.enrichTLSStatus(&server)
s.enrichServerRuntime(ctx, &server)
return server, nil
}
func (s *Service) ListProxies(ctx context.Context) ([]Proxy, error) {
return s.store.ListProxies(ctx)
}
// ListProxiesTraffic 采集各 frps 管理接口的代理流量并按规则 ID 聚合。
// dashboard 地址/端口存于服务端 options,密码复用 Token 列;未配置或不可达的服务端按规则记入 Errors。
func (s *Service) ListProxiesTraffic(ctx context.Context) (ProxiesTraffic, error) {
servers, err := s.store.ListServers(ctx)
if err != nil {
return ProxiesTraffic{}, err
}
proxies, err := s.store.ListProxies(ctx)
if err != nil {
return ProxiesTraffic{}, err
}
result := ProxiesTraffic{Items: map[int64]ProxyTraffic{}, Errors: map[int64]string{}, SampledAt: time.Now().UTC().Format(time.RFC3339)}
for _, proxy := range proxies {
if !proxy.Enabled {
continue
}
result.Items[proxy.ID] = ProxyTraffic{}
}
for _, server := range servers {
if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
continue
}
var password string
if server.DashboardHasPwd {
encoded, err := s.store.DashboardPassword(ctx, server.ID)
if err != nil {
continue
}
value, err := s.secretBox.Decrypt(encoded)
if err != nil {
continue
}
password = value
}
traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
if !traffic.OK {
// 旧版 frps(<v0.71)没有全量代理列表端点:按本服务端的规则名逐条查询
names := make([]string, 0, len(proxies))
for _, proxy := range proxies {
if proxy.ServerID == server.ID && proxy.Enabled {
names = append(names, proxy.Name)
}
}
traffic = s.trafficClient.fetchLegacyRouteProxies(ctx, server, password, names)
}
if !traffic.OK {
for _, proxy := range proxies {
if proxy.ServerID == server.ID && proxy.Enabled {
result.Errors[proxy.ID] = traffic.Error
}
}
continue
}
sampledAt := time.Now()
for _, proxy := range proxies {
if proxy.ServerID != server.ID || !proxy.Enabled {
continue
}
current, ok := resolveProxyTraffic(traffic, server, proxy)
if !ok {
continue
}
result.Items[proxy.ID] = s.trafficWithRate(proxy.ID, current, sampledAt)
}
}
if len(result.Errors) == 0 {
result.Errors = nil
}
return result, nil
}
// resolveProxyTraffic 兼容多种代理名形态:裸规则名、user 前缀名(frps 实际注册名,如 admin.ssssss)、域名索引。
func resolveProxyTraffic(traffic ServerTraffic, server Server, proxy Proxy) (ProxyTraffic, bool) {
if item, ok := traffic.Proxies[proxy.Name]; ok {
return item, true
}
if user := strings.TrimSpace(server.Options.User); user != "" {
if item, ok := traffic.Proxies[user+"."+proxy.Name]; ok {
return item, true
}
}
for _, domain := range proxy.CustomDomains {
if item, ok := traffic.Proxies["domain:"+strings.ToLower(strings.TrimSpace(domain))]; ok {
return item, true
}
}
return ProxyTraffic{}, false
}
// trafficWithRate 结合上次采样计算 B/s 均值速率;无上次采样、间隔过短或计数回退(如 frps 重启)时速率为 0。
func (s *Service) trafficWithRate(proxyID int64, current ProxyTraffic, at time.Time) ProxyTraffic {
s.trafficMu.Lock()
defer s.trafficMu.Unlock()
if previous, ok := s.trafficLast[proxyID]; ok {
elapsed := at.Sub(previous.at).Seconds()
if elapsed >= 2 && current.TrafficIn >= previous.in && current.TrafficOut >= previous.out {
current.TrafficInRate = int64(float64(current.TrafficIn-previous.in) / elapsed)
current.TrafficOutRate = int64(float64(current.TrafficOut-previous.out) / elapsed)
}
}
s.trafficLast[proxyID] = trafficSample{at: at, in: current.TrafficIn, out: current.TrafficOut}
return current
}
func (s *Service) CreateServer(ctx context.Context, in ServerInput) (Server, error) {
in = normalizeServerInput(in)
if err := validateServerInput(in); err != nil {
return Server{}, err
}
if err := s.validateCredentials(in, Server{}); err != nil {
return Server{}, err
}
token, err := s.encryptOptional(in.Token)
if err != nil {
return Server{}, err
}
agentToken, err := s.encryptOptional(in.AgentToken)
if err != nil {
return Server{}, err
}
sshSecret, err := s.encryptOptional(in.SSHSecret)
if err != nil {
return Server{}, err
}
server, err := s.store.CreateServer(ctx, in, token, agentToken, sshSecret)
if err != nil {
return Server{}, err
}
if err := s.saveOIDCSecret(ctx, server.ID, in); err != nil {
return Server{}, err
}
if err := s.saveDashboardPassword(ctx, server.ID, in); err != nil {
return Server{}, err
}
if err := s.saveTLSAssets(server.ID, in); err != nil {
return Server{}, err
}
server, err = s.store.GetServer(ctx, server.ID)
if err != nil {
return Server{}, err
}
return server, s.persistConfig(ctx)
}
func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (Server, error) {
in = normalizeServerInput(in)
if err := validateServerInput(in); err != nil {
return Server{}, err
}
existing, err := s.store.GetServer(ctx, id)
if err != nil {
return Server{}, err
}
// 掩码往返保护:回传掩码视为保留旧 Token,前端原样回填掩码也不会覆盖真实凭据
updateToken := (in.Token != "" && in.Token != maskedToken) || in.ClearToken
token := ""
if in.Token != "" && in.Token != maskedToken {
token, err = s.secretBox.Encrypt(in.Token)
if err != nil {
return Server{}, err
}
}
if err := s.validateCredentials(in, existing); err != nil {
return Server{}, err
}
agentTokenUpdate := (in.AgentToken != "" && in.AgentToken != maskedToken) || in.ClearAgentToken
agentToken := ""
if in.AgentToken != "" && in.AgentToken != maskedToken {
agentToken, err = s.secretBox.Encrypt(in.AgentToken)
if err != nil {
return Server{}, err
}
}
sshSecretUpdate := (in.SSHSecret != "" && in.SSHSecret != maskedToken) || in.ClearSSHSecret
sshSecret := ""
if in.SSHSecret != "" && in.SSHSecret != maskedToken {
sshSecret, err = s.secretBox.Encrypt(in.SSHSecret)
if err != nil {
return Server{}, err
}
}
server, err := s.store.UpdateServer(ctx, id, in, token, updateToken, agentToken, agentTokenUpdate, sshSecret, sshSecretUpdate)
if err != nil {
return Server{}, err
}
if err := s.saveOIDCSecret(ctx, id, in); err != nil {
return Server{}, err
}
if err := s.saveDashboardPassword(ctx, id, in); err != nil {
return Server{}, err
}
if err := s.saveTLSAssets(id, in); err != nil {
return Server{}, err
}
server, err = s.store.GetServer(ctx, id)
if err != nil {
return Server{}, err
}
if !server.Enabled {
_ = s.managerForServer(id).Stop(ctx)
}
sshChanged := sshSettingsChanged(existing, server, sshSecretUpdate)
if sshChanged {
// SSH 目标或凭据变化后必须废弃旧隧道,避免新服务端继续复用旧 VPS 的连接。
s.stopAgentTunnel(id)
}
if sshChanged || agentTokenUpdate || existing.AgentURL != server.AgentURL {
s.invalidateAgentClient(id)
}
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
s.stopAgentTunnel(id)
}
return server, s.persistConfig(ctx)
}
func (s *Service) DeleteServer(ctx context.Context, id int64) error {
s.stopAgentTunnel(id)
if err := s.managerForServer(id).Stop(ctx); err != nil {
return err
}
if err := s.store.DeleteServer(ctx, id); err != nil {
return err
}
_ = os.Remove(s.serverConfigPath(id))
_ = os.Remove(s.serverLogPath(id))
_ = os.Remove(s.cfg.SSHKnownHostsPathForServer(id))
s.removeManager(id)
return s.persistConfig(ctx)
}
func (s *Service) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
in = normalizeProxyInput(in)
if err := validateProxyInput(in); err != nil {
return Proxy{}, err
}
if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
return Proxy{}, err
}
proxy, err := s.store.CreateProxy(ctx, in)
if err != nil {
return Proxy{}, err
}
if err := s.persistConfig(ctx); err != nil {
return Proxy{}, err
}
// 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
return proxy, nil
}
func (s *Service) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
in = normalizeProxyInput(in)
if err := validateProxyInput(in); err != nil {
return Proxy{}, err
}
if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
return Proxy{}, err
}
proxy, err := s.store.UpdateProxy(ctx, id, in)
if err != nil {
return Proxy{}, err
}
if err := s.persistConfig(ctx); err != nil {
return Proxy{}, err
}
// 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
return proxy, nil
}
func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSHInstaller, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, SSHInstaller{}, err
}
if server.SSHHost == "" || server.SSHUser == "" {
return Server{}, SSHInstaller{}, fmt.Errorf("请先在服务端表单填写 SSH 地址与用户")
}
secret := ""
if server.SSHConfigured {
encoded, err := s.store.SSHSecret(ctx, serverID)
if err != nil || encoded == "" {
return Server{}, SSHInstaller{}, fmt.Errorf("SSH 凭据未保存")
}
secret, err = s.secretBox.Decrypt(encoded)
if err != nil {
return Server{}, SSHInstaller{}, err
}
}
installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser, KnownHostsPath: s.cfg.SSHKnownHostsPathForServer(serverID)}
if server.SSHAuth == "password" {
installer.Password = secret
} else {
installer.PrivateKey = secret
}
return server, installer, nil
}
func (s *Service) AgentProbe(ctx context.Context, serverID int64) (map[string]any, error) {
_, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return nil, err
}
return AgentInstaller{SSH: installer}.Probe(ctx)
}
func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, error) {
return s.agentInstall(ctx, serverID, false)
}
// AgentInstallTrustHostKey 在用户确认 VPS 已重装系统后,先更新该服务端的 SSH 主机密钥记录,再安装 Agent。
func (s *Service) AgentInstallTrustHostKey(ctx context.Context, serverID int64) (Server, error) {
return s.agentInstall(ctx, serverID, true)
}
func (s *Service) agentInstall(ctx context.Context, serverID int64, trustHostKey bool) (Server, error) {
if trustHostKey {
if err := s.resetAgentSSHKnownHost(ctx, serverID); err != nil {
return Server{}, err
}
}
server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return Server{}, err
}
token, err := AgentInstaller{SSH: installer}.Install(ctx)
if err != nil {
return Server{}, err
}
agentToken, err := s.secretBox.Encrypt(token)
if err != nil {
return Server{}, err
}
// 已是 HTTPS 直连时优先保持原传输方式;服务器重置或证书丢失时自动回退隧道,
// 避免 SSH 安装已经成功,却因为旧证书指纹失效而把整个升级判定为失败。
if server.AgentTransport == "https-pin" {
updated, keepErr := s.keepAgentHTTPSAfterInstall(ctx, server, token, agentToken)
if keepErr == nil {
return updated, nil
}
s.logger.Warn("HTTPS 直连验证失败,自动回退 SSH 隧道",
"server_id", serverID,
"error", keepErr.Error(),
)
}
localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if err != nil {
return Server{}, err
}
tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
if err := tunnel.Start(); err != nil {
_ = listener.Close()
return Server{}, err
}
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
client := NewAgentClient(baseURL, token)
if err := client.Ping(ctx); err != nil {
tunnel.Stop()
rollbackErr := (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
if rollbackErr != nil {
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败: %w;恢复旧监听配置也失败: %v", err, rollbackErr)
}
return Server{}, fmt.Errorf("Agent 已安装,但 SSH 隧道验证失败,已恢复旧监听配置: %w", err)
}
input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
Options: server.Options, AgentURL: baseURL,
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true,
}
updated, err := s.store.UpdateServer(ctx, serverID, input, "", false, agentToken, true, "", false)
if err != nil {
return Server{}, err
}
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
existing.Stop()
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
_ = (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
return Server{}, err
}
// 旧版升级流程可能残留 havlineagent-https.conf,这里通过隧道顺手关闭,避免 7443 继续暴露。
_ = client.DisableHTTPS(ctx)
s.invalidateAgentClient(serverID)
return updated, nil
}
func (s *Service) resetAgentSSHKnownHost(ctx context.Context, serverID int64) error {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return err
}
if strings.TrimSpace(server.SSHHost) == "" || strings.TrimSpace(server.SSHUser) == "" {
return fmt.Errorf("请先填写 SSH 地址与用户")
}
s.stopAgentTunnel(serverID)
s.invalidateAgentClient(serverID)
path := s.cfg.SSHKnownHostsPathForServer(serverID)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("重置 SSH 主机密钥记录失败: %w", err)
}
return nil
}
// keepAgentHTTPSAfterInstall 在 HTTPS 直连模式下完成 Agent 升级:
// 保持 agent_url、pin 与 transport 不变,只更新 Token 密文并重新验证连通。
func (s *Service) keepAgentHTTPSAfterInstall(ctx context.Context, server Server, token, agentToken string) (Server, error) {
baseURL := agentHTTPSBaseURL(server)
pins := agentTransportPins(server)
if len(pins) == 0 {
return Server{}, fmt.Errorf("Agent 已升级,但 HTTPS 直连缺少证书指纹;请重新切换 HTTPS 直连")
}
client := NewAgentClientWithPins(baseURL, token, pins...)
if err := client.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("Agent 已升级,但 HTTPS 直连验证失败: %w", err)
}
input := ServerInput{
Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
Options: server.Options, AgentURL: baseURL,
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
SSHAuth: server.SSHAuth, MgmtEnabled: true,
}
if _, err := s.store.UpdateServer(ctx, server.ID, input, "", false, agentToken, true, "", false); err != nil {
return Server{}, err
}
listenAddr := strings.TrimSpace(server.AgentListenAddr)
if listenAddr == "" {
listenAddr = fmt.Sprintf("0.0.0.0:%d", listenPort("", 7443))
}
if err := s.store.SetAgentTransport(ctx, server.ID, baseURL, "https-pin", server.AgentLocalPort, server.AgentTLSPin, listenAddr, "connected", ""); err != nil {
return Server{}, err
}
s.invalidateAgentClient(server.ID)
return s.store.GetServer(ctx, server.ID)
}
func (s *Service) tunnelContext(fallback context.Context) context.Context {
s.downloadMu.RLock()
base := s.baseCtx
s.downloadMu.RUnlock()
if base != nil {
return base
}
if fallback != nil {
return fallback
}
return context.Background()
}
func (s *Service) reserveAgentTunnelPort(ctx context.Context, ownerID int64, preferred int) (int, net.Listener, error) {
s.tunnelMu.Lock()
defer s.tunnelMu.Unlock()
servers, err := s.store.ListServers(ctx)
if err != nil {
return 0, nil, err
}
used := map[int]bool{}
for _, server := range servers {
if server.ID != ownerID && server.AgentLocalPort > 0 {
used[server.AgentLocalPort] = true
}
}
if preferred > 0 && !used[preferred] {
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", preferred)); err == nil {
return preferred, listener, nil
}
}
for port := 17700; port <= 18700; port++ {
if used[port] {
continue
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
return port, listener, nil
}
return 0, nil, fmt.Errorf("SSH 隧道本地端口 17700-18700 已耗尽")
}
func (s *Service) ensureAgentTunnel(ctx context.Context, serverID int64) (*agentTunnel, error) {
s.tunnelMu.Lock()
if tunnel := s.tunnels[serverID]; tunnel != nil {
s.tunnelMu.Unlock()
return tunnel, nil
}
s.tunnelMu.Unlock()
server, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return nil, err
}
localPort, listener, err := s.reserveAgentTunnelPort(ctx, serverID, server.AgentLocalPort)
if err != nil {
return nil, err
}
tunnel := newAgentTunnelWithListener(s.tunnelContext(ctx), serverID, localPort, installer, listener)
if err := tunnel.Start(); err != nil {
_ = listener.Close()
return nil, err
}
s.tunnelMu.Lock()
if existing := s.tunnels[serverID]; existing != nil {
s.tunnelMu.Unlock()
tunnel.Stop()
return existing, nil
}
s.tunnels[serverID] = tunnel
s.tunnelMu.Unlock()
baseURL := fmt.Sprintf("http://127.0.0.1:%d", localPort)
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
tunnel.Stop()
return nil, err
}
s.invalidateAgentClient(serverID)
return tunnel, nil
}
// StartAgentTunnels 在应用启动后恢复所有 tunnel transport。
func (s *Service) StartAgentTunnels(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("恢复 Agent 隧道失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "tunnel" {
continue
}
if _, err := s.ensureAgentTunnel(ctx, server.ID); err != nil {
s.logger.Warn("Agent 隧道启动失败", "server_id", server.ID, "error", err.Error())
continue
}
s.cleanupLegacyAgentHTTPS(ctx, server.ID)
}
}
// cleanupLegacyAgentHTTPS 清理隧道模式下可能残留的 havlineagent-https.conf,避免 7443 继续暴露。
func (s *Service) cleanupLegacyAgentHTTPS(ctx context.Context, serverID int64) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return
}
_ = client.DisableHTTPS(ctx)
}
func (s *Service) stopAgentTunnels() {
s.tunnelMu.Lock()
tunnels := make([]*agentTunnel, 0, len(s.tunnels))
for id, tunnel := range s.tunnels {
tunnels = append(tunnels, tunnel)
delete(s.tunnels, id)
}
s.tunnelMu.Unlock()
for _, tunnel := range tunnels {
tunnel.Stop()
}
}
func (s *Service) stopAgentTunnel(serverID int64) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
delete(s.tunnels, serverID)
s.tunnelMu.Unlock()
if tunnel != nil {
tunnel.Stop()
}
}
func (s *Service) tunnelState(serverID int64) (string, string, bool) {
s.tunnelMu.Lock()
tunnel := s.tunnels[serverID]
s.tunnelMu.Unlock()
if tunnel == nil {
return "", "", false
}
state, lastError := tunnel.State()
return state, lastError, true
}
func (s *Service) RestartAgentTunnel(ctx context.Context, serverID int64) (Server, error) {
current, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.stopAgentTunnel(serverID)
if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
_ = s.store.SetAgentTransport(ctx, serverID, current.AgentURL, "tunnel", current.AgentLocalPort, current.AgentTLSPin, current.AgentListenAddr, "failed", err.Error())
return Server{}, err
}
s.cleanupLegacyAgentHTTPS(ctx, serverID)
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &server)
return server, nil
}
// AgentTransportStatus 返回传输方式的详细状态,供前端与诊断使用。
func (s *Service) AgentTransportStatus(ctx context.Context, serverID int64) (map[string]any, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return nil, err
}
state := server.AgentTransportState
lastError := server.AgentTransportError
if server.AgentTransport == "tunnel" {
if tunnelState, tunnelError, ok := s.tunnelState(serverID); ok {
state, lastError = tunnelState, tunnelError
}
}
return map[string]any{
"transport": server.AgentTransport,
"state": state,
"agent_url": server.AgentURL,
"local_port": server.AgentLocalPort,
"listen_addr": server.AgentListenAddr,
"tls_pin": server.AgentTLSPin,
"tls_not_after": server.AgentTLSNotAfter,
"pin_prev_until": server.AgentTLSPinPrevUntil,
"firewall_state": server.AgentFirewallState,
"last_error": lastError,
}, nil
}
// AgentFirewallStatus 检测 VPS 主机防火墙,并把结果缓存到服务端记录供 UI 展示。
func (s *Service) AgentFirewallStatus(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
status, err := client.FirewallStatus(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(status))
return status, nil
}
// AllowAgentFirewallPort 由用户显式触发,Agent 仅对 ufw / firewalld 执行放行。
func (s *Service) AllowAgentFirewallPort(ctx context.Context, serverID int64, port int) (map[string]any, error) {
if port <= 0 || port > 65535 {
return nil, fmt.Errorf("端口无效")
}
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("当前 Agent 连接不可用")
}
result, err := client.AllowFirewallPort(ctx, port)
if err != nil {
return nil, err
}
_ = s.store.SetAgentFirewallState(ctx, serverID, firewallSummary(result))
return result, nil
}
func firewallSummary(result map[string]any) string {
status := result
if nested, ok := result["status"].(map[string]any); ok {
status = nested
}
tool, _ := status["tool"].(string)
open, _ := status["port_open"].(bool)
if tool == "" {
return ""
}
if open {
return tool + ":已放行"
}
return tool + ":未放行"
}
func (s *Service) agentToken(ctx context.Context, serverID int64) (string, error) {
encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" {
return "", fmt.Errorf("Agent Token 未保存")
}
token, err := s.secretBox.Decrypt(encoded)
if err != nil || token == "" {
return "", fmt.Errorf("Agent Token 无法解密")
}
return token, nil
}
func (s *Service) EnableAgentHTTPS(ctx context.Context, serverID int64, port int) (Server, error) {
if port < 0 || port > 65535 {
return Server{}, fmt.Errorf("HTTPS 端口无效")
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "http" {
return Server{}, fmt.Errorf("请先通过「升级 Agent」迁移到 SSH 隧道,再切换 HTTPS 直连")
}
currentClient, ok := s.agentClient(ctx, serverID)
if !ok {
return Server{}, fmt.Errorf("当前 Agent 连接不可用")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
result, err := currentClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
actualPort := resultPort(result, port)
if actualPort <= 0 {
return Server{}, fmt.Errorf("Agent 未返回可用的 HTTPS 端口")
}
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
pinned := NewAgentClientWithPin(baseURL, token, cert.Pin)
if err := pinned.Ping(ctx); err != nil {
_ = currentClient.DisableHTTPS(ctx)
if result["self_check"] == "ok" {
return Server{}, fmt.Errorf("HTTPS 直连验证失败:Agent 本机 %d 端口自检通过,但公网 %s 返回的证书不一致,请检查该端口是否被其他服务、端口转发或防火墙接管: %w", actualPort, baseURL, err)
}
return Server{}, fmt.Errorf("HTTPS 直连验证失败: %w", err)
}
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, "", "", cert.NotAfter.UTC().Format(time.RFC3339)); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
s.stopAgentTunnel(serverID)
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
return updated, nil
}
const (
agentCertRenewBefore = 30 * 24 * time.Hour
agentCertPinGrace = 7 * 24 * time.Hour
)
// StartAgentCertificateRenewal 后台巡检 HTTPS 直连证书,剩余不足 30 天时自动轮换。
func (s *Service) StartAgentCertificateRenewal(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
s.renewAgentCertificates(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.renewAgentCertificates(ctx)
}
}
}
func (s *Service) renewAgentCertificates(ctx context.Context) {
servers, err := s.store.ListServers(ctx)
if err != nil {
s.logger.Warn("巡检 Agent 证书失败", "error", err.Error())
return
}
for _, server := range servers {
if !server.MgmtEnabled || server.AgentTransport != "https-pin" {
continue
}
notAfter, ok := s.agentCertificateExpiry(ctx, server)
if !ok || time.Until(notAfter) > agentCertRenewBefore {
continue
}
if _, err := s.RotateAgentCertificate(ctx, server.ID); err != nil {
s.logger.Warn("Agent 证书自动轮换失败", "server_id", server.ID, "error", err.Error())
}
}
}
// agentCertificateExpiry 优先使用数据库缓存;旧记录缺少到期时间时从 Agent 补齐。
func (s *Service) agentCertificateExpiry(ctx context.Context, server Server) (time.Time, bool) {
if raw := strings.TrimSpace(server.AgentTLSNotAfter); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed, true
}
}
client, ok := s.agentClient(ctx, server.ID)
if !ok {
return time.Time{}, false
}
state, err := client.HTTPSState(ctx)
if err != nil {
return time.Time{}, false
}
raw, _ := state["not_after"].(string)
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, false
}
_ = s.store.SetAgentTLSNotAfter(ctx, server.ID, parsed.UTC().Format(time.RFC3339))
return parsed, true
}
// RotateAgentCertificate 安装新证书;过渡期内客户端同时接受新 pin 与旧 pin。
func (s *Service) RotateAgentCertificate(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport != "https-pin" {
return Server{}, fmt.Errorf("仅 HTTPS 直连模式支持证书轮换")
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
cert, err := generateAgentCertificate(server.SSHHost)
if err != nil {
return Server{}, err
}
oldPin := strings.TrimSpace(server.AgentTLSPin)
port := listenPort(server.AgentListenAddr, 7443)
configureClient := NewAgentClientWithPins(agentHTTPSBaseURL(server), token, cert.Pin, oldPin)
result, err := configureClient.ConfigureHTTPS(ctx, cert.CertPEM, cert.KeyPEM, port)
if err != nil {
return Server{}, err
}
actualPort := resultPort(result, port)
baseURL := fmt.Sprintf("https://%s:%d", server.SSHHost, actualPort)
graceUntil := time.Now().Add(agentCertPinGrace).UTC().Format(time.RFC3339)
notAfter := cert.NotAfter.UTC().Format(time.RFC3339)
if err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort, cert.Pin, fmt.Sprintf("0.0.0.0:%d", actualPort), "connected", ""); err != nil {
return Server{}, err
}
if err := s.store.SetAgentTLSPinRotation(ctx, serverID, cert.Pin, oldPin, graceUntil, notAfter); err != nil {
return Server{}, err
}
s.invalidateAgentClient(serverID)
dual := NewAgentClientWithPins(baseURL, token, cert.Pin, oldPin)
if err := dual.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("证书轮换后公网校验失败(旧 pin 在过渡期仍可用): %w", err)
}
return s.store.GetServer(ctx, serverID)
}
func resultPort(result map[string]any, fallback int) int {
if raw, ok := result["port"].(float64); ok && raw > 0 && raw <= 65535 {
return int(raw)
}
return fallback
}
func listenPort(listenAddr string, fallback int) int {
if _, rawPort, err := net.SplitHostPort(strings.TrimSpace(listenAddr)); err == nil {
if parsed, err := strconv.Atoi(rawPort); err == nil && parsed > 0 && parsed <= 65535 {
return parsed
}
}
return fallback
}
func (s *Service) SwitchAgentToTunnel(ctx context.Context, serverID int64) (Server, error) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
if server.AgentTransport == "https-pin" {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
return Server{}, fmt.Errorf("建立 SSH 隧道失败: %w", err)
}
token, err := s.agentToken(ctx, serverID)
if err != nil {
return Server{}, err
}
// 切换时不再依赖旧 HTTPS 直连;先通过隧道验证 Agent,再清理旧 HTTPS 配置。
client := NewAgentClient(fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort), token)
if err := client.Ping(ctx); err != nil {
return Server{}, fmt.Errorf("SSH 隧道已建立,但 Agent 验证失败: %w", err)
}
if err := client.DisableHTTPS(ctx); err != nil {
s.logger.Warn("关闭旧 HTTPS 直连失败,已保留 SSH 隧道",
"server_id", serverID,
"error", err.Error(),
)
}
} else if _, err := s.ensureAgentTunnel(ctx, serverID); err != nil {
return Server{}, err
}
updated, err := s.store.GetServer(ctx, serverID)
if err != nil {
return Server{}, err
}
s.enrichServerRuntime(ctx, &updated)
return updated, nil
}
func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData bool) (string, error) {
_, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return "", err
}
output, err := AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
if err == nil {
s.stopAgentTunnel(serverID)
_ = s.store.SetAgentTransport(ctx, serverID, "", "tunnel", 0, "", "", "stopped", "")
}
return output, err
}
func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) {
_, installer, err := s.sshInstaller(ctx, serverID)
if err != nil {
return "", err
}
return AgentInstaller{SSH: installer}.Diagnose(ctx)
}
func (s *Service) AgentTest(ctx context.Context, serverID int64) error {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.Ping(ctx)
}
// AgentClient 导出指定服务端的 agent 客户端(供 handler 直取明细端点);不可用返回 false。
func (s *Service) AgentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
return s.agentClient(ctx, serverID)
}
func (s *Service) AgentStatus(ctx context.Context, serverID int64) (map[string]any, error) {
statusCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
server, err := s.store.GetServer(statusCtx, serverID)
if err != nil {
return nil, err
}
if !server.AgentConfigured {
return map[string]any{
"configured": false,
"mgmt_enabled": false,
"available": false,
"message": "公网 agent 未配置或 Token 未保存",
}, nil
}
if !server.MgmtEnabled {
return map[string]any{
"configured": true,
"mgmt_enabled": false,
"available": false,
"message": "Agent 已配置,但未启用日常管理",
}, nil
}
if server.AgentTransport == "tunnel" {
if state, lastError, ok := s.tunnelState(serverID); ok && (state == "failed" || state == "reconnecting") {
if strings.TrimSpace(lastError) == "" {
lastError = state
}
return nil, fmt.Errorf("SSH 隧道未连接:%s", lastError)
}
}
client, ok := s.agentClient(statusCtx, serverID)
if !ok {
return map[string]any{
"configured": false,
"mgmt_enabled": false,
"available": false,
"message": "公网 agent 未配置或 Token 未保存",
}, nil
}
status, err := client.Status(statusCtx)
if err != nil {
if statusErr, ok := err.(*statusError); ok && statusErr.code == 401 {
return map[string]any{
"configured": true,
"mgmt_enabled": true,
"available": false,
"token_mismatch": true,
"message": "Agent Token 不匹配,请点击「升级 Agent」重新同步",
}, nil
}
return nil, err
}
if status == nil {
status = map[string]any{}
}
status["configured"] = true
status["mgmt_enabled"] = true
return status, nil
}
func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, version, proxy string) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
result, err := client.InstallFRPS(ctx, version, proxy)
if err != nil {
return nil, err
}
// 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置。
// 失败不能吞掉:否则前端只看到「安装成功」,VPS 上跑的仍是旧配置。
warning := ""
if server, err := s.store.GetServer(ctx, serverID); err != nil {
warning = "frps 已安装,但读取服务端配置失败:" + err.Error()
} else if err := s.syncAgent404Page(ctx, client, server); err != nil {
warning = "frps 已安装,但自定义 404 页面下发失败:" + err.Error()
} else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil {
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
} else if err := client.PushFRPSConfig(ctx, content); err != nil {
warning = "frps 已安装,但 frps.toml 下发失败:" + err.Error()
}
if warning != "" {
result["config_warning"] = warning
if list, ok := result["notes"].([]any); ok {
result["notes"] = append(list, warning)
}
}
return result, nil
}
func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.FRPSAction(ctx, action)
}
func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) error {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return err
}
// 先把「自定义 404 页面内容」落到 VPS 上:frps.toml 里写的是文件路径,文件必须先存在
if err := s.syncAgent404Page(ctx, client, server); err != nil {
return err
}
// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
content, err := s.buildFRPSServerConfig(ctx, server)
if err != nil {
return err
}
return client.PushFRPSConfig(ctx, content)
}
// syncAgent404Page 把界面里填写的 404 页面内容写到 VPS 的固定路径;内容清空时删掉旧文件。
func (s *Service) syncAgent404Page(ctx context.Context, client *AgentClient, server Server) error {
html := strings.TrimSpace(server.Options.Custom404HTML)
if html == "" {
// 没填内容就清掉 Havline 托管的那份,避免残留旧页面
return client.DeleteFRPS404Page(ctx, FixedAgent404PagePath)
}
if err := client.PushFRPS404Page(ctx, FixedAgent404PagePath, html); err != nil {
return fmt.Errorf("自定义 404 页面下发失败:%w", err)
}
return nil
}
// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
func errPrivilegedVhostNoPublicProxy() error {
return fmt.Errorf("该服务端的 frps 回源端口为 80/443:VPS 上由 frps 直接对外,公网反代(Nginx)不适用也无需部署;如需公网反代,请到「公网服务端」页把回源端口改回 8080/8443")
}
// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
server, err := s.store.GetServer(ctx, proxy.ServerID)
if err != nil {
return "", err
}
httpPort := server.Options.VhostHTTPPort
if httpPort <= 0 {
httpPort = 8080
}
httpsPort := server.Options.VhostHTTPSPort
if httpsPort <= 0 {
httpsPort = 8443
}
if proxy.Type == "https" {
return fmt.Sprintf("127.0.0.1:%d", httpsPort), nil
}
return fmt.Sprintf("127.0.0.1:%d", httpPort), nil
}
// InstallAgentNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
func (s *Service) InstallAgentNginx(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("该服务端未配置公网 agent(缺少地址或令牌)")
}
return client.InstallNginx(ctx)
}
// RouteHealthSummary 公网反代部署健康的汇总(仪表盘用)。
// 健康判定与规则页一致:四段(DNS/证书/隧道/内网服务)全通过才算健康;
// 一个域名可同时命中多段失败,因此各段计数之和可能大于 Routes-Healthy。
type RouteHealthSummary struct {
Servers int `json:"servers"`
Routes int `json:"routes"`
Healthy int `json:"healthy"`
DNSFailed int `json:"dns_failed"`
CertMissing int `json:"cert_missing"`
TunnelDown int `json:"tunnel_down"`
// TunnelUnknown 是「frps 管理接口不可用、隧道状态判不出来」的条数
TunnelUnknown int `json:"tunnel_unknown"`
ServiceDown int `json:"service_down"`
FailedServers []string `json:"failed_servers,omitempty"`
}
// SummarizeRouteHealth 逐台已启用服务端采集公网反代健康后汇总。
// 单台服务端采集失败只记名并继续其它服务端,不阻断整轮汇总。
func (s *Service) SummarizeRouteHealth(ctx context.Context) (RouteHealthSummary, error) {
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return RouteHealthSummary{}, err
}
var (
items []RouteHealthCandidate
failed []string
)
for _, server := range servers {
candidates, err := s.RouteHealth(ctx, server.ID)
if err != nil {
failed = append(failed, server.Name)
continue
}
items = append(items, candidates...)
}
return summarizeRouteHealth(len(servers), failed, items), nil
}
func summarizeRouteHealth(servers int, failedServers []string, items []RouteHealthCandidate) RouteHealthSummary {
summary := RouteHealthSummary{Servers: servers, Routes: len(items), FailedServers: failedServers}
for _, item := range items {
if !item.DNSOK {
summary.DNSFailed++
}
if !item.CertOK {
summary.CertMissing++
}
switch {
case !item.TunnelKnown:
// frps 管理接口不可用时隧道状态判不出来,不能计入「不通」
summary.TunnelUnknown++
case !item.TunnelOK:
summary.TunnelDown++
}
if !item.ServiceOK {
summary.ServiceDown++
}
if item.DNSOK && item.CertOK && item.TunnelOK && item.ServiceOK {
summary.Healthy++
}
}
return summary
}
// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
type RouteHealthCandidate struct {
ProxyID int64 `json:"proxy_id"`
Domain string `json:"domain"`
DNSOK bool `json:"dns_ok"`
DNSDetail string `json:"dns_detail,omitempty"`
CertOK bool `json:"cert_ok"`
CertDetail string `json:"cert_detail,omitempty"`
TunnelOK bool `json:"tunnel_ok"`
// TunnelKnown 表示「隧道状态是判得出来的」:frps 管理接口没配或连不上时 TunnelOK 恒为 false,
// 但那是「无法判定」而不是「不通」——告警与汇总都必须按它区分,否则会误报。
TunnelKnown bool `json:"tunnel_known"`
TunnelName string `json:"tunnel_name,omitempty"`
TunnelDetail string `json:"tunnel_detail,omitempty"`
ServiceOK bool `json:"service_ok"`
}
// RouteHealth 批量返回公网反代规则的四段健康状态:
// DNS 解析是否指向服务端地址、本地证书覆盖情况、frps 隧道注册状态、内网服务探活。
func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealthCandidate, error) {
candidates, err := s.ListAgentRouteCandidates(ctx, serverID)
if err != nil {
return nil, err
}
server, err := s.store.GetServer(ctx, serverID)
if err != nil {
return nil, err
}
// 隧道注册名集合:frps dashboard(webServer)可用时取其代理名列表;
// 调用细节(URL/认证/具体错误)记录到 dashboardDiag,失败时透出给前端。
tunnelNames := map[string]bool{}
// 由 dashboard 返回的真实代理名(例如 admin.ssssss),按域名映射用于展示。
tunnelNameByDomain := map[string]string{}
dashboardReady := false
dashboardDiag := ""
if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
dashboardDiag = "frps 管理接口未配置(服务端表单缺少地址或端口)"
} else {
encoded, perr := s.store.DashboardPassword(ctx, serverID)
if perr != nil {
dashboardDiag = fmt.Sprintf("读取 dashboard 密码失败: %v", perr)
} else if encoded == "" {
dashboardDiag = "dashboard 密码未保存(服务端表单留空),frps 已开启认证时无法访问管理接口"
} else {
password, derr := s.secretBox.Decrypt(encoded)
if derr != nil {
dashboardDiag = fmt.Sprintf("dashboard 密码解密失败: %v", derr)
} else {
traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
if traffic.OK {
dashboardReady = true
for name := range traffic.Proxies {
tunnelNames[name] = true
}
for domain, name := range traffic.ProxyNamesByDomain {
tunnelNameByDomain[domain] = name
}
} else {
// frp 0.68.0 没有全量代理列表,只支持 /api/proxies/{实际代理名};
// 用本地候选规则逐条查询,兼容 user.proxyName(例如 admin.ssssss)。
names := make([]string, 0, len(candidates))
for _, candidate := range candidates {
names = append(names, candidate.Name)
}
legacyTraffic := s.trafficClient.fetchLegacyRouteProxies(ctx, server, password, names)
if legacyTraffic.OK {
dashboardReady = true
for name := range legacyTraffic.Proxies {
if !strings.HasPrefix(name, "domain:") {
tunnelNames[name] = true
}
}
for domain, name := range legacyTraffic.ProxyNamesByDomain {
tunnelNameByDomain[domain] = name
}
} else {
dashboardDiag = fmt.Sprintf("frps 管理接口调用失败: %s(旧版逐规则查询失败: %s;Basic Auth 用户 %q)", traffic.Error, legacyTraffic.Error, server.Options.DashboardUser)
}
}
}
}
}
// 本地证书覆盖:域名精确或通配符匹配
certRecords, cerr := s.certStore.List(ctx)
if cerr != nil {
certRecords = nil
}
certFor := func(domain string) (*certificate.Record, bool) {
for i := range certRecords {
rec := &certRecords[i]
if rec.Domain == domain {
return rec, true
}
for _, d := range rec.Domains {
if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
return rec, true
}
}
}
return nil, false
}
resolver := &net.Resolver{}
out := make([]RouteHealthCandidate, 0, len(candidates))
for _, proxy := range candidates {
for _, domain := range proxy.CustomDomains {
item := RouteHealthCandidate{ProxyID: proxy.ID, Domain: domain}
// ① DNS:解析域名,与 server_addr(IP 或域名解析后)比对
ips, derr := resolver.LookupIPAddr(ctx, domain)
if derr != nil || len(ips) == 0 {
item.DNSDetail = "DNS 解析失败"
} else {
serverIPs, serr := resolver.LookupIPAddr(ctx, server.ServerAddr)
if serr != nil || len(serverIPs) == 0 {
item.DNSDetail = "服务端地址无法解析"
} else {
matched := false
for _, a := range ips {
for _, b := range serverIPs {
if a.IP.Equal(b.IP) {
matched = true
}
}
}
item.DNSOK = matched
if !matched {
item.DNSDetail = fmt.Sprintf("当前解析 %s;服务端地址 %s", formatIPAddrs(ips), formatIPAddrs(serverIPs))
}
}
}
// ② 证书:本地库覆盖 + 有效期
if rec, ok := certFor(domain); ok {
if rec.ExpiresAt != nil {
days := int(time.Until(*rec.ExpiresAt).Hours() / 24)
item.CertOK = days > 0
item.CertDetail = fmt.Sprintf("%d 天后到期", days)
} else {
item.CertOK = true
item.CertDetail = "有效期未知"
}
} else {
item.CertDetail = "本地无证书"
}
// ③ 隧道:frps dashboard 的代理名与规则名匹配。frpc 配置了 user 时,
// frp 会自动给代理名加 "<user>." 前缀(如 admin.ssssss),两种形态都接受。
item.TunnelName = proxy.Name
if dashboardReady {
// dashboard 可用:这一次的隧道状态是「判得出来」的,不管结论是通还是不通
item.TunnelKnown = true
// 优先按域名索引匹配(V2 API 返回 spec.http.customDomains,代理名含 user 前缀不可靠),
// 再回退代理名(裸名或 user.前缀名)
switch {
case tunnelNames["domain:"+domain]:
item.TunnelOK = true
item.TunnelName = tunnelNameByDomain[strings.ToLower(domain)]
if item.TunnelName == "" {
item.TunnelName = proxy.Name
}
case tunnelNames[proxy.Name]:
item.TunnelOK = true
item.TunnelName = proxy.Name
case func() bool {
u := strings.TrimSpace(server.Options.User)
return u != "" && tunnelNames[u+"."+proxy.Name]
}():
item.TunnelOK = true
item.TunnelName = strings.TrimSpace(server.Options.User) + "." + proxy.Name
}
if item.TunnelOK {
item.TunnelDetail = "frps 已注册: " + item.TunnelName
} else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
names := make([]string, 0, len(tunnelNames))
for name := range tunnelNames {
name = strings.TrimSpace(name)
if name != "" && !strings.HasPrefix(name, "domain:") {
names = append(names, name)
}
}
sort.Strings(names)
item.TunnelDetail = tunnelMismatchDetail(names, domain)
}
} else {
item.TunnelDetail = dashboardDiag
}
// ④ 内网服务探活:Havline 与服务同在内网,直接 TCP 拨测
conn, serr2 := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", proxy.LocalIP, proxy.LocalPort), 2*time.Second)
if serr2 == nil {
_ = conn.Close()
item.ServiceOK = true
}
out = append(out, item)
}
}
return out, nil
}
func formatIPAddrs(addrs []net.IPAddr) string {
if len(addrs) == 0 {
return "无"
}
values := make([]string, 0, len(addrs))
for _, addr := range addrs {
values = append(values, addr.IP.String())
}
return strings.Join(values, ", ")
}
// tunnelMismatchDetail 区分「frps 一个代理都没有」和「有代理但没绑定当前域名」,
// 避免空列表被拼成 “frps 已注册代理: ;” 这种误导性提示。
func tunnelMismatchDetail(names []string, domain string) string {
if len(names) == 0 {
return fmt.Sprintf("frps 当前没有注册任何代理;请确认 frpc 已启动并成功登录,域名 %s 尚未绑定", domain)
}
return fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
}
// GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。
func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
return s.store.GetProxy(ctx, id)
}
// ListAgentRouteCandidates 返回指定服务端可手动部署到公网 Nginx 的 HTTP/HTTPS 规则。
func (s *Service) ListAgentRouteCandidates(ctx context.Context, serverID int64) ([]Proxy, error) {
proxies, err := s.store.ListProxiesByServer(ctx, serverID)
if err != nil {
return nil, err
}
candidates := make([]Proxy, 0, len(proxies))
for _, proxy := range proxies {
if proxy.Enabled && (proxy.Type == "http" || proxy.Type == "https") && len(proxy.CustomDomains) > 0 {
candidates = append(candidates, proxy)
}
}
return candidates, nil
}
// DeployAgentRoute 手动把单条穿透规则部署为公网 Nginx 反代。
func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
proxy, err := s.store.GetProxy(ctx, proxyID)
if err != nil {
return err
}
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
}
server, serr := s.store.GetServer(ctx, proxy.ServerID)
if serr != nil {
return serr
}
// 80/443 回源端口的服务端由 frps 直接对外,VPS 上没有 Nginx 的位置,直接拒绝部署
if privilegedVhostPorts(server.Options) {
return errPrivilegedVhostNoPublicProxy()
}
// 本机 frpc 配置里没有该域名,说明规则改动后还没应用:先自动应用这台服务端的配置并等隧道注册,
// 否则 VPS 上部署完只会得到「隧道未注册」,用户还得手动回「内网穿透」页点应用。
if !s.frpcConfigHasDomains(server.ID, proxy.CustomDomains) {
if err := s.applyServerConfig(ctx, server.ID); err != nil {
return fmt.Errorf("该域名还没有下发给本机 frpc,自动应用配置又失败:%w;请到「内网穿透」页手动点「校验并应用配置」后再部署", err)
}
s.logger.Info("部署公网反代前自动应用 frpc 配置", "server_id", server.ID, "domains", strings.Join(proxy.CustomDomains, "、"))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
if proxy.Type == "https" {
// https 类型隧道是端到端透传:frps 不解密,要求内网服务自己持有证书并使用 TLS。
// 内网明文 HTTP 服务应改用 http 类型,公网 HTTPS 由 Nginx 挂证书终结(证书自动推送)。
return fmt.Errorf("https 类型隧道要求内网服务自己提供 TLS(192.168.11.30:8001 这类明文服务无法走通);" +
"请把规则类型改为 http(公网 HTTPS 由 Nginx 证书终结),或给内网服务配置证书")
}
upstream, uerr := s.agentUpstreamFor(ctx, proxy)
if uerr != nil {
return uerr
}
// vhost 端口供 agent 回源自检用
vhostPort := server.Options.VhostHTTPPort
if vhostPort <= 0 {
vhostPort = 8080
}
// 仅中国大陆 IP:先把本地已维护的中国 IP 段下发到 agent(geo 变量依赖它)
if proxy.Options.ChinaOnly {
data, err := os.ReadFile(s.cfg.ChinaCIDRPath())
if err != nil || strings.TrimSpace(string(data)) == "" {
return fmt.Errorf("启用「仅中国大陆 IP」前需先在「反向代理」页更新中国 IP 段(本地 china_cidr.conf 不存在或为空)")
}
if err := client.SyncChinaCIDR(ctx, string(data)); err != nil {
return fmt.Errorf("下发中国 IP 段失败: %w", err)
}
}
for _, domain := range proxy.CustomDomains {
// 证书用于公网 Nginx 的 TLS 终结(443 监听),与 frp 隧道类型解耦:
// 本地明文 HTTP 服务应选 http 类型规则,浏览器侧 HTTPS 由 Nginx 挂证书承担。
// https 类型隧道要求内网服务自己做 TLS,否则即使端口对上也无法工作。
tlsEdge := false
if certErr := s.pushAgentCertificate(ctx, client, domain); certErr == nil {
tlsEdge = true
} else if proxy.Type == "https" {
return fmt.Errorf("推送证书 %s 失败: %w", domain, certErr)
}
result, err := client.SyncRouteSpec(ctx, RouteSpec{
Domain: domain, Upstream: upstream, TLS: tlsEdge, CertDir: "",
WebSocket: proxy.Options.WebSocket, VhostPort: vhostPort,
RedirectHTTPS: proxy.Options.RedirectHTTPS,
AllowIPs: proxy.Options.AllowIPs, DenyIPs: proxy.Options.DenyIPs,
BasicAuthUser: proxy.Options.BasicAuthUser,
BasicAuthPassword: proxy.Options.BasicAuthPassword,
HTTPSDisabled: proxy.Options.HTTPSDisabled,
ClientMaxBodySize: proxy.Options.ClientMaxBodySize,
ProxyReadTimeout: proxy.Options.ProxyReadTimeout,
SecurityHeaders: proxy.Options.SecurityHeaders,
TLS13Only: proxy.Options.TLS13Only,
RateLimitRate: proxy.Options.RateLimitRate,
RateLimitBurst: proxy.Options.RateLimitBurst,
ConnLimitMax: proxy.Options.ConnLimitMax,
ChinaOnly: proxy.Options.ChinaOnly,
})
if err != nil {
return fmt.Errorf("部署域名 %s 失败: %w", domain, err)
}
// 回源自检分级提示,不掩盖灰区:
if code, ok := result["probe_status"].(float64); ok {
switch {
case code == 0:
return fmt.Errorf("反代已部署,但回源自检失败(连接不上 frps vhost :%d):检查 frps 是否运行、vhostHTTPPort 是否与表单一致", vhostPort)
case code == 404:
return fmt.Errorf("反代已部署,frps 正常但没有找到域名 %s 的隧道注册(%s)", domain, s.frpcTunnelHint(server.ID, domain))
case code >= 500:
return fmt.Errorf("反代已部署,隧道已连通但内网服务异常(HTTP %.0f):请检查内网服务 %s 是否存活", code, upstream)
}
}
}
return nil
}
// frpcTunnelHint 解释「frps 里查不到这条隧道」最可能的原因,并给出下一步动作:
// 先看最近一次应用过的 frpc 配置里有没有该域名,再看进程是否在跑,最后才怀疑 frpc↔frps 的连接。
func (s *Service) frpcTunnelHint(serverID int64, domain string) string {
if !s.frpcConfigHasDomains(serverID, []string{domain}) {
return "本机 frpc 配置里还没有这个域名,请到「内网穿透」页点「校验并应用配置」"
}
if s.managerForServer(serverID).Status().Status != "running" {
return "配置里有该域名,但本机 frpc 没有运行,请到「内网穿透」页启动"
}
return "配置里有该域名且 frpc 正在运行,说明 frpc 没有成功注册到 frps(多为登录失败或代理名冲突),请到「内网穿透 → 服务端详情 → 日志」看 frpc 报错"
}
// frpcConfigHasDomains 检查最近一次应用过的 frpc 配置里是否包含这些域名;
// 文件由 Reload/启动流程写入,内容即「已经下发给 frpc 的规则」。
func (s *Service) frpcConfigHasDomains(serverID int64, domains []string) bool {
data, err := os.ReadFile(s.serverConfigPath(serverID))
if err != nil {
return false
}
content := string(data)
for _, domain := range domains {
if !strings.Contains(content, domain) {
return false
}
}
return true
}
// applyServerConfig 重新生成配置文件并只重启这台服务端的 frpc,
// 用于「规则改了但没应用配置」的自动补救(不碰其他服务端的进程)。
func (s *Service) applyServerConfig(ctx context.Context, serverID int64) error {
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.persistConfigsLocked(ctx); err != nil {
return err
}
manager := s.managerForServer(serverID)
hasProxies, err := s.hasEnabledProxies(ctx, serverID)
if err != nil {
return err
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, serverID, manager, "")
return nil
}
if err := manager.Verify(ctx, s.serverConfigPath(serverID)); err != nil {
s.recordProcessState(ctx, serverID, manager, err.Error())
return err
}
if err := manager.Restart(ctx); err != nil {
s.recordProcessState(ctx, serverID, manager, err.Error())
return err
}
s.recordProcessState(ctx, serverID, manager, "")
return nil
}
// pushAgentCertificate 从 Havline 本地证书存储读取域名对应证书并推送到公网 agent;
// 支持精确域名与通配符(*.example.com)匹配。
func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient, domain string) error {
if s.certStore == nil {
return fmt.Errorf("证书存储不可用")
}
records, err := s.certStore.List(ctx)
if err != nil {
return err
}
for _, record := range records {
matched := record.Domain == domain
if !matched {
for _, d := range record.Domains {
if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
matched = true
break
}
}
}
if !matched || record.CertPath == "" || record.KeyPath == "" {
continue
}
certPEM, err := os.ReadFile(record.CertPath)
if err != nil {
return fmt.Errorf("读取证书文件失败: %w", err)
}
keyPEM, err := os.ReadFile(record.KeyPath)
if err != nil {
return fmt.Errorf("读取私钥文件失败: %w", err)
}
return client.PushCertificate(ctx, domain, string(certPEM), string(keyPEM))
}
return fmt.Errorf("Havline 本地没有域名 %s 的证书;请先在「证书」页申请,或改用 HTTP 类型规则", domain)
}
// RemoveAgentRoute 手动移除单条穿透规则对应的公网 Nginx 反代。
func (s *Service) RemoveAgentRoute(ctx context.Context, proxyID int64) error {
proxy, err := s.store.GetProxy(ctx, proxyID)
if err != nil {
return err
}
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
for _, domain := range proxy.CustomDomains {
if err := client.DeleteRoute(ctx, domain); err != nil {
return fmt.Errorf("移除域名 %s 失败: %w", domain, err)
}
}
return nil
}
func (s *Service) DeleteProxy(ctx context.Context, id int64) error {
if _, err := s.store.GetProxy(ctx, id); err != nil {
return err
}
if err := s.store.DeleteProxy(ctx, id); err != nil {
return err
}
if err := s.persistConfig(ctx); err != nil {
return err
}
// 公网 Nginx 反代由 Agent 页面手动管理,删除穿透规则不自动访问 VPS。
return nil
}
// cachedAgentClient 是按 URL+Token 指纹缓存的 agent 客户端;指纹用密文而非明文,避免内存里多存一份 Token。
type cachedAgentClient struct {
fingerprint string
client *AgentClient
}
func (s *Service) invalidateAgentClient(serverID int64) {
s.agentMu.Lock()
delete(s.agentClients, serverID)
s.agentMu.Unlock()
}
func agentHTTPSBaseURL(server Server) string {
current := strings.TrimRight(strings.TrimSpace(server.AgentURL), "/")
if strings.HasPrefix(strings.ToLower(current), "https://") {
return current
}
host := strings.TrimSpace(server.SSHHost)
if host == "" {
return current
}
port := listenPort(server.AgentListenAddr, 7443)
return fmt.Sprintf("https://%s:%d", host, port)
}
// agentTransportPins 返回当前应接受的证书 pin;轮换过渡期内同时包含旧 pin。
func agentTransportPins(server Server) []string {
out := make([]string, 0, 2)
if pin := strings.TrimSpace(server.AgentTLSPin); pin != "" {
out = append(out, pin)
}
prev := strings.TrimSpace(server.AgentTLSPinPrev)
if prev == "" || !pinGraceActive(server.AgentTLSPinPrevUntil) {
return out
}
return append(out, prev)
}
func pinGraceActive(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
deadline, err := time.Parse(time.RFC3339, raw)
if err != nil {
return false
}
return time.Now().Before(deadline)
}
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
server, err := s.store.GetServer(ctx, serverID)
if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
return nil, false
}
encoded, err := s.store.AgentToken(ctx, serverID)
if err != nil || encoded == "" {
return nil, false
}
token, err := s.secretBox.Decrypt(encoded)
if err != nil || token == "" {
return nil, false
}
baseURL := server.AgentURL
var pins []string
if server.AgentTransport == "tunnel" || (server.AgentTransport == "http" && isLoopbackAgentURL(server.AgentURL)) {
tunnel, err := s.ensureAgentTunnel(ctx, serverID)
if err != nil {
return nil, false
}
baseURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.localPort)
} else if server.AgentTransport == "https-pin" {
pins = agentTransportPins(server)
if len(pins) == 0 {
return nil, false
}
baseURL = agentHTTPSBaseURL(server)
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return nil, false
}
if baseURL != strings.TrimRight(strings.TrimSpace(server.AgentURL), "/") {
err := s.store.SetAgentTransport(ctx, serverID, baseURL, "https-pin", server.AgentLocalPort,
server.AgentTLSPin, server.AgentListenAddr, server.AgentTransportState, server.AgentTransportError)
if err != nil {
s.logger.Warn("agent https url repair failed", "server_id", serverID, "error", err)
}
}
} else if baseURL == "" {
return nil, false
}
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
fingerprint := baseURL + "|" + encoded + "|" + strings.Join(pins, ",")
s.agentMu.Lock()
defer s.agentMu.Unlock()
if s.agentClients == nil {
s.agentClients = make(map[int64]cachedAgentClient)
}
if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
return cached.client, true
}
var client *AgentClient
if len(pins) > 0 {
client = NewAgentClientWithPins(baseURL, token, pins...)
} else {
client = NewAgentClient(baseURL, token)
}
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
return client, true
}
func isLoopbackAgentURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
return
}
// 80/443 回源端口的服务端由 frps 直接对外:任何自动下发都要跳过
if server, err := s.store.GetServer(ctx, proxy.ServerID); err == nil && privilegedVhostPorts(server.Options) {
return
}
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return
}
upstream, uerr := s.agentUpstreamFor(ctx, proxy)
if uerr != nil {
s.logger.Warn("agent route sync skipped", "proxy_id", proxy.ID, "error", uerr)
return
}
for _, domain := range proxy.CustomDomains {
if err := client.SyncRoute(ctx, domain, upstream, proxy.Type == "https", ""); err != nil {
s.logger.Warn("agent route sync failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
}
}
}
func (s *Service) deleteAgentRoute(ctx context.Context, proxy Proxy) {
client, ok := s.agentClient(ctx, proxy.ServerID)
if !ok {
return
}
for _, domain := range proxy.CustomDomains {
if err := client.DeleteRoute(ctx, domain); err != nil {
s.logger.Warn("agent route delete failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
}
}
}
func (s *Service) Start(ctx context.Context) error {
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.persistConfigsLocked(ctx); err != nil {
return err
}
s.manager.RefreshVersion(ctx)
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return err
}
if len(servers) == 0 {
return fmt.Errorf("未启用 FRP 服务端")
}
// 23.5:按服务端隔离——某个服务端校验/启动失败不停止其他服务端,
// 已启动的保持运行,失败原因聚合成一条错误返回。
var failures []string
for _, server := range servers {
manager := s.managerForServer(server.ID)
hasProxies, proxyErr := s.hasEnabledProxies(ctx, server.ID)
if proxyErr != nil {
failures = append(failures, fmt.Sprintf("服务端 %s 读取规则失败:%v", server.Name, proxyErr))
continue
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, server.ID, manager, "")
continue
}
if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
continue
}
if err := manager.Start(); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 启动失败:%v", server.Name, err))
continue
}
s.recordProcessState(ctx, server.ID, manager, "")
}
if len(failures) > 0 {
return fmt.Errorf("%s", strings.Join(failures, ";"))
}
return nil
}
func (s *Service) AutoStart(ctx context.Context) error {
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return err
}
shouldStart := false
for _, server := range servers {
if server.Options.AutoStart {
shouldStart = true
break
}
}
if !shouldStart {
return nil
}
return s.Start(ctx)
}
func (s *Service) Stop(ctx context.Context) error {
s.stopAgentTunnels()
_ = s.ReconcileRuntime(ctx)
servers, err := s.store.ListServers(ctx)
if err != nil {
return err
}
var messages []string
for _, server := range servers {
manager := s.managerForServer(server.ID)
if err := manager.Stop(ctx); err != nil {
messages = append(messages, fmt.Sprintf("服务端 %d:%v", server.ID, err))
continue
}
s.recordProcessState(ctx, server.ID, manager, "")
}
if len(messages) > 0 {
return fmt.Errorf("%s", strings.Join(messages, ";"))
}
return nil
}
// Restart 重启全部 frpc:先停后起。
//
// 比手动「停止 → 启动」多一层保障:Stop 会把 Agent 隧道一起停掉,而单独点「启动」
// 不会重建它们;重启在这里顺带把隧道重新拉起来,避免停一次之后就再也连不上。
func (s *Service) Restart(ctx context.Context) error {
stopErr := s.Stop(ctx)
startErr := s.Start(ctx)
if startErr != nil {
if stopErr != nil {
return fmt.Errorf("停止阶段:%v;启动阶段:%w", stopErr, startErr)
}
return startErr
}
// 隧道重建可能要等远端,放到后台跑,不占着这次请求
tunnelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
go func() {
defer cancel()
s.StartAgentTunnels(tunnelCtx)
}()
if stopErr != nil {
s.logger.Warn("frpc 重启时停止阶段报错,但已重新启动", "module", "FRP", "error", stopErr.Error())
}
return nil
}
func (s *Service) Reload(ctx context.Context) error {
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.persistConfigsLocked(ctx); err != nil {
return err
}
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return err
}
// 23.5:按服务端隔离重载,失败不中断其他服务端,聚合报错。
var failures []string
for _, server := range servers {
manager := s.managerForServer(server.ID)
hasProxies, proxyErr := s.hasEnabledProxies(ctx, server.ID)
if proxyErr != nil {
failures = append(failures, fmt.Sprintf("服务端 %s 读取规则失败:%v", server.Name, proxyErr))
continue
}
if !hasProxies {
_ = manager.Stop(ctx)
s.recordProcessState(ctx, server.ID, manager, "")
continue
}
if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
continue
}
if err := manager.Restart(ctx); err != nil {
s.recordProcessState(ctx, server.ID, manager, err.Error())
failures = append(failures, fmt.Sprintf("服务端 %s 重启失败:%v", server.Name, err))
continue
}
s.recordProcessState(ctx, server.ID, manager, "")
}
if len(failures) > 0 {
return fmt.Errorf("%s", strings.Join(failures, ";"))
}
return nil
}
func (s *Service) recordProcessState(ctx context.Context, serverID int64, manager *Manager, lastError string) {
status := manager.Status()
processState := status.Status
if lastError != "" {
processState = "error"
}
runtime := ServerRuntimeStatus{
ServerID: serverID, ProcessState: processState, ConnectionState: "unknown",
StateSource: "manager", ProcessPID: status.PID, LastStartedAt: status.LastStartedAt, LastError: lastError,
}
if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
s.logger.Warn("保存 FRP 进程状态失败", "server_id", serverID, "error", err.Error())
}
}
func (s *Service) ReconcileRuntime(ctx context.Context) error {
servers, err := s.store.ListServers(ctx)
if err != nil {
return err
}
for _, server := range servers {
runtime, runtimeErr := s.store.ServerRuntimeStatus(ctx, server.ID)
if runtimeErr != nil || runtime.ProcessPID <= 0 {
continue
}
manager := s.managerForServer(server.ID)
if manager.Attach(runtime.ProcessPID) {
runtime.ProcessState = "running"
runtime.StateSource = "reconciled"
runtime.LastError = ""
if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
return err
}
continue
}
if processAlive(runtime.ProcessPID) {
runtime.ProcessState = "unknown"
runtime.StateSource = "reconcile"
runtime.LastError = "检测到运行中的进程,但无法确认其属于当前 FRP 配置"
} else {
runtime.ProcessState = "stopped"
runtime.ProcessPID = 0
runtime.StateSource = "reconcile"
runtime.LastError = ""
}
if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
return err
}
}
return nil
}
func (s *Service) Test(ctx context.Context, id int64) error {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return err
}
if err := s.persistServerConfigForVerify(ctx, server); err != nil {
return err
}
return s.managerForServer(id).Verify(ctx, s.serverConfigPath(id))
}
// persistServerConfigForVerify 为「测试」路径服务:渲染到临时文件并 verify,通过后才
// 原子替换正式配置;不通过时删除临时文件、正式配置保持原样(23.5 的「临时配置加 verify,
// 只有验证成功才替换正式配置」要求)。
func (s *Service) persistServerConfigForVerify(ctx context.Context, server Server) error {
content, err := s.renderServerConfig(ctx, server)
if err != nil {
return err
}
officialPath := s.serverConfigPath(server.ID)
if err := os.WriteFile(officialPath+".verify", []byte(content), 0o600); err != nil {
return err
}
if err := s.managerForServer(server.ID).Verify(ctx, officialPath+".verify"); err != nil {
_ = os.Remove(officialPath + ".verify")
return err
}
// 备份失败就不能覆盖唯一副本:否则旧配置没有回退、只剩 .verify 里的新内容
if err := fsutil.Backup(officialPath); err != nil {
_ = os.Remove(officialPath + ".verify")
return fmt.Errorf("备份现有 frps 配置失败,已保留原配置: %w", err)
}
return os.Rename(officialPath+".verify", officialPath)
}
func (s *Service) renderServerConfig(ctx context.Context, server Server) (string, error) {
proxies, err := s.store.ListEnabledProxies(ctx, server.ID)
if err != nil {
return "", err
}
token, oidcSecret, err := s.loadAuthSecrets(ctx, server)
if err != nil {
return "", err
}
return renderTOML(renderedConfig{
Server: server, Token: token, OIDCClientSecret: oidcSecret, LogPath: s.serverLogPath(server.ID),
TLSCertPath: s.existingTLSAssetPath(server.ID, "cert.pem"), TLSKeyPath: s.existingTLSAssetPath(server.ID, "key.pem"),
TLSTrustedCAPath: s.existingTLSAssetPath(server.ID, "ca.pem"), Proxies: proxies,
}), nil
}
func (s *Service) Status(ctx context.Context) RuntimeStatus {
versionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
s.manager.RefreshVersion(versionCtx)
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return s.statusWithVersion(s.manager)
}
statuses := make([]RuntimeStatus, 0, len(servers))
for _, server := range servers {
manager := s.managerForServer(server.ID)
// 每个服务端各自探测版本(共用同一个 2 秒超时上下文)
manager.RefreshVersion(versionCtx)
current := s.statusWithVersion(manager)
current.Configured = s.serverConfigured(server)
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
current.EnabledProxies = len(proxies)
if len(proxies) == 0 {
// 没有启用规则的服务端不应启动 frpc,也不应把它的历史退出错误聚合到全局状态。
current.Status = "stopped"
current.PID = 0
current.LastError = ""
}
}
statuses = append(statuses, current)
}
return latestRuntimeStatus(statuses)
}
func (s *Service) hasEnabledProxies(ctx context.Context, serverID int64) (bool, error) {
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
if err != nil {
return false, err
}
return len(proxies) > 0, nil
}
// statusWithVersion 取服务端运行状态,并在版本探测失败时按二进制目录名推导版本:
// frpc 进程未运行或 --version 探测失败时,RuntimeStatus.Version 因带 omitempty 会被省略,
// 前端(FRP 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。
// 这里回落到 <FrpDir>/bin/<版本>/ 的目录约定,与「FRP 应用配置」弹窗的 active_version 同源。
func (s *Service) statusWithVersion(manager *Manager) RuntimeStatus {
status := manager.Status()
if status.Version != "" {
return status
}
if bin, err := manager.BinaryPath(); err == nil {
status.Version = versionFromBinaryPath(s.cfg.FrpDir(), bin)
}
return status
}
func (s *Service) ReadLogs(limit int, serverID int64) ([]string, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
servers, err := s.store.ListServers(context.Background())
if err != nil {
return nil, err
}
lines := []string{}
for _, server := range servers {
// 指定 server_id 时只读该服务端的日志文件;0 表示全部服务端
if serverID > 0 && server.ID != serverID {
continue
}
data, readErr := os.ReadFile(s.serverLogPath(server.ID))
if os.IsNotExist(readErr) {
continue
}
if readErr != nil {
return nil, readErr
}
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) != "" {
lines = append(lines, fmt.Sprintf("[%s] %s", server.Name, line))
}
}
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return lines, nil
}
// ReadProxyLogs 返回单条穿透规则的日志:读所属服务端的 frpc 日志并按规则名过滤。
func (s *Service) ReadProxyLogs(ctx context.Context, proxyID int64, limit int) ([]string, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
proxy, err := s.store.GetProxy(ctx, proxyID)
if err != nil {
return nil, err
}
data, readErr := os.ReadFile(s.serverLogPath(proxy.ServerID))
if os.IsNotExist(readErr) {
return []string{}, nil
}
if readErr != nil {
return nil, readErr
}
lines := []string{}
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) != "" && strings.Contains(line, proxy.Name) {
lines = append(lines, line)
}
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return lines, nil
}
func (s *Service) ExportMaskedConfig(ctx context.Context) (string, error) {
servers, err := s.store.ListEnabledServers(ctx)
if err != nil {
return "", err
}
if len(servers) == 0 {
return "", fmt.Errorf("未启用 FRP 服务端")
}
var out strings.Builder
for _, server := range servers {
proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID)
if proxyErr != nil {
return "", proxyErr
}
fmt.Fprintf(&out, "# 服务端:%s (%s:%d)\n", server.Name, server.ServerAddr, server.ServerPort)
out.WriteString(renderMaskedTOML(server, proxies))
out.WriteString("\n")
}
return out.String(), nil
}
func (s *Service) validateCredentials(in ServerInput, existing Server) error {
if !in.Enabled {
return nil
}
switch in.Options.AuthMethod {
case "none":
return nil
case "token":
if in.ClearToken || ((in.Token == "" || in.Token == maskedToken) && !existing.HasToken) {
return fmt.Errorf("Token 认证需要填写 FRP Token")
}
case "oidc":
if in.ClearOIDCClientSecret || ((in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken) && !existing.HasOIDCSecret) {
return fmt.Errorf("OIDC 认证需要填写客户端密钥")
}
}
return nil
}
func (s *Service) encryptOptional(value string) (string, error) {
if value == "" {
return "", nil
}
return s.secretBox.Encrypt(value)
}
func (s *Service) saveOIDCSecret(ctx context.Context, serverID int64, in ServerInput) error {
if in.ClearOIDCClientSecret {
return s.store.SetOIDCSecret(ctx, serverID, "")
}
if in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken {
return nil
}
value, err := s.secretBox.Encrypt(in.OIDCClientSecret)
if err != nil {
return err
}
return s.store.SetOIDCSecret(ctx, serverID, value)
}
// saveDashboardPassword 保存 frps 管理接口密码(与 frpc Token 无关,仅用于 Havline 拉取流量信息)。
func (s *Service) saveDashboardPassword(ctx context.Context, serverID int64, in ServerInput) error {
if in.ClearDashboardPassword {
return s.store.SetDashboardPassword(ctx, serverID, "")
}
if in.DashboardPassword == "" || in.DashboardPassword == maskedToken {
return nil
}
encoded, err := s.secretBox.Encrypt(in.DashboardPassword)
if err != nil {
return err
}
return s.store.SetDashboardPassword(ctx, serverID, encoded)
}
func (s *Service) loadAuthSecrets(ctx context.Context, server Server) (string, string, error) {
if server.Options.AuthMethod == "none" {
return "", "", nil
}
if server.Options.AuthMethod == "token" {
encoded, err := s.store.Token(ctx, server.ID)
if err != nil {
return "", "", err
}
value, err := s.secretBox.Decrypt(encoded)
if err != nil {
return "", "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
}
return value, "", nil
}
encoded, err := s.store.OIDCSecret(ctx, server.ID)
if err != nil {
return "", "", err
}
value, err := s.secretBox.Decrypt(encoded)
if err != nil {
return "", "", fmt.Errorf("OIDC 客户端密钥无法解密,请重新保存服务端配置")
}
return "", value, nil
}
func (s *Service) serverConfigured(server Server) bool {
switch server.Options.AuthMethod {
case "none":
return true
case "oidc":
return server.HasOIDCSecret
default:
return server.HasToken
}
}
func (s *Service) enrichTLSStatus(server *Server) {
server.Options.TLSCertificateConfigured = s.existingTLSAssetPath(server.ID, "cert.pem") != ""
server.Options.TLSKeyConfigured = s.existingTLSAssetPath(server.ID, "key.pem") != ""
server.Options.TLSTrustedCAConfigured = s.existingTLSAssetPath(server.ID, "ca.pem") != ""
}
func (s *Service) tlsAssetPath(serverID int64, name string) string {
return filepath.Join(s.cfg.FrpDir(), "certs", fmt.Sprintf("server-%d-%s", serverID, name))
}
func (s *Service) existingTLSAssetPath(serverID int64, name string) string {
path := s.tlsAssetPath(serverID, name)
if _, err := os.Stat(path); err == nil {
return path
}
return ""
}
func (s *Service) saveTLSAssets(serverID int64, in ServerInput) error {
assets := []struct {
name, content string
clear bool
}{
{"cert.pem", in.TLSCertificate, in.ClearTLSCertificate}, {"key.pem", in.TLSKey, in.ClearTLSKey}, {"ca.pem", in.TLSTrustedCA, in.ClearTLSTrustedCA},
}
for _, asset := range assets {
path := s.tlsAssetPath(serverID, asset.name)
if asset.clear {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
continue
}
if asset.content == "" {
continue
}
if !strings.Contains(asset.content, "-----BEGIN") {
return fmt.Errorf("TLS %s 内容不是有效 PEM", asset.name)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if err := os.WriteFile(path, []byte(strings.TrimSpace(asset.content)+"\n"), 0o600); err != nil {
return err
}
}
return nil
}
func (s *Service) persistConfig(ctx context.Context) error {
s.applyMu.Lock()
defer s.applyMu.Unlock()
return s.persistConfigsLocked(ctx)
}
// persistConfigsLocked 写全部服务端配置文件。调用方必须已持有 s.applyMu。
// 单个服务端写入失败立即返回,其余服务端保持上一版配置,不会出现部分新部分旧混写。
func (s *Service) persistConfigsLocked(ctx context.Context) error {
servers, err := s.store.ListServers(ctx)
if err != nil {
return err
}
for _, server := range servers {
path := s.serverConfigPath(server.ID)
if !server.Enabled {
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
return removeErr
}
continue
}
if err := s.persistServerConfig(ctx, server); err != nil {
return err
}
}
return nil
}
func (s *Service) persistServerConfig(ctx context.Context, server Server) error {
content, err := s.renderServerConfig(ctx, server)
if err != nil {
return err
}
return writeConfigAtomically(s.serverConfigPath(server.ID), content)
}
func writeConfigAtomically(path, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
// 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename),
// 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。
if err := fsutil.Backup(path); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "frpc-*.toml")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}