feat(agent): 默认回环监听并自动建立 SSH 隧道并发布 v1.4.0
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateListenAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr string
|
||||
allowRemote bool
|
||||
loopback bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"127.0.0.1:7700", false, true, false},
|
||||
{"localhost:7700", false, true, false},
|
||||
{"[::1]:7700", false, true, false},
|
||||
{":7700", false, false, true},
|
||||
{"0.0.0.0:7700", false, false, true},
|
||||
{"0.0.0.0:7700", true, false, false},
|
||||
{"bad-address", false, false, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
loopback, err := validateListenAddr(tc.addr, tc.allowRemote)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("%s 应拒绝", tc.addr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s 不应报错: %v", tc.addr, err)
|
||||
}
|
||||
if loopback != tc.loopback {
|
||||
t.Fatalf("%s loopback=%v, want %v", tc.addr, loopback, tc.loopback)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-27
@@ -51,11 +51,13 @@ import (
|
||||
// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。
|
||||
// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。
|
||||
// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。
|
||||
const AgentVersion = "0.12.9"
|
||||
// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。
|
||||
const AgentVersion = "0.13.0"
|
||||
|
||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
AllowRemote bool
|
||||
Token string
|
||||
NginxConfDir string
|
||||
NginxBinary string
|
||||
@@ -67,8 +69,9 @@ type Config struct {
|
||||
|
||||
// Server 暴露 agent HTTP 服务。
|
||||
type Server struct {
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
listenLoopback bool
|
||||
mu sync.Mutex
|
||||
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
|
||||
metricsMu sync.Mutex
|
||||
metricsAt time.Time
|
||||
@@ -128,7 +131,11 @@ var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
|
||||
|
||||
func NewServer(cfg Config) (*Server, error) {
|
||||
if strings.TrimSpace(cfg.ListenAddr) == "" {
|
||||
cfg.ListenAddr = ":7700"
|
||||
cfg.ListenAddr = "127.0.0.1:7700"
|
||||
}
|
||||
loopback, err := validateListenAddr(cfg.ListenAddr, cfg.AllowRemote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.NginxConfDir) == "" {
|
||||
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
|
||||
@@ -164,13 +171,43 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
return nil, fmt.Errorf("create agent directory: %w", err)
|
||||
}
|
||||
}
|
||||
server := &Server{cfg: cfg}
|
||||
server := &Server{cfg: cfg, listenLoopback: loopback}
|
||||
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
|
||||
// 历史错误目录的缓存会被自动修正。
|
||||
server.preloadNginxConfDir()
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func validateListenAddr(addr string, allowRemote bool) (bool, error) {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Agent 监听地址无效: %w", err)
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
loopback := strings.EqualFold(host, "localhost")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
loopback = ip.IsLoopback()
|
||||
}
|
||||
if !loopback && !allowRemote {
|
||||
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr)
|
||||
}
|
||||
return loopback, nil
|
||||
}
|
||||
|
||||
func listenScope(loopback bool) string {
|
||||
if loopback {
|
||||
return "loopback"
|
||||
}
|
||||
return "public"
|
||||
}
|
||||
|
||||
func transportSecurity(loopback bool) string {
|
||||
if loopback {
|
||||
return "loopback"
|
||||
}
|
||||
return "plaintext-http"
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
|
||||
@@ -242,28 +279,32 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
||||
nginxProbe := s.probeNginx()
|
||||
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": "fonu-agent",
|
||||
"agent_version": AgentVersion,
|
||||
"nginx_binary": s.cfg.NginxBinary,
|
||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||
"nginx_ok": nginxProbe.OK,
|
||||
"nginx_missing": nginxProbe.Missing,
|
||||
"nginx_running": nginxProbe.Running,
|
||||
"nginx_path": nginxProbe.Path,
|
||||
"nginx_version": nginxProbe.Version,
|
||||
"nginx_error": nginxProbe.Error,
|
||||
"frps_binary": s.cfg.FrpsBinary,
|
||||
"frps_config_path": s.cfg.FrpsConfigPath,
|
||||
"ports": ports,
|
||||
"frps_active": frpsActive,
|
||||
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"service": "fonu-agent",
|
||||
"agent_version": AgentVersion,
|
||||
"listen_addr": s.cfg.ListenAddr,
|
||||
"listen_scope": listenScope(s.listenLoopback),
|
||||
"remote_access_enabled": s.cfg.AllowRemote,
|
||||
"transport_security": transportSecurity(s.listenLoopback),
|
||||
"nginx_binary": s.cfg.NginxBinary,
|
||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||
"nginx_ok": nginxProbe.OK,
|
||||
"nginx_missing": nginxProbe.Missing,
|
||||
"nginx_running": nginxProbe.Running,
|
||||
"nginx_path": nginxProbe.Path,
|
||||
"nginx_version": nginxProbe.Version,
|
||||
"nginx_error": nginxProbe.Error,
|
||||
"frps_binary": s.cfg.FrpsBinary,
|
||||
"frps_config_path": s.cfg.FrpsConfigPath,
|
||||
"ports": ports,
|
||||
"frps_active": frpsActive,
|
||||
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -324,6 +324,20 @@ func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentTunnelRestart(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
server, err := h.svc.RestartAgentTunnel(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, server)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -131,6 +131,7 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
|
||||
@@ -146,6 +146,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
frpMultiSvc.SetBaseContext(ctx)
|
||||
go frpMultiSvc.StartAgentTunnels(ctx)
|
||||
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 5
|
||||
|
||||
@@ -84,6 +84,13 @@ func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, e
|
||||
return strings.TrimSpace(out), err
|
||||
}
|
||||
|
||||
// RestorePreviousConfig 在安全迁移验证失败时恢复安装前的 agent.env,并重启旧监听配置。
|
||||
func (i AgentInstaller) RestorePreviousConfig(ctx context.Context) error {
|
||||
command := `if [ -f /etc/fonu-agent/agent.env.previous ]; then cp /etc/fonu-agent/agent.env.previous /etc/fonu-agent/agent.env && chmod 600 /etc/fonu-agent/agent.env && systemctl restart fonu-agent; fi`
|
||||
_, err := i.SSH.Run(ctx, command)
|
||||
return err
|
||||
}
|
||||
|
||||
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
|
||||
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
|
||||
return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
|
||||
@@ -144,6 +151,10 @@ if [ ! -f "$CONF_FILE" ]; then
|
||||
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent)
|
||||
printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
|
||||
fi
|
||||
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
|
||||
touch "$CONF_FILE"
|
||||
sed -i '/^FONU_AGENT_LISTEN=/d; /^FONU_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
|
||||
printf 'FONU_AGENT_LISTEN=127.0.0.1:7700\nFONU_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
|
||||
cat > "$UNIT" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Fonu FRPS management agent
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const agentRemoteAddr = "127.0.0.1:7700"
|
||||
|
||||
type agentTunnel struct {
|
||||
serverID int64
|
||||
localPort int
|
||||
ssh SSHInstaller
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
client *ssh.Client
|
||||
state string
|
||||
lastError string
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
return &agentTunnel{
|
||||
serverID: serverID,
|
||||
localPort: localPort,
|
||||
ssh: installer,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
state: "connecting",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *agentTunnel) Start() error {
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort))
|
||||
if err != nil {
|
||||
t.setState("failed", err)
|
||||
return fmt.Errorf("监听隧道本地端口失败: %w", err)
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.listener = listener
|
||||
t.state = "connected"
|
||||
t.lastError = ""
|
||||
t.mu.Unlock()
|
||||
t.wg.Add(1)
|
||||
go t.acceptLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *agentTunnel) acceptLoop() {
|
||||
defer t.wg.Done()
|
||||
for {
|
||||
conn, err := t.listener.Accept()
|
||||
if err != nil {
|
||||
if t.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
t.setState("failed", err)
|
||||
return
|
||||
}
|
||||
t.wg.Add(1)
|
||||
go func() {
|
||||
defer t.wg.Done()
|
||||
t.handle(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *agentTunnel) handle(local net.Conn) {
|
||||
defer local.Close()
|
||||
client, err := t.ensureClient()
|
||||
if err != nil {
|
||||
t.setState("reconnecting", err)
|
||||
return
|
||||
}
|
||||
remote, err := client.Dial("tcp", agentRemoteAddr)
|
||||
if err != nil {
|
||||
t.invalidateClient(client)
|
||||
t.setState("reconnecting", err)
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
t.setState("connected", nil)
|
||||
proxyTunnel(local, remote)
|
||||
}
|
||||
|
||||
func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
client := t.client
|
||||
t.mu.Unlock()
|
||||
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err == nil {
|
||||
return client, nil
|
||||
}
|
||||
t.invalidateClient(client)
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
client, err := t.ssh.client(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
existing := t.client
|
||||
t.mu.Unlock()
|
||||
_ = client.Close()
|
||||
return existing, nil
|
||||
}
|
||||
t.client = client
|
||||
t.mu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (t *agentTunnel) invalidateClient(client *ssh.Client) {
|
||||
t.mu.Lock()
|
||||
if t.client == client {
|
||||
t.client = nil
|
||||
}
|
||||
t.mu.Unlock()
|
||||
_ = client.Close()
|
||||
}
|
||||
|
||||
func (t *agentTunnel) State() (string, string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.state, t.lastError
|
||||
}
|
||||
|
||||
func (t *agentTunnel) setState(state string, err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.state = state
|
||||
if err == nil {
|
||||
t.lastError = ""
|
||||
return
|
||||
}
|
||||
t.lastError = err.Error()
|
||||
}
|
||||
|
||||
func (t *agentTunnel) Stop() {
|
||||
t.cancel()
|
||||
t.mu.Lock()
|
||||
if t.listener != nil {
|
||||
_ = t.listener.Close()
|
||||
}
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
t.wg.Wait()
|
||||
}
|
||||
|
||||
func proxyTunnel(a, b net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(a, b)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(b, a)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentTunnelBindsLoopbackAndReleasesPort(t *testing.T) {
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := probe.Addr().(*net.TCPAddr).Port
|
||||
_ = probe.Close()
|
||||
|
||||
tunnel := newAgentTunnel(context.Background(), 1, port, SSHInstaller{})
|
||||
if err := tunnel.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil {
|
||||
t.Fatal("隧道启动后本地端口应已被占用")
|
||||
}
|
||||
state, _ := tunnel.State()
|
||||
if state != "connected" {
|
||||
t.Fatalf("state=%q, want connected", state)
|
||||
}
|
||||
tunnel.Stop()
|
||||
if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err != nil {
|
||||
t.Fatalf("隧道停止后端口未释放: %v", err)
|
||||
} else {
|
||||
_ = listener.Close()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
+38
-32
@@ -84,38 +84,44 @@ type DownloadTask struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentURL string `json:"agent_url,omitempty"`
|
||||
AgentConfigured bool `json:"agent_configured"`
|
||||
SSHConfigured bool `json:"ssh_configured"`
|
||||
MgmtEnabled bool `json:"mgmt_enabled"`
|
||||
SSHHost string `json:"ssh_host,omitempty"`
|
||||
SSHPort int `json:"ssh_port,omitempty"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
SSHAuth string `json:"ssh_auth,omitempty"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
TLS bool `json:"tls_enabled"`
|
||||
TLSServerName string `json:"tls_server_name,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"has_token"`
|
||||
HasOIDCSecret bool `json:"has_oidc_client_secret"`
|
||||
DashboardHasPwd bool `json:"dashboard_has_pwd"`
|
||||
Options ServerOptions `json:"options"`
|
||||
BootStatus string `json:"boot_status"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
ProcessState string `json:"process_state"`
|
||||
ConnectionState string `json:"connection_state"`
|
||||
StateSource string `json:"state_source"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
LastConnectedAt string `json:"last_connected_at,omitempty"`
|
||||
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastStartedAt string `json:"last_started_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentURL string `json:"agent_url,omitempty"`
|
||||
AgentTransport string `json:"agent_transport"`
|
||||
AgentLocalPort int `json:"agent_local_port,omitempty"`
|
||||
AgentTLSPin string `json:"agent_tls_pin,omitempty"`
|
||||
AgentListenAddr string `json:"agent_listen_addr,omitempty"`
|
||||
AgentTransportState string `json:"agent_transport_state"`
|
||||
AgentTransportError string `json:"agent_transport_error,omitempty"`
|
||||
AgentConfigured bool `json:"agent_configured"`
|
||||
SSHConfigured bool `json:"ssh_configured"`
|
||||
MgmtEnabled bool `json:"mgmt_enabled"`
|
||||
SSHHost string `json:"ssh_host,omitempty"`
|
||||
SSHPort int `json:"ssh_port,omitempty"`
|
||||
SSHUser string `json:"ssh_user,omitempty"`
|
||||
SSHAuth string `json:"ssh_auth,omitempty"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
TLS bool `json:"tls_enabled"`
|
||||
TLSServerName string `json:"tls_server_name,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"has_token"`
|
||||
HasOIDCSecret bool `json:"has_oidc_client_secret"`
|
||||
DashboardHasPwd bool `json:"dashboard_has_pwd"`
|
||||
Options ServerOptions `json:"options"`
|
||||
BootStatus string `json:"boot_status"`
|
||||
DesiredState string `json:"desired_state"`
|
||||
ProcessState string `json:"process_state"`
|
||||
ConnectionState string `json:"connection_state"`
|
||||
StateSource string `json:"state_source"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
LastConnectedAt string `json:"last_connected_at,omitempty"`
|
||||
LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastStartedAt string `json:"last_started_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ServerRuntimeStatus struct {
|
||||
|
||||
+212
-5
@@ -49,6 +49,9 @@ type Service struct {
|
||||
agentMu sync.Mutex
|
||||
agentClients map[int64]cachedAgentClient
|
||||
|
||||
tunnelMu sync.Mutex
|
||||
tunnels map[int64]*agentTunnel
|
||||
|
||||
// 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
|
||||
diagMu sync.Mutex
|
||||
diagCache map[int64]diagnosticsCacheEntry
|
||||
@@ -79,6 +82,7 @@ func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *
|
||||
trafficClient: newFRPSTrafficClient(),
|
||||
trafficLast: map[int64]trafficSample{},
|
||||
diagCache: map[int64]diagnosticsCacheEntry{},
|
||||
tunnels: map[int64]*agentTunnel{},
|
||||
}
|
||||
return service
|
||||
}
|
||||
@@ -356,6 +360,10 @@ func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
|
||||
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) {
|
||||
@@ -629,10 +637,14 @@ func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (S
|
||||
if !server.Enabled {
|
||||
_ = s.managerForServer(id).Stop(ctx)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -732,10 +744,31 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
localPort := server.AgentLocalPort
|
||||
if localPort == 0 {
|
||||
localPort, err = s.allocateAgentTunnelPort(ctx)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
}
|
||||
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
|
||||
if err := tunnel.Start(); err != nil {
|
||||
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: fmt.Sprintf("http://%s:%d", server.SSHHost, 7700),
|
||||
Options: server.Options, AgentURL: baseURL,
|
||||
AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
|
||||
SSHAuth: server.SSHAuth, MgmtEnabled: true,
|
||||
}
|
||||
@@ -743,15 +776,178 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
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, "tunnel", localPort, "", "127.0.0.1:7700", "connected", ""); err != nil {
|
||||
tunnel.Stop()
|
||||
_ = (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
|
||||
return Server{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
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) allocateAgentTunnelPort(ctx context.Context) (int, error) {
|
||||
s.tunnelMu.Lock()
|
||||
defer s.tunnelMu.Unlock()
|
||||
servers, err := s.store.ListServers(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
used := map[int]bool{}
|
||||
for _, server := range servers {
|
||||
if server.AgentLocalPort > 0 {
|
||||
used[server.AgentLocalPort] = true
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
_ = listener.Close()
|
||||
return port, nil
|
||||
}
|
||||
return 0, 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 := server.AgentLocalPort
|
||||
if localPort == 0 {
|
||||
localPort, err = s.allocateAgentTunnelPort(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tunnel := newAgentTunnel(s.tunnelContext(ctx), serverID, localPort, installer)
|
||||
if err := tunnel.Start(); err != nil {
|
||||
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()
|
||||
if err := s.store.SetAgentTransport(ctx, serverID, "tunnel", localPort, server.AgentTLSPin, "127.0.0.1:7700", "connected", ""); err != nil {
|
||||
tunnel.Stop()
|
||||
return nil, err
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, "tunnel", current.AgentLocalPort, current.AgentTLSPin, current.AgentListenAddr, "failed", err.Error())
|
||||
return Server{}, err
|
||||
}
|
||||
server, err := s.store.GetServer(ctx, serverID)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
s.enrichServerRuntime(ctx, &server)
|
||||
return server, 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
|
||||
}
|
||||
return AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
|
||||
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) {
|
||||
@@ -1307,7 +1503,7 @@ type cachedAgentClient struct {
|
||||
|
||||
func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
|
||||
server, err := s.store.GetServer(ctx, serverID)
|
||||
if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured {
|
||||
if err != nil || !server.MgmtEnabled || !server.AgentConfigured {
|
||||
return nil, false
|
||||
}
|
||||
encoded, err := s.store.AgentToken(ctx, serverID)
|
||||
@@ -1318,8 +1514,18 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
|
||||
if err != nil || token == "" {
|
||||
return nil, false
|
||||
}
|
||||
baseURL := server.AgentURL
|
||||
if server.AgentTransport == "tunnel" {
|
||||
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 baseURL == "" {
|
||||
return nil, false
|
||||
}
|
||||
// 复用同一个 http.Client:批量同步 N 条路由时不再每次新建 Transport 与 TLS 握手
|
||||
fingerprint := server.AgentURL + "|" + encoded
|
||||
fingerprint := baseURL + "|" + encoded
|
||||
s.agentMu.Lock()
|
||||
defer s.agentMu.Unlock()
|
||||
if s.agentClients == nil {
|
||||
@@ -1328,7 +1534,7 @@ func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient
|
||||
if cached, ok := s.agentClients[serverID]; ok && cached.fingerprint == fingerprint {
|
||||
return cached.client, true
|
||||
}
|
||||
client := NewAgentClient(server.AgentURL, token)
|
||||
client := NewAgentClient(baseURL, token)
|
||||
s.agentClients[serverID] = cachedAgentClient{fingerprint: fingerprint, client: client}
|
||||
return client, true
|
||||
}
|
||||
@@ -1421,6 +1627,7 @@ func (s *Service) AutoStart(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Service) Stop(ctx context.Context) error {
|
||||
s.stopAgentTunnels()
|
||||
_ = s.ReconcileRuntime(ctx)
|
||||
servers, err := s.store.ListServers(ctx)
|
||||
if err != nil {
|
||||
|
||||
+21
-1
@@ -279,6 +279,24 @@ func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (s *Store) SetAgentTransport(ctx context.Context, id int64, transport string, localPort int, tlsPin, listenAddr, state, lastError string) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE frp_servers
|
||||
SET agent_transport = ?, agent_local_port = ?, agent_tls_pin = ?,
|
||||
agent_listen_addr = ?, agent_transport_state = ?, agent_transport_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, transport, localPort, tlsPin, listenAddr, state, lastError, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return fmt.Errorf("FRP 服务端不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Token(ctx context.Context, id int64) (string, error) {
|
||||
var token string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
|
||||
@@ -428,6 +446,8 @@ SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled
|
||||
COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END,
|
||||
COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
|
||||
CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
|
||||
COALESCE(agent_transport, 'http'), COALESCE(agent_local_port, 0), COALESCE(agent_tls_pin, ''),
|
||||
COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
|
||||
COALESCE(config_json, '{}'), created_at, updated_at
|
||||
FROM frp_servers`
|
||||
|
||||
@@ -439,7 +459,7 @@ func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
|
||||
var server Server
|
||||
var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
|
||||
var configJSON, created, updated string
|
||||
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &configJSON, &created, &updated)
|
||||
err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user