feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+428
View File
@@ -0,0 +1,428 @@
package frp
import (
"bytes"
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// AgentClient 调用公网 VPS 上的 havline-agent。凭据由调用方解密后只在内存中使用。
type AgentClient struct {
baseURL string
token string
pin string
http *http.Client
}
func NewAgentClient(baseURL, token string) *AgentClient {
return newAgentClient(baseURL, token, "")
}
func NewAgentClientWithPin(baseURL, token, pin string) *AgentClient {
return newAgentClient(baseURL, token, pin)
}
// NewAgentClientWithPins 支持证书轮换过渡期同时接受旧 pin 与新 pin。
func NewAgentClientWithPins(baseURL, token string, pins ...string) *AgentClient {
return newAgentClient(baseURL, token, pins...)
}
func newAgentClient(baseURL, token string, pins ...string) *AgentClient {
normalized := normalizePins(pins)
client := &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token,
pin: strings.Join(normalized, ","),
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second},
}
if len(normalized) > 0 {
client.http.Transport = &http.Transport{TLSClientConfig: pinTLSConfig(hostFromBaseURL(client.baseURL), normalized...)}
}
return client
}
func normalizePins(pins []string) []string {
out := make([]string, 0, len(pins))
for _, pin := range pins {
pin = strings.ToLower(strings.TrimSpace(pin))
if pin == "" {
continue
}
out = append(out, pin)
}
return out
}
func hostFromBaseURL(baseURL string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return parsed.Hostname()
}
func pinTLSConfig(host string, pins ...string) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("服务器未返回证书")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return fmt.Errorf("Agent HTTPS 证书不在有效期内")
}
if err := verifyCertificateHost(cert, host); err != nil {
return err
}
sum := sha256.Sum256(rawCerts[0])
got := hex.EncodeToString(sum[:])
for _, pin := range pins {
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) == 1 {
return nil
}
}
return fmt.Errorf("Agent HTTPS 证书指纹不匹配")
},
}
}
// verifyCertificateHost 校验证书 SAN 是否匹配连接主机,避免 pin 正确但证书属于其他主机。
func verifyCertificateHost(cert *x509.Certificate, host string) error {
host = strings.TrimSpace(host)
if host == "" {
return nil
}
if ip := net.ParseIP(host); ip != nil {
for _, candidate := range cert.IPAddresses {
if candidate.Equal(ip) {
return nil
}
}
return fmt.Errorf("Agent HTTPS 证书不包含 IP %s", host)
}
if err := cert.VerifyHostname(host); err != nil {
return fmt.Errorf("Agent HTTPS 证书 SAN 不匹配 %s", host)
}
return nil
}
func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
if c.baseURL == "" || c.token == "" {
return fmt.Errorf("公网 agent 未配置")
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &statusError{code: resp.StatusCode, body: strings.TrimSpace(string(data))}
}
if result != nil && len(data) > 0 {
if err := json.Unmarshal(data, result); err != nil {
return fmt.Errorf("agent 响应格式无效: %w", err)
}
}
return nil
}
func (c *AgentClient) Ping(ctx context.Context) error {
return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
}
func (c *AgentClient) ConfigureHTTPS(ctx context.Context, certPEM, keyPEM string, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPut, "/api/v1/transport/https", map[string]any{
"cert_pem": certPEM,
"key_pem": keyPEM,
"port": port,
}, &result)
return result, err
}
func (c *AgentClient) DisableHTTPS(ctx context.Context) error {
var result map[string]any
return c.request(ctx, http.MethodDelete, "/api/v1/transport/https", nil, &result)
}
// HTTPSState 读取 Agent 当前 HTTPS 反代状态与证书到期时间。
func (c *AgentClient) HTTPSState(ctx context.Context) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/transport/https", nil, &result)
return result, err
}
func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
return result, err
}
// FirewallStatus 读取 VPS 主机防火墙类型与目标端口放行状态。
func (c *AgentClient) FirewallStatus(ctx context.Context, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodGet, "/api/v1/firewall?port="+strconv.Itoa(port), nil, &result)
return result, err
}
// AllowFirewallPort 请求 Agent 显式放行主机防火墙端口(仅 ufw / firewalld)。
func (c *AgentClient) AllowFirewallPort(ctx context.Context, port int) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPost, "/api/v1/firewall/allow", map[string]any{"port": port}, &result)
return result, err
}
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
const agentMinVersionInsights = "0.11.0"
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
const agentMinVersionRouteVersions = "0.12.2"
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
// statusError 保留 HTTP 状态码,便于上层区分「端点不存在」与真实错误
type statusError struct {
code int
body string
}
func (e *statusError) Error() string {
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
}
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
func wrapEndpointMissing(err error, minVersion string) error {
var statusErr *statusError
if errors.As(err, &statusErr) && (statusErr.code == http.StatusNotFound || statusErr.code == http.StatusMethodNotAllowed) {
return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, minVersion)
}
return err
}
// Metrics 采集 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)
func (c *AgentClient) Metrics(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/metrics", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
// NginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部
func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (map[string]any, error) {
query := url.Values{}
query.Set("type", kind)
query.Set("lines", strconv.Itoa(lines))
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/logs/nginx?"+query.Encode(), nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
// Certs 列出 VPS 证书目录下的域名与到期时间
func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/certs", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
// NginxReload 显式校验并重载 Nginx,返回校验 / 重载结果与失败原因
func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/reload", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/frps/config", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
}
return result, nil
}
func (c *AgentClient) SyncRoute(ctx context.Context, domain, upstream string, tls bool, certDir string) error {
_, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "")
return err
}
// RouteSpec 公网反代单域名部署参数(字段名与 agent routeRequest 的 JSON key 严格一致)。
type RouteSpec struct {
Domain, Upstream, CertDir string
TLS, WebSocket, RedirectHTTPS bool
HTTPSDisabled, SecurityHeaders bool
TLS13Only bool
VhostPort int
AllowIPs, DenyIPs []string
BasicAuthUser, BasicAuthPassword string
ClientMaxBodySize, ProxyReadTimeout string
RateLimitRate, RateLimitBurst, ConnLimitMax int
ChinaOnly bool
}
// SyncRouteSpec 部署路由并要求回源自检;位置参数已到上限,新增选项一律走本方法。
func (c *AgentClient) SyncRouteSpec(ctx context.Context, spec RouteSpec) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPut, "/api/v1/routes", map[string]any{
"domain": spec.Domain, "upstream": spec.Upstream, "tls": spec.TLS, "cert_dir": spec.CertDir,
"websocket": spec.WebSocket, "vhost_port": spec.VhostPort,
"redirect_https": spec.RedirectHTTPS, "allow_ips": spec.AllowIPs, "basic_auth_user": spec.BasicAuthUser,
"deny_ips": spec.DenyIPs, "basic_auth_password": spec.BasicAuthPassword,
"https_disabled": spec.HTTPSDisabled, "client_max_body_size": spec.ClientMaxBodySize,
"proxy_read_timeout": spec.ProxyReadTimeout, "security_headers": spec.SecurityHeaders,
"tls13_only": spec.TLS13Only,
"rate_limit_rate": spec.RateLimitRate, "rate_limit_burst": spec.RateLimitBurst,
"conn_limit_max": spec.ConnLimitMax, "china_only": spec.ChinaOnly,
}, &result)
return result, err
}
// InstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
func (c *AgentClient) InstallNginx(ctx context.Context) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPost, "/api/v1/nginx/install", nil, &result)
return result, err
}
// SyncChinaCIDR 把中国 IP 段(geo include 格式)下发到公网 agent。
func (c *AgentClient) SyncChinaCIDR(ctx context.Context, content string) error {
var result map[string]any
return c.request(ctx, http.MethodPut, "/api/v1/china-cidr", map[string]any{"content": content}, &result)
}
// SyncRouteEx 保留的位置参数兼容包装(新代码请用 SyncRouteSpec)。
func (c *AgentClient) SyncRouteEx(ctx context.Context, domain, upstream string, tls bool, certDir string, websocket bool, vhostPort int, redirectHTTPS bool, allowIPs []string, basicAuthUser string) (map[string]any, error) {
return c.SyncRouteSpec(ctx, RouteSpec{
Domain: domain, Upstream: upstream, TLS: tls, CertDir: certDir,
WebSocket: websocket, VhostPort: vhostPort, RedirectHTTPS: redirectHTTPS,
AllowIPs: allowIPs, BasicAuthUser: basicAuthUser,
})
}
// RouteConf 返回指定域名当前反代配置内容。
func (c *AgentClient) RouteConf(ctx context.Context, domain string) (string, error) {
var result struct {
Domain string `json:"domain"`
Content string `json:"content"`
}
if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/conf", nil, &result); err != nil {
return "", err
}
return result.Content, nil
}
// RouteDetail 单条公网反代路由的真实状态。
type RouteDetail struct {
Domain string `json:"domain"`
FileExists bool `json:"file_exists"`
SyntaxOK bool `json:"syntax_ok"`
Loaded bool `json:"loaded"`
}
// RoutesDetail 返回全部公网反代路由的状态明细(文件/语法/被 Nginx 加载)。
func (c *AgentClient) RoutesDetail(ctx context.Context) ([]RouteDetail, error) {
var result []RouteDetail
err := c.request(ctx, http.MethodGet, "/api/v1/routes", nil, &result)
return result, err
}
func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
}
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
}
func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
var result map[string]any
err := c.request(ctx, http.MethodPost, "/api/v1/frps/install", map[string]string{"version": version, "proxy": proxy}, &result)
return result, err
}
func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error {
return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
}
func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
}, nil)
}
// RouteVersions 返回域名当前 vhost 内容与历史版本列表(需 agent ≥ 0.12.2)。
func (c *AgentClient) RouteVersions(ctx context.Context, domain string) (map[string]any, error) {
var result map[string]any
if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/versions", nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
// RouteVersion 读取某个历史版本的内容,供与当前 vhost 做差异对比。
func (c *AgentClient) RouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
var result map[string]any
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name)
if err := c.request(ctx, http.MethodGet, path, nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
// RollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本。
func (c *AgentClient) RollbackRouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
var result map[string]any
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name) + "/rollback"
if err := c.request(ctx, http.MethodPost, path, nil, &result); err != nil {
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
}
return result, nil
}
+72
View File
@@ -0,0 +1,72 @@
package frp
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
type agentCertificate struct {
CertPEM string
KeyPEM string
Pin string
NotAfter time.Time
}
func generateAgentCertificate(host string) (agentCertificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return agentCertificate{}, err
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return agentCertificate{}, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return agentCertificate{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return agentCertificate{}, err
}
sum := sha256.Sum256(der)
return agentCertificate{
CertPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Pin: hex.EncodeToString(sum[:]),
NotAfter: template.NotAfter,
}, nil
}
func validatePin(pin string) error {
decoded, err := hex.DecodeString(pin)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("证书指纹格式无效")
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
package frp
import (
"crypto/x509"
"encoding/pem"
"net"
"strings"
"testing"
"time"
)
func TestGenerateAgentCertificate(t *testing.T) {
cert, err := generateAgentCertificate("127.0.0.1")
if err != nil {
t.Fatal(err)
}
if err := validatePin(cert.Pin); err != nil {
t.Fatalf("pin 无效: %v", err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
parsed, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
if len(parsed.IPAddresses) != 1 || !parsed.IPAddresses[0].Equal(net.ParseIP("127.0.0.1")) {
t.Fatalf("证书 IP SAN 不正确: %#v", parsed.IPAddresses)
}
}
func TestPinTLSConfigVerification(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("agent.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("正确 pin 校验失败: %v", err)
}
wrong := pinTLSConfig("agent.example.com", strings.Repeat("0", 64)).VerifyPeerCertificate
if err := wrong([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("错误 pin 应校验失败")
}
}
func TestPinTLSConfigAcceptsGracePins(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("agent.example.com", strings.Repeat("1", 64), cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err != nil {
t.Fatalf("轮换过渡期应同时接受旧 pin 与新 pin: %v", err)
}
}
func TestPinTLSConfigRejectsHostMismatch(t *testing.T) {
cert, err := generateAgentCertificate("agent.example.com")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(cert.CertPEM))
if block == nil {
t.Fatal("证书 PEM 解码失败")
}
verify := pinTLSConfig("other.example.com", cert.Pin).VerifyPeerCertificate
if err := verify([][]byte{block.Bytes}, nil); err == nil {
t.Fatal("证书 SAN 与连接主机不一致时应拒绝")
}
}
func TestAgentHTTPSBaseURL(t *testing.T) {
cases := []struct {
name string
server Server
want string
}{
{
name: "旧隧道地址按监听端口重建",
server: Server{AgentURL: "http://127.0.0.1:17701", SSHHost: "agent.example.com", AgentListenAddr: "0.0.0.0:7443"},
want: "https://agent.example.com:7443",
},
{
name: "已是 HTTPS 地址保持不变",
server: Server{AgentURL: "https://agent.example.com:8443", SSHHost: "agent.example.com", AgentListenAddr: "0.0.0.0:7443"},
want: "https://agent.example.com:8443",
},
{
name: "监听地址缺少端口时使用默认端口",
server: Server{AgentURL: "http://127.0.0.1:17701", SSHHost: "agent.example.com"},
want: "https://agent.example.com:7443",
},
{
name: "缺少 SSH 主机时保留原地址",
server: Server{AgentURL: "http://127.0.0.1:17701"},
want: "http://127.0.0.1:17701",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := agentHTTPSBaseURL(tc.server); got != tc.want {
t.Fatalf("agentHTTPSBaseURL() = %q, 期望 %q", got, tc.want)
}
})
}
}
func TestAgentTransportPins(t *testing.T) {
active := Server{
AgentTLSPin: "new",
AgentTLSPinPrev: "old",
AgentTLSPinPrevUntil: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
}
pins := agentTransportPins(active)
if len(pins) != 2 || pins[0] != "new" || pins[1] != "old" {
t.Fatalf("过渡期应同时返回新旧 pin:%v", pins)
}
expired := active
expired.AgentTLSPinPrevUntil = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if pins := agentTransportPins(expired); len(pins) != 1 || pins[0] != "new" {
t.Fatalf("过渡期结束后只应保留新 pin:%v", pins)
}
}
func TestResultPortAndListenPort(t *testing.T) {
if got := resultPort(map[string]any{"port": float64(8443)}, 0); got != 8443 {
t.Fatalf("resultPort() = %d, 期望 8443", got)
}
if got := resultPort(nil, 7443); got != 7443 {
t.Fatalf("resultPort() 回退值 = %d, 期望 7443", got)
}
if got := listenPort("0.0.0.0:9443", 7443); got != 9443 {
t.Fatalf("listenPort() = %d, 期望 9443", got)
}
if got := listenPort("", 7443); got != 7443 {
t.Fatalf("listenPort() 回退值 = %d, 期望 7443", got)
}
}
+60
View File
@@ -0,0 +1,60 @@
package frp
import (
"context"
"fmt"
)
// 以下三个方法对应 agent 0.10.0 新增的只读端点;老版本 agent 会返回
// 「agent 版本过低」的提示(见 AgentClient 的 wrapEndpointMissing),而不是原始 HTTP 错误。
// AgentMetrics 返回 VPS 主机资源(CPU / 内存 / 磁盘 / 负载 / 运行时长)
func (s *Service) AgentMetrics(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.Metrics(ctx)
}
// AgentNginxLogs 读取 VPS 上 Nginx 的 access / error 日志尾部
func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind string, lines int) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
if kind != "access" && kind != "error" {
kind = "access"
}
if lines <= 0 || lines > 2000 {
lines = 200
}
return client.NginxLogs(ctx, kind, lines)
}
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.Certs(ctx)
}
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.NginxReload(ctx)
}
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[string]any, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
return client.FRPSConfig(ctx)
}
+195
View File
@@ -0,0 +1,195 @@
package frp
import (
"context"
"fmt"
"strings"
"github.com/havline/havline/internal/frp/agentbin"
)
// AgentInstaller 负责把内嵌的 havline-agent 通过 SSH 装到 VPS 上,并收集安装结果。
type AgentInstaller struct {
SSH SSHInstaller
}
// Probe 返回 VPS 环境摘要:架构、nginx 是否安装、agent 是否已装、systemd 可用性。
func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
client, err := i.SSH.client(ctx)
if err != nil {
return nil, fmt.Errorf("SSH 连接失败: %w", err)
}
defer client.Close()
out := func(cmd string) string {
v, _ := runSSHSession(client, cmd)
return strings.TrimSpace(v)
}
arch := out("uname -m")
return map[string]any{
"arch": arch,
"nginx_conf": out(`conf=$(nginx -V 2>&1 | grep -o '\-\-conf-path=[^ ]*' | head -1 | cut -d= -f2-); [ -z "$conf" ] && conf=/etc/nginx/nginx.conf; grep '^[[:space:]]*include' "$conf" 2>/dev/null | grep -m1 -E 'conf\.d|sites-enabled|vhost/nginx' || ls -d /etc/nginx/conf.d 2>/dev/null`),
"agent_exist": out("test -x /usr/local/bin/havline-agent && echo yes || echo no") == "yes",
"agent_running": out("systemctl is-active havline-agent 2>/dev/null || true"),
"agent_version": out("/usr/local/bin/havline-agent -version 2>/dev/null || echo 未知"),
// Token 只回「是否已配置 + 末 4 位」:明文不经过 HTTP 返回前端(库里同一字段也是掩码)
"agent_token_configured": out("test -s /etc/havline-agent/agent.env && grep -q '^HAVLINE_AGENT_TOKEN=' /etc/havline-agent/agent.env && echo yes || echo no") == "yes",
"agent_token_hint": out("sed -n 's/^HAVLINE_AGENT_TOKEN=//p' /etc/havline-agent/agent.env 2>/dev/null | tr -d '\\n' | tail -c 4"),
"frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
"frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
"frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
"frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
"frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
"port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
"port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
"port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
}, nil
}
// Install 上传内嵌二进制并执行安装脚本;重复执行等于覆盖升级。返回 agent Token。
func (i AgentInstaller) Install(ctx context.Context) (string, error) {
arch, err := i.SSH.Run(ctx, "uname -m")
if err != nil {
return "", fmt.Errorf("探测架构失败: %w", err)
}
binary, ok := agentbin.BinaryForArch(strings.TrimSpace(arch))
if !ok {
return "", fmt.Errorf("不支持的 VPS 架构: %s", strings.TrimSpace(arch))
}
// 开发环境(go run / 非 Docker 构建)里内嵌的是占位文件;提前报错而不是让 VPS 上 systemd 反复 Exec format error
if !isELFBinary(binary) {
return "", fmt.Errorf("内嵌 agent 二进制无效(开发构建占位文件)。请用 Docker 构建或先执行构建脚本生成真实 agent:go build -o internal/frp/agentbin/havline-agent-linux-amd64 ./cmd/havline-agent(GOOS=linux GOARCH=amd64)")
}
script, err := agentInstallScript()
if err != nil {
return "", err
}
output, err := i.SSH.Install(ctx, binary, script)
if err != nil {
return "", err
}
token := extractToken(output)
if token == "" {
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
}
return token, nil
}
// Uninstall 停止并移除 agent 服务;--keep-data 保留路由与证书。
func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, error) {
command := "/usr/local/bin/havline-agent-uninstall"
if keepData {
command += " --keep-data"
}
out, err := i.SSH.Run(ctx, fmt.Sprintf("if [ -x %s ]; then %s; else echo 'agent 未安装'; fi", "'"+command+"'", command))
return strings.TrimSpace(out), err
}
// RestorePreviousConfig 在安全迁移验证失败时恢复安装前的 agent.env,并重启旧监听配置。
func (i AgentInstaller) RestorePreviousConfig(ctx context.Context) error {
command := `if [ -f /etc/havline-agent/agent.env.previous ]; then cp /etc/havline-agent/agent.env.previous /etc/havline-agent/agent.env && chmod 600 /etc/havline-agent/agent.env && systemctl restart havline-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 havline-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u havline-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
}
func extractToken(output string) string {
for _, line := range strings.Split(output, "\n") {
if token, found := strings.CutPrefix(strings.TrimSpace(line), "Token: "); found {
return strings.TrimSpace(token)
}
}
return ""
}
func agentInstallScript() ([]byte, error) {
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
return []byte(`#!/usr/bin/env bash
set -eu
if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 或 sudo 运行" >&2; exit 1; fi
AGENT_BINARY=${1:-/tmp/havline-agent}
PREFIX=/usr/local/bin/havline-agent
CONF_DIR=/etc/havline-agent
CONF_FILE="$CONF_DIR/agent.env"
UNIT=/etc/systemd/system/havline-agent.service
if [ "${1:-}" = "--uninstall" ]; then
systemctl stop havline-agent 2>/dev/null || true
systemctl disable havline-agent 2>/dev/null || true
rm -f "$UNIT" "$PREFIX" /usr/local/bin/havline-agent-uninstall
systemctl daemon-reload
if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/havline-agent; fi
echo "havline-agent 已卸载"
exit 0
fi
mkdir -p "$CONF_DIR" /var/lib/havline-agent/certs
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
systemctl stop havline-agent 2>/dev/null || true
TMP_PREFIX="${PREFIX}.new"
# 迁移旧版 Agent 留在 nginx include 范围内的 <配置>.versions 目录(保留历史版本内容)
for NGINX_DIR in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
[ -d "$NGINX_DIR" ] || continue
for LEGACY in "$NGINX_DIR"/*.versions; do
[ -d "$LEGACY" ] || continue
TARGET="$NGINX_DIR/.$(basename "$LEGACY")"
[ -e "$TARGET" ] || mv "$LEGACY" "$TARGET" || true
done
done
install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
if [ ! -x "$TMP_PREFIX" ]; then
echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
exit 1
fi
AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
echo "agent 二进制无法执行:$TMP_PREFIX" >&2
rm -f "$TMP_PREFIX"
exit 1
}
mv -f "$TMP_PREFIX" "$PREFIX"
if [ ! -x "$PREFIX" ]; then
echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
exit 1
fi
printf 'agent version: %s\n' "$AGENT_VERSION"
if [ ! -f "$CONF_FILE" ]; then
umask 077
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Havline 无法认证 agent)
printf 'HAVLINE_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 '/^HAVLINE_AGENT_LISTEN=/d; /^HAVLINE_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
printf 'HAVLINE_AGENT_LISTEN=127.0.0.1:7700\nHAVLINE_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
cat > "$UNIT" <<'UNIT'
[Unit]
Description=Havline FRPS management agent
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/havline-agent/agent.env
ExecStart=/usr/local/bin/havline-agent
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl restart havline-agent
systemctl enable havline-agent
cp "$0" /usr/local/bin/havline-agent-uninstall
echo "havline-agent installed"
echo "Token: $(sed -n 's/^HAVLINE_AGENT_TOKEN=//p' "$CONF_FILE")"
`), nil
}
// isELFBinary 判断内容是否为 Linux ELF 可执行(魔数 0x7F 'ELF'),用于拦截开发构建的占位文件。
func isELFBinary(data []byte) bool {
return len(data) > 4 && data[0] == 0x7F && data[1] == 'E' && data[2] == 'L' && data[3] == 'F'
}
+239
View File
@@ -0,0 +1,239 @@
package frp
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
const agentRemoteAddr = "127.0.0.1:7700"
const (
agentTunnelKeepalive = 30 * time.Second
agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
)
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 {
return newAgentTunnelWithListener(parent, serverID, localPort, installer, nil)
}
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
ctx, cancel := context.WithCancel(parent)
return &agentTunnel{
serverID: serverID,
localPort: localPort,
ssh: installer,
ctx: ctx,
cancel: cancel,
listener: listener,
state: "connecting",
}
}
func (t *agentTunnel) Start() error {
if t.listener == nil {
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.listener = listener
}
t.mu.Lock()
t.state = "connected"
t.lastError = ""
t.mu.Unlock()
t.wg.Add(2)
go t.acceptLoop()
go t.monitorLoop()
return nil
}
// monitorLoop 主动保活并在断开后按指数退避重建 SSH 连接,避免只在有流量时才尝试重连。
func (t *agentTunnel) monitorLoop() {
defer t.wg.Done()
backoff := agentTunnelBackoffMin
for {
_, err := t.ensureClient()
wait := agentTunnelKeepalive
if err != nil {
t.setState("reconnecting", err)
backoff = nextTunnelBackoff(backoff)
wait = backoff
} else {
t.setState("connected", nil)
backoff = agentTunnelBackoffMin
}
if !t.sleep(wait) {
return
}
}
}
func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-t.ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextTunnelBackoff(current time.Duration) time.Duration {
if current < agentTunnelBackoffMin {
return agentTunnelBackoffMin
}
next := current * 2
if next > agentTunnelBackoffMax {
return agentTunnelBackoffMax
}
return next
}
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()
}
+53
View File
@@ -0,0 +1,53 @@
package frp
import (
"context"
"fmt"
"net"
"testing"
"time"
)
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" && state != "reconnecting" {
t.Fatalf("state=%q, want connected 或 reconnecting", 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()
}
}
func TestNextTunnelBackoff(t *testing.T) {
cases := []struct {
in time.Duration
want time.Duration
}{
{0, agentTunnelBackoffMin},
{time.Second, 2 * time.Second},
{40 * time.Second, agentTunnelBackoffMax},
{agentTunnelBackoffMax, agentTunnelBackoffMax},
}
for _, tc := range cases {
if got := nextTunnelBackoff(tc.in); got != tc.want {
t.Fatalf("nextTunnelBackoff(%v) = %v, 期望 %v", tc.in, got, tc.want)
}
}
}
@@ -0,0 +1,73 @@
package frp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestAgentEndpointMissingDetection 锁定旧 Agent 的 404 / 405 兼容提示,且不把其它错误误判为版本过低。
func TestAgentEndpointMissingDetection(t *testing.T) {
cases := []struct {
name string
status int
minVersion string
route bool
wantMissing bool
}{
{"404 判定版本过低", http.StatusNotFound, agentMinVersionInsights, false, true},
{"405 判定版本过低", http.StatusMethodNotAllowed, agentMinVersionRouteVersions, true, true},
{"500 不判定版本过低", http.StatusInternalServerError, agentMinVersionInsights, false, false},
{"400 不判定版本过低", http.StatusBadRequest, agentMinVersionInsights, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(`{"error":"test"}`))
}))
defer server.Close()
client := NewAgentClient(server.URL, "token")
var err error
if tc.route {
_, err = client.RouteVersions(context.Background(), "example.com")
} else {
_, err = client.Metrics(context.Background())
}
if tc.wantMissing {
if !errors.Is(err, ErrAgentEndpointMissing) {
t.Fatalf("应判定为端点缺失:%v", err)
}
msg := err.Error()
if !strings.Contains(msg, tc.minVersion) || !strings.Contains(msg, "升级 Agent") {
t.Fatalf("错误信息应包含最低版本与升级提示:%q", msg)
}
return
}
if errors.Is(err, ErrAgentEndpointMissing) {
t.Fatalf("不应误判为版本过低:%v", err)
}
if err == nil {
t.Fatal("应返回错误")
}
})
}
}
func TestAgentStatusVersionField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"agent_version":"0.10.0","listen_addr":"127.0.0.1:7700"}`))
}))
defer server.Close()
client := NewAgentClient(server.URL, "token")
status, err := client.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if status["agent_version"] != "0.10.0" {
t.Fatalf("agent_version 解析失败:%#v", status)
}
}
+42
View File
@@ -0,0 +1,42 @@
package frp
import (
"strings"
"testing"
)
// 官方校验文件名不带版本号;这里钉住候选顺序,避免以后又被改回不存在的名字。
func TestChecksumAssetCandidates(t *testing.T) {
got := checksumAssetCandidates("0.71.0")
if len(got) < 2 {
t.Fatalf("应至少有一个候选与一个兜底,实际 %v", got)
}
if got[0] != "frp_sha256_checksums.txt" {
t.Fatalf("首选应为官方现行名字,实际 %q", got[0])
}
if got[1] != "frp_0.71.0_sha256sums.txt" {
t.Fatalf("兜底名字应带版本号,实际 %q", got[1])
}
}
// 解析真实格式:<hash> <文件名>(也兼容 * 前缀与多余空白)
func TestFindChecksumLine(t *testing.T) {
content := []byte(strings.Join([]string{
"845b486c63686990e671f13cc5e3bd130ce7be659ab3c6f043008353451858cb frp_0.71.0_android_arm64.tar.gz",
"84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716 frp_0.71.0_linux_amd64.tar.gz",
"96b8a4a09d7cd6a5ea0e1f9f0b1e4a35b0e0f0b4a4f7c0b0e0f0b4a4f7c0b0e0 *frp_0.71.0_linux_arm64.tar.gz",
"",
}, "\n"))
sum, ok := findChecksumLine(content, "frp_0.71.0_linux_amd64.tar.gz")
if !ok || sum != "84f27e39f11169f7adcef8e8b70c9329de17747b1f14dad9fb95eef5682ea716" {
t.Fatalf("未解析出 linux_amd64 的哈希:%q ok=%v", sum, ok)
}
sum, ok = findChecksumLine(content, "frp_0.71.0_linux_arm64.tar.gz")
if !ok || sum != "96b8a4a09d7cd6a5ea0e1f9f0b1e4a35b0e0f0b4a4f7c0b0e0f0b4a4f7c0b0e0" {
t.Fatalf("带 * 前缀的文件名应能匹配:%q ok=%v", sum, ok)
}
if _, ok := findChecksumLine(content, "frp_0.71.0_windows_amd64.zip"); ok {
t.Fatal("不存在的文件不应匹配到哈希")
}
}
+247
View File
@@ -0,0 +1,247 @@
package frp
import (
"fmt"
"sort"
"strconv"
"strings"
)
type renderedConfig struct {
Server Server
Token string
OIDCClientSecret string
LogPath string
TLSCertPath string
TLSKeyPath string
TLSTrustedCAPath string
Proxies []Proxy
}
func renderTOML(cfg renderedConfig) string {
var out strings.Builder
writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
options := normalizeServerOptions(cfg.Server.Options)
if options.User != "" {
writeKeyValue(&out, "user", options.User)
}
if options.AuthMethod != "none" {
writeKeyValue(&out, "auth.method", options.AuthMethod)
}
if options.AuthMethod == "token" {
writeKeyValue(&out, "auth.token", cfg.Token)
}
if options.AuthMethod == "oidc" {
writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
if options.OIDCAudience != "" {
writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
}
if options.OIDCScope != "" {
writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
}
}
if cfg.LogPath != "" {
writeKeyValue(&out, "log.to", cfg.LogPath)
}
writeKeyValue(&out, "log.level", options.LogLevel)
writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
writeKeyValue(&out, "transport.protocol", options.Protocol)
writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
if cfg.Server.TLSServerName != "" {
writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
}
if cfg.TLSCertPath != "" {
writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
}
if cfg.TLSKeyPath != "" {
writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
}
if cfg.TLSTrustedCAPath != "" {
writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
}
if options.ProxyURL != "" {
writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
}
writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
if options.ConnectServerLocalIP != "" {
writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
}
if options.DNSServer != "" {
writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
}
writeKeyValue(&out, "transport.poolCount", options.PoolCount)
writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
for _, proxy := range cfg.Proxies {
out.WriteString("\n[[proxies]]\n")
writeKeyValue(&out, "name", proxy.Name)
writeKeyValue(&out, "type", proxy.Type)
writeKeyValue(&out, "localIP", proxy.LocalIP)
writeKeyValue(&out, "localPort", proxy.LocalPort)
if proxy.Type == "tcp" && proxy.RemotePort != nil {
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
}
if proxy.Type == "udp" && proxy.RemotePort != nil {
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
}
if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
writeStringSlice(&out, "customDomains", proxy.CustomDomains)
}
if proxy.HostHeaderRewrite != "" {
writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
}
writeProxyOptions(&out, proxy)
}
return out.String()
}
func writeProxyOptions(out *strings.Builder, proxy Proxy) {
opts := proxy.Options
if opts.Subdomain != "" {
writeKeyValue(out, "subdomain", opts.Subdomain)
}
if len(opts.Locations) > 0 {
writeStringSlice(out, "locations", opts.Locations)
}
if opts.RouteByHTTPUser != "" {
writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
}
if opts.HTTPUser != "" {
writeKeyValue(out, "httpUser", opts.HTTPUser)
}
if opts.HTTPPassword != "" {
writeKeyValue(out, "httpPassword", opts.HTTPPassword)
}
if opts.Multiplexer != "" {
writeKeyValue(out, "multiplexer", opts.Multiplexer)
}
if opts.SecretKey != "" {
writeKeyValue(out, "secretKey", opts.SecretKey)
}
if len(opts.AllowUsers) > 0 {
writeStringSlice(out, "allowUsers", opts.AllowUsers)
}
writeProxyTransport(out, opts.Transport)
writeProxyHealthCheck(out, opts.HealthCheck)
if opts.LoadBalancer.Group != "" {
writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
}
if opts.LoadBalancer.GroupKey != "" {
writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
}
writeStringMap(out, "metadatas", opts.Metadatas)
writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
// natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
// disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
}
if opts.Plugin != nil {
writeProxyPlugin(out, *opts.Plugin)
}
}
func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
if opts.BandwidthLimit != "" {
writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
}
if opts.BandwidthLimitMode != "" {
writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
}
if opts.UseEncryption {
writeKeyValue(out, "transport.useEncryption", true)
}
if opts.UseCompression {
writeKeyValue(out, "transport.useCompression", true)
}
if opts.ProxyProtocolVersion != "" {
writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
}
}
func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
if opts.Type == "" {
return
}
writeKeyValue(out, "healthCheck.type", opts.Type)
if opts.Path != "" {
writeKeyValue(out, "healthCheck.path", opts.Path)
}
if opts.IntervalSeconds > 0 {
writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
}
if opts.MaxFailed > 0 {
writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
}
if opts.TimeoutSeconds > 0 {
writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
}
if len(opts.HTTPHeaders) > 0 {
values := make([]string, 0, len(opts.HTTPHeaders))
for _, header := range opts.HTTPHeaders {
values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
}
fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
}
}
func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
out.WriteString("[proxies.plugin]\n")
writeKeyValue(out, "type", plugin.Type)
for _, item := range []struct{ key, value string }{
{"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
{"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
{"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
{"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
} {
if item.value != "" {
writeKeyValue(out, item.key, item.value)
}
}
}
func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if key != "" {
writeKeyValue(out, prefix+"."+key, values[key])
}
}
}
func renderMaskedTOML(server Server, proxies []Proxy) string {
return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
}
func writeKeyValue(out *strings.Builder, key string, value any) {
switch v := value.(type) {
case string:
fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
case int:
fmt.Fprintf(out, "%s = %d\n", key, v)
case bool:
fmt.Fprintf(out, "%s = %t\n", key, v)
}
}
func writeStringSlice(out *strings.Builder, key string, values []string) {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, strconv.Quote(value))
}
fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
}
+65
View File
@@ -0,0 +1,65 @@
package frp
import (
"strings"
"testing"
)
func TestRenderTOML(t *testing.T) {
remotePort := 60022
content := renderTOML(renderedConfig{
Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
Token: "token with \"quotes\"",
Proxies: []Proxy{
{Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
},
})
for _, expected := range []string{
`serverAddr = "vps.example.com"`,
`auth.token = "token with \"quotes\""`,
`remotePort = 60022`,
`customDomains = ["nas.example.com"]`,
} {
if !strings.Contains(content, expected) {
t.Fatalf("配置缺少 %q:\n%s", expected, content)
}
}
}
func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
if !strings.Contains(content, `auth.token = "********"`) {
t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
}
}
func TestRenderProxyOptions(t *testing.T) {
content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
{Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
}})
for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
if !strings.Contains(content, expected) {
t.Fatalf("配置缺少 %q:\n%s", expected, content)
}
}
}
func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
omitted := renderTOML(renderedConfig{Proxies: []Proxy{
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
}})
if strings.Contains(omitted, "natTraversal") {
t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
}
enabled := renderTOML(renderedConfig{Proxies: []Proxy{
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
}})
if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
}
}
func intPtr(value int) *int { return &value }
+46
View File
@@ -0,0 +1,46 @@
package frp
import (
"os"
"strings"
)
const maxConnectionLogRead = 128 << 10
func inspectConnectionLog(path, processState string) (string, string) {
if processState != "running" {
if processState == "error" {
return "error", "frpc 进程已退出"
}
return "disconnected", ""
}
data, err := os.ReadFile(path)
if err != nil {
return "unknown", ""
}
if len(data) > maxConnectionLogRead {
data = data[len(data)-maxConnectionLogRead:]
}
lines := strings.Split(string(data), "\n")
for index := len(lines) - 1; index >= 0; index-- {
line := strings.TrimSpace(lines[index])
lower := strings.ToLower(line)
switch {
case strings.Contains(lower, "login to server success"):
return "connected", ""
case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
return "error", trimConnectionError(line)
case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
return "disconnected", ""
}
}
return "unknown", ""
}
func trimConnectionError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 500 {
return value[:500]
}
return value
}
+251
View File
@@ -0,0 +1,251 @@
package frp
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
)
// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
const (
diagStateOK = "ok"
diagStateFailed = "failed"
diagStateUnknown = "unknown"
diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second
diagnosticsMaxInFlight = 4
)
type diagnosticsCacheEntry struct {
diagnostics ServerDiagnostics
expiresAt time.Time
}
// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return ServerDiagnostics{}, err
}
return s.runDiagnostics(ctx, server), nil
}
func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
s.diagMu.Lock()
if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
cached := entry.diagnostics
s.diagMu.Unlock()
return cached
}
// 并发上限:超限时返回旧结果或 unknown,不排队等待
if s.diagInFlight >= diagnosticsMaxInFlight {
cached, ok := s.diagCache[server.ID]
s.diagMu.Unlock()
if ok {
return cached.diagnostics
}
return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
}
s.diagInFlight++
s.diagMu.Unlock()
defer func() {
s.diagMu.Lock()
s.diagInFlight--
s.diagMu.Unlock()
}()
result := s.computeDiagnostics(ctx, server)
s.diagMu.Lock()
s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
s.diagMu.Unlock()
return result
}
// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
// 绝不写入 Error(Error 仅表示控制端口探测失败)。
func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
diagnostics := ServerDiagnostics{
ServerID: server.ID,
Available: tcpCheck.State == diagStateOK,
LatencyMS: tcpCheck.LatencyMS,
Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
}
if tcpCheck.State == diagStateFailed {
diagnostics.Error = tcpCheck.Detail
}
// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
// 不让列表页同步等待外部定位 API。
location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
diagnostics.Location = location
locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
if !resolved {
locationCheck.State = diagStateUnknown
locationCheck.Detail = "定位信息后台获取中"
go s.refreshLocation(server.ServerAddr)
}
diagnostics.Checks = append(diagnostics.Checks, locationCheck)
s.store.AddServerDiagnostic(ctx, diagnostics)
return diagnostics
}
// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
check := DiagnosticCheck{Name: "tcp_reachable"}
if dnsCheck.State == diagStateFailed {
check.State = diagStateFailed
check.Detail = "已跳过:" + dnsCheck.Detail
return check
}
dialer := net.Dialer{Timeout: diagTCPTimeout}
started := time.Now()
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
check.LatencyMS = time.Since(started).Milliseconds()
if check.LatencyMS < 1 {
check.LatencyMS = 1
}
if err != nil {
check.State = diagStateFailed
check.Detail = diagnoseDialError(err)
return check
}
_ = conn.Close()
check.State = diagStateOK
check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
return check
}
// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
check := DiagnosticCheck{Name: "dns_resolution"}
if ip := net.ParseIP(host); ip != nil {
check.State = diagStateOK
check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
return check
}
resolver := net.DefaultResolver
ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
defer cancel()
started := time.Now()
addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
elapsed := time.Since(started).Milliseconds()
if err != nil || len(addresses) == 0 {
check.State = diagStateFailed
check.Detail = "域名解析失败:" + diagnoseResolveError(err)
return check
}
var ipv4, ipv6 []string
for _, address := range addresses {
if address.IP.To4() != nil {
ipv4 = append(ipv4, address.IP.String())
} else {
ipv6 = append(ipv6, address.IP.String())
}
}
summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
if len(ipv6) > 0 {
summary += ";AAAA=" + strings.Join(ipv6, ", ")
} else {
summary += ";AAAA=无"
}
check.LatencyMS = elapsed
check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
return check
}
func diagnoseResolveError(err error) string {
if err == nil {
return "无解析结果"
}
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return "域名不存在"
}
return err.Error()
}
// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
check := DiagnosticCheck{Name: "frpc_login"}
data, err := os.ReadFile(s.serverLogPath(serverID))
if os.IsNotExist(err) {
check.State = diagStateUnknown
check.Detail = "frpc 尚未运行,无登录记录"
return check
}
if err != nil {
check.State = diagStateUnknown
check.Detail = "读取 frpc 日志失败"
return check
}
state, detail := summarizeFRPCLogin(string(data))
check.State = state
check.Detail = detail
return check
}
// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
func summarizeFRPCLogin(log string) (string, string) {
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
const window = 200
if len(lines) > window {
lines = lines[len(lines)-window:]
}
failureDetail := ""
for index := len(lines) - 1; index >= 0; index-- {
line := lines[index]
if prefix := strings.Index(line, "] "); prefix >= 0 {
line = line[prefix+2:]
}
if strings.Contains(line, "login to server success") {
return diagStateOK, "frpc 已登录 frps"
}
if failureDetail == "" && isLoginFailureLine(line) {
failureDetail = line
}
}
if failureDetail != "" {
if len(failureDetail) > 160 {
failureDetail = failureDetail[:160] + "…"
}
return diagStateFailed, "frpc 登录失败:" + failureDetail
}
return diagStateUnknown, "日志中暂无登录结果记录"
}
func isLoginFailureLine(line string) bool {
if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
return true
}
return strings.Contains(line, "token") && strings.Contains(line, "error")
}
// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
ip := resolvePublicIP(ctx, host)
if ip == "" {
// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
return strings.TrimSpace(host), true
}
s.locationMu.Lock()
cached, ok := s.locations[ip]
s.locationMu.Unlock()
if ok && time.Now().Before(cached.expiresAt) {
return cached.value, true
}
return ip, false
}
// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
func (s *Service) refreshLocation(host string) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = s.serverLocation(ctx, host)
}
+240
View File
@@ -0,0 +1,240 @@
package frp
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
)
// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
// token 认证才需要取同一份 token;none / oidc 不涉及
token := ""
if server.Options.AuthMethod == "token" {
encoded, err := s.store.Token(ctx, server.ID)
if err != nil {
return "", err
}
if encoded != "" {
token, err = s.secretBox.Decrypt(encoded)
if err != nil {
return "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
}
}
}
dashboardOn := server.Options.DashboardPort > 0 && strings.TrimSpace(server.Options.DashboardAddr) != ""
hasVhost, vhostErr := s.hasVhostProxies(ctx, server.ID)
// subDomainHost 缺口检测:frpc 侧规则填了 subdomain 时,frps 必须配置 subDomainHost 才生效
proxies, proxyErr := s.store.ListProxiesByServer(ctx, server.ID)
hasSubdomain := false
if proxyErr == nil {
for _, p := range proxies {
if strings.TrimSpace(p.Options.Subdomain) != "" {
hasSubdomain = true
break
}
}
}
var b strings.Builder
b.WriteString("# 由 Havline 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(INI 自 v0.52.0 起已被官方弃用)\n")
b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
b.WriteString("bindAddr = \"0.0.0.0\"\n")
b.WriteString(fmt.Sprintf("bindPort = %d\n", server.ServerPort))
switch server.Options.Protocol {
case "kcp":
b.WriteString(fmt.Sprintf("kcpBindPort = %d # frpc 使用 kcp 协议接入;官方允许与 bindPort 相同(UDP)\n", server.ServerPort))
case "quic":
b.WriteString(fmt.Sprintf("quicBindPort = %d # frpc 使用 quic 协议接入;UDP 端口官方允许与 bindPort 相同,如冲突请改为独立端口\n", server.ServerPort))
}
if vhostErr == nil && hasVhost {
// 官方默认 vhost 端口为 0(禁用)。默认 8080/8443:80/443 由 Nginx 占用(Nginx 80/443 →
// proxy_pass 127.0.0.1:8080 → frps vhost → 隧道),可在服务端表单自定义,与 Nginx upstream 同源
httpPort := server.Options.VhostHTTPPort
if httpPort <= 0 {
httpPort = 8080
}
httpsPort := server.Options.VhostHTTPSPort
if httpsPort <= 0 {
httpsPort = 8443
}
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
}
// 子域名根:规则填了 subdomain 时必须配置,否则 subdomain 不生效
if host := strings.TrimSpace(server.Options.SubDomainHost); host != "" {
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
}
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
}
// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
b.WriteString(fmt.Sprintf("tcpmuxHTTPConnectPort = %d\n", port))
}
// 强制 TLS:公网 frps 标准安全实践;开启后 frpc 不开 TLS 将无法连接
if server.Options.TLSForce {
b.WriteString("transport.tls.force = true # 只接受 TLS 连接(frpc 侧必须启用 transport.tls.enable)\n")
}
// 传输参数与 frpc 侧同源:两端不一致会导致连不上或池被截断
if !server.Options.TCPMux {
b.WriteString("transport.tcpMux = false # 与 frpc 侧 tcp_mux 同源(官方默认 true,仅在关闭时输出)\n")
}
if server.Options.PoolCount > 0 {
b.WriteString(fmt.Sprintf("transport.maxPoolCount = %d # 不低于 frpc 侧 pool_count,避免连接池被静默截断\n", server.Options.PoolCount))
}
// 端口白名单:逗号分隔的单端口或「起-止」范围,如 6000,6005-6010,7000-8000;留空不限制
if ports := strings.TrimSpace(server.Options.AllowPorts); ports != "" {
b.WriteString(fmt.Sprintf("allowPorts = [\n%s\n]\n", renderAllowPorts(ports)))
}
// 每客户端可绑定端口上限;0 表示不限制
if maxPorts := server.Options.MaxPortsPerClient; maxPorts > 0 {
b.WriteString(fmt.Sprintf("maxPortsPerClient = %d\n", maxPorts))
}
// frps 文件日志:journalctl 只保留尾部,排障需完整历史时开启;写入 /var/log/frps.log
if server.Options.FrpsLogToFile {
maxDays := server.Options.FrpsLogMaxDays
if maxDays <= 0 {
maxDays = 3
}
b.WriteString("\nlog.to = \"/var/log/frps.log\"\n")
b.WriteString(fmt.Sprintf("log.maxDays = %d\n", maxDays))
}
if !server.TLS {
b.WriteString("# 安全提示:frpc 侧未启用 TLS,frps 可加 transport.tls.force = true 强制 TLS(开启后 frpc 不开 TLS 将无法连接)\n")
}
switch server.Options.AuthMethod {
case "token":
if token != "" {
// 点式键与官方 frps_full_example.toml 同款(TOML 语义上与 [auth] 表等价)
b.WriteString("\nauth.method = \"token\"\n")
b.WriteString(fmt.Sprintf("auth.token = %q\n", token))
} else {
b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Havline 重新生成本文件以保持两端一致\n")
}
case "none":
b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n")
b.WriteString("# 公网暴露的 frps 建议配置认证或用 transport.tls.force = true 强制 TLS,防止任意客户端接入\n")
case "oidc":
b.WriteString("\n# frpc 侧使用 OIDC 认证;frps 侧的 oidc.* 校验参数(issuer、client_id 等)依赖你的身份提供商,\n")
b.WriteString("# 请参考官方 frps_full_example.toml 的 [auth] oidc 段手动配置\n")
}
if server.TLS {
b.WriteString("\n# frpc 侧已启用 transport.tls.enable;frps 的 transport.tls.force 默认为 false,无需额外 TLS 配置即可接受 TLS 连接\n")
}
// webServer(frps 管理接口):与客户端 options.dashboard_* 同源
if dashboardOn {
b.WriteString("\nwebServer.addr = \"0.0.0.0\"\n")
b.WriteString(fmt.Sprintf("webServer.port = %d\n", server.Options.DashboardPort))
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
b.WriteString(fmt.Sprintf("webServer.user = %q\n", user))
}
if encoded, err := s.store.DashboardPassword(ctx, server.ID); err == nil && encoded != "" {
if password, err := s.secretBox.Decrypt(encoded); err == nil && password != "" {
b.WriteString(fmt.Sprintf("webServer.password = %q\n", password))
}
}
b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Havline 所在机器访问该端口\n")
}
if hasSubdomain {
b.WriteString("\n# 注意:该服务端下存在填写 subdomain 的规则;frps 需配置 subDomainHost(如 frps.example.com)后,\n")
b.WriteString("# 规则的 subdomain 字段才会生效为 sub.frps.example.com;域名 DNS 也需指向本 VPS\n")
}
b.WriteString("\n# 防火墙放行清单(按本服务端当前配置生成)\n")
b.WriteString(fmt.Sprintf("# %d/tcp # frps 控制端口\n", server.ServerPort))
if dashboardOn {
b.WriteString(fmt.Sprintf("# %d/tcp # frps 管理接口(webServer)\n", server.Options.DashboardPort))
}
if vhostErr == nil && hasVhost {
httpPort := server.Options.VhostHTTPPort
if httpPort <= 0 {
httpPort = 8080
}
httpsPort := server.Options.VhostHTTPSPort
if httpsPort <= 0 {
httpsPort = 8443
}
b.WriteString(fmt.Sprintf("# %d, %d/tcp # 上方 vhostHTTPPort / vhostHTTPSPort 对应端口(仅本机 Nginx 回源需要,可不对公网开放)\n", httpPort, httpsPort))
}
return b.String(), nil
}
// hasVhostProxies 判断该服务端下是否存在依赖 vhost 端口的规则(http/https/tcpmux)。
func (s *Service) hasVhostProxies(ctx context.Context, serverID int64) (bool, error) {
proxies, err := s.store.ListProxiesByServer(ctx, serverID)
if err != nil {
return false, err
}
for _, p := range proxies {
if p.Type == "http" || p.Type == "https" || p.Type == "tcpmux" {
return true, nil
}
}
return false, nil
}
// renderAllowPorts 把表单端口白名单文本(如 "6000,6005-6010,7000-8000")渲染为 TOML 数组行;
// 非法片段直接忽略,不中断生成。
func renderAllowPorts(ports string) string {
var lines []string
for _, part := range strings.Split(ports, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if start, end, ok := strings.Cut(part, "-"); ok {
startN, err1 := strconv.Atoi(strings.TrimSpace(start))
endN, err2 := strconv.Atoi(strings.TrimSpace(end))
if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 {
lines = append(lines, fmt.Sprintf("{ start = %d, end = %d },", startN, endN))
}
continue
}
if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 {
lines = append(lines, fmt.Sprintf("{ single = %d },", n))
}
}
return strings.Join(lines, "\n")
}
// frpsSecretLinePattern 匹配带着敏感值的 frps.toml 行
var frpsSecretLinePattern = regexp.MustCompile(`(?i)^\s*(auth\.token|auth\.oidc\.clientsecret)\s*=`)
// maskFRPSSecrets 把 frps.toml 里的敏感值换成掩码:保留键名与行结构,
// 让用户在预览里仍能看清「配了哪些认证项」,但不下发明文密钥。
// 只用于预览;下发路径(PushFRPSConfigToAgent)必须用未脱敏的内容。
func maskFRPSSecrets(content string) string {
lines := strings.Split(content, "\n")
for i, line := range lines {
if !frpsSecretLinePattern.MatchString(line) {
continue
}
key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
lines[i] = fmt.Sprintf("%s = %q", key, maskedToken)
}
return strings.Join(lines, "\n")
}
// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
// 这是**预览**入口:密钥一律脱敏(明文只在 PushFRPSConfigToAgent 的下发路径里出现)。
func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return "", err
}
content, err := s.buildFRPSServerConfig(ctx, server)
if err != nil {
return "", err
}
return maskFRPSSecrets(content), nil
}
@@ -0,0 +1,43 @@
package frp
import (
"strings"
"testing"
)
// frps.toml 的预览(FRPSServerConfig 返回的内容)绝不能带明文密钥:
// 拉到的内容会直接渲染在浏览器的「即将下发的内容」里。
func TestMaskFRPSSecrets(t *testing.T) {
content := strings.Join([]string{
`bindPort = 7000`,
`auth.method = "token"`,
`auth.token = "s3cr3t-token"`,
`auth.oidc.clientSecret = "s3cr3t-oidc"`,
`# auth.token = "注释里的示例不应受影响"`,
`webServer.addr = "0.0.0.0"`,
}, "\n")
masked := maskFRPSSecrets(content)
if strings.Contains(masked, "s3cr3t-token") || strings.Contains(masked, "s3cr3t-oidc") {
t.Fatalf("预览里仍能看到明文密钥:\n%s", masked)
}
for _, expected := range []string{
`auth.token = "********"`,
`auth.oidc.clientSecret = "********"`,
`bindPort = 7000`,
`auth.method = "token"`,
`webServer.addr = "0.0.0.0"`,
} {
if !strings.Contains(masked, expected) {
t.Fatalf("脱敏后缺少 %q:\n%s", expected, masked)
}
}
}
// 注释行不是生效配置,不参与脱敏(保持模板原样)
func TestMaskFRPSSecretsKeepsComments(t *testing.T) {
content := `# auth.token = "说明文字"`
if got := maskFRPSSecrets(content); got != content {
t.Fatalf("注释行被改写:%q", got)
}
}
+99
View File
@@ -0,0 +1,99 @@
package frp
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
)
const locationCacheTTL = 24 * time.Hour
type locationCache struct {
value string
expiresAt time.Time
}
type ipLocationResponse struct {
Success bool `json:"success"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
}
func (s *Service) serverLocation(ctx context.Context, host string) string {
ip := resolvePublicIP(ctx, host)
if ip == "" {
return strings.TrimSpace(host)
}
s.locationMu.Lock()
if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
s.locationMu.Unlock()
return cached.value
}
s.locationMu.Unlock()
value := lookupIPLocation(ctx, ip)
// 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
s.locationMu.Lock()
s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
s.locationMu.Unlock()
return value
}
func resolvePublicIP(ctx context.Context, host string) string {
if ip := net.ParseIP(host); isPublicIP(ip) {
return ip.String()
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return ""
}
for _, address := range addresses {
if isPublicIP(address.IP) {
return address.IP.String()
}
}
return ""
}
func isPublicIP(ip net.IP) bool {
return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}
func lookupIPLocation(ctx context.Context, ip string) string {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
if err != nil {
return "未定位"
}
client := &http.Client{Timeout: 4 * time.Second}
response, err := client.Do(request)
if err != nil {
return "未定位"
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "未定位"
}
var data ipLocationResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
return "未定位"
}
return formatLocation(data)
}
func formatLocation(data ipLocationResponse) string {
parts := []string{data.City, data.Region, data.Country}
values := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
values = append(values, part)
}
}
if len(values) == 0 {
return "未定位"
}
return strings.Join(values, " · ")
}
+22
View File
@@ -0,0 +1,22 @@
package frp
import (
"net"
"testing"
)
func TestFormatLocation(t *testing.T) {
value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
if value != "Hong Kong · China" {
t.Fatalf("位置格式错误:%s", value)
}
}
func TestIsPublicIP(t *testing.T) {
if !isPublicIP(net.ParseIP("38.95.74.87")) {
t.Fatal("公网 IP 应被识别")
}
if isPublicIP(net.ParseIP("192.168.1.1")) {
t.Fatal("私网 IP 不应被定位")
}
}
+300
View File
@@ -0,0 +1,300 @@
package frp
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type Manager struct {
bin string
configPath string
logPath string
logger *slog.Logger
mu sync.RWMutex
cmd *exec.Cmd
orphan *os.Process
orphanPID int
pid int
version string
started time.Time
reloaded time.Time
lastErr string
}
func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
}
func (m *Manager) Verify(ctx context.Context, configPath string) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) VerifyBinary(ctx context.Context) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.runningLocked() {
return nil
}
bin, err := m.binaryPathLocked()
if err != nil {
m.lastErr = err.Error()
return err
}
logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(bin, "-c", m.configPath)
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
m.lastErr = err.Error()
return fmt.Errorf("启动 frpc 失败:%w", err)
}
m.cmd = cmd
m.pid = cmd.Process.Pid
m.started = time.Now().UTC()
m.reloaded = m.started
m.lastErr = ""
go m.wait(cmd, logFile)
return nil
}
func (m *Manager) Restart(ctx context.Context) error {
if err := m.Stop(ctx); err != nil {
return err
}
return m.Start()
}
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
cmd := m.cmd
orphan := m.orphan
m.mu.Unlock()
if cmd == nil && orphan == nil {
return nil
}
process := orphan
if cmd != nil {
process = cmd.Process
}
if process == nil {
return nil
}
if err := process.Kill(); err != nil {
return fmt.Errorf("停止 frpc 失败:%w", err)
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
if !m.running() {
m.mu.Lock()
m.lastErr = ""
m.orphan = nil
m.orphanPID = 0
m.mu.Unlock()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("等待 frpc 停止超时")
case <-time.After(50 * time.Millisecond):
}
}
}
func (m *Manager) Status() RuntimeStatus {
m.mu.RLock()
defer m.mu.RUnlock()
return m.statusLocked()
}
func (m *Manager) statusLocked() RuntimeStatus {
status := RuntimeStatus{
Status: "stopped",
PID: m.pid,
Version: m.version,
LastError: m.lastErr,
}
if m.runningLocked() {
status.Status = "running"
if status.PID == 0 {
status.PID = m.orphanPID
}
} else if m.orphan != nil {
status.PID = 0
}
if !m.started.IsZero() {
status.LastStartedAt = m.started.Format(time.RFC3339)
}
if !m.reloaded.IsZero() {
status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
}
return status
}
func (m *Manager) RefreshVersion(ctx context.Context) {
bin, err := m.BinaryPath()
if err != nil {
return
}
output, err := exec.CommandContext(ctx, bin, "--version").Output()
if err != nil {
return
}
m.mu.Lock()
m.version = strings.TrimSpace(string(output))
m.mu.Unlock()
}
func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
logFile.Close()
m.mu.Lock()
if m.cmd != cmd {
m.mu.Unlock()
return
}
m.cmd = nil
m.pid = 0
if err != nil {
// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
}
m.mu.Unlock()
}
// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
func tailFile(path string, maxLines int) string {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return strings.Join(lines, " | ")
}
func (m *Manager) running() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.runningLocked()
}
func (m *Manager) runningLocked() bool {
if m.cmd != nil && m.cmd.Process != nil {
return true
}
return m.orphan != nil && processAlive(m.orphanPID)
}
func (m *Manager) Attach(pid int) bool {
bin, err := m.BinaryPath()
if err != nil || !processMatches(pid, bin, m.configPath) {
return false
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
m.mu.Lock()
m.orphan = process
m.orphanPID = pid
m.pid = pid
m.lastErr = ""
m.mu.Unlock()
return true
}
func processAlive(pid int) bool {
alive, _ := probeProcess(pid)
return alive
}
func processMatches(pid int, binary, configPath string) bool {
alive, commandLine := probeProcess(pid)
if !alive {
return false
}
commandLine = strings.ToLower(filepath.ToSlash(commandLine))
return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
}
func (m *Manager) BinaryPath() (string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.binaryPathLocked()
}
func (m *Manager) binaryPathLocked() (string, error) {
if strings.ContainsAny(m.bin, `/\\`) {
if _, err := os.Stat(m.bin); err != nil {
return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
}
return m.bin, nil
}
path, err := exec.LookPath(m.bin)
if err != nil {
return "", fmt.Errorf("未找到 frpc 二进制,请设置 HAVLINE_FRPC_BIN")
}
return path, nil
}
func (m *Manager) SetBinary(bin string) {
m.mu.Lock()
m.bin = bin
m.version = ""
m.mu.Unlock()
}
func (m *Manager) LogPath() string {
return filepath.Clean(m.logPath)
}
func safeCommandOutput(output []byte, err error) string {
text := strings.TrimSpace(string(output))
if text == "" {
return err.Error()
}
if len(text) > 500 {
return text[:500]
}
return text
}
+355
View File
@@ -0,0 +1,355 @@
package frp
import "time"
type RuntimeInfo struct {
Platform string `json:"platform"`
ConfiguredBin string `json:"configured_bin"`
ActiveBin string `json:"active_bin,omitempty"`
ActiveVersion string `json:"active_version,omitempty"`
DetectedVersion string `json:"detected_version,omitempty"`
DownloadProxy string `json:"download_proxy"`
ConfigPath string `json:"config_path"`
ConfigPaths []string `json:"config_paths,omitempty"`
ConfigExists bool `json:"config_exists"`
}
type Release struct {
Version string `json:"version"`
PublishedAt string `json:"published_at,omitempty"`
AssetName string `json:"asset_name"`
Size int64 `json:"size"`
Checksum string `json:"-"`
AssetID int64 `json:"-"`
}
type Binary struct {
Version string `json:"version"`
Path string `json:"path"`
Size int64 `json:"size"`
InstalledAt string `json:"installed_at"`
Active bool `json:"active"`
}
// DiagnosticCheck 单项诊断结果;State 取 ok / failed / unknown(unknown 表示无法确认,不等于失败)。
type DiagnosticCheck struct {
Name string `json:"name"`
State string `json:"state"`
Detail string `json:"detail,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
}
// ServerDiagnostics 服务端诊断结果。Available/LatencyMS/Error 保持 tcp_reachable 口径
// 兼容历史延迟曲线;定位等非阻断信息只出现在 Checks 中,不参与 Error。
type ServerDiagnostics struct {
ServerID int64 `json:"server_id"`
Available bool `json:"available"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Location string `json:"location,omitempty"`
Checks []DiagnosticCheck `json:"checks"`
Error string `json:"error,omitempty"`
}
type ServerSample struct {
CheckedAt string `json:"checked_at"`
Available bool `json:"available"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Error string `json:"error,omitempty"`
}
type ServerStatusDetail struct {
Available bool `json:"available"`
Availability float64 `json:"availability"`
AverageLatency int64 `json:"average_latency_ms"`
PeakLatency int64 `json:"peak_latency_ms"`
Samples []ServerSample `json:"samples"`
LastCheckedAt string `json:"last_checked_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type ServerDetail struct {
Server Server `json:"server"`
Status ServerStatusDetail `json:"status"`
Proxies []Proxy `json:"proxies"`
}
type DownloadTask struct {
Version string `json:"version,omitempty"`
Status string `json:"status"`
Message string `json:"message"`
Downloaded int64 `json:"downloaded"`
Total int64 `json:"total"`
Error string `json:"error,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type Server struct {
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"`
AgentTLSPinPrev string `json:"agent_tls_pin_prev,omitempty"`
AgentTLSPinPrevUntil string `json:"agent_tls_pin_prev_until,omitempty"`
AgentTLSNotAfter string `json:"agent_tls_not_after,omitempty"`
AgentFirewallState string `json:"agent_firewall_state,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 {
ServerID int64
Status string
DesiredState string
ProcessState string
ConnectionState string
StateSource string
ProcessPID int
ExitCode int
LastError string
LastStartedAt string
LastConnectedAt string
LastDisconnectedAt string
}
type ServerOptions struct {
User string `json:"user,omitempty"`
Location string `json:"location,omitempty"`
AuthMethod string `json:"auth_method"`
OIDCClientID string `json:"oidc_client_id,omitempty"`
OIDCAudience string `json:"oidc_audience,omitempty"`
OIDCScope string `json:"oidc_scope,omitempty"`
OIDCTokenEndpointURL string `json:"oidc_token_endpoint_url,omitempty"`
OIDCTrustedCAPath string `json:"oidc_trusted_ca_path,omitempty"`
OIDCInsecureSkipVerify bool `json:"oidc_insecure_skip_verify"`
OIDCProxyURL string `json:"oidc_proxy_url,omitempty"`
// VPS frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 生成器和 Nginx upstream 同源
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
SubDomainHost string `json:"sub_domain_host,omitempty"`
Custom404Page string `json:"custom_404_page,omitempty"`
TLSForce bool `json:"tls_force"`
AllowPorts string `json:"allow_ports,omitempty"`
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
TCPMuxHTTPConnectPort int `json:"tcpmux_http_connect_port,omitempty"`
FrpsLogToFile bool `json:"frps_log_to_file"`
FrpsLogMaxDays int `json:"frps_log_max_days,omitempty"`
// frps 管理接口(webServer),仅用于 Havline 拉取穿透流量等只读信息,不写入 frpc.toml
DashboardAddr string `json:"dashboard_addr,omitempty"`
DashboardPort int `json:"dashboard_port,omitempty"`
DashboardUser string `json:"dashboard_user,omitempty"`
DashboardHasPwd bool `json:"dashboard_has_pwd"`
LogLevel string `json:"log_level"`
LogMaxDays int `json:"log_max_days"`
Protocol string `json:"protocol"`
ProxyURL string `json:"proxy_url,omitempty"`
DialServerTimeout int `json:"dial_server_timeout"`
DialServerKeepalive int `json:"dial_server_keepalive"`
ConnectServerLocalIP string `json:"connect_server_local_ip,omitempty"`
DNSServer string `json:"dns_server,omitempty"`
PoolCount int `json:"pool_count"`
TCPMux bool `json:"tcp_mux"`
TCPMuxKeepaliveInterval int `json:"tcp_mux_keepalive_interval"`
HeartbeatInterval int `json:"heartbeat_interval"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
LoginFailExit bool `json:"login_fail_exit"`
TLSDisableCustomFirstByte bool `json:"tls_disable_custom_first_byte"`
AutoStart bool `json:"auto_start"`
Remark string `json:"remark,omitempty"`
TLSCertificateConfigured bool `json:"tls_certificate_configured"`
TLSKeyConfigured bool `json:"tls_key_configured"`
TLSTrustedCAConfigured bool `json:"tls_trusted_ca_configured"`
}
type Proxy struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
LocalIP string `json:"local_ip"`
LocalPort int `json:"local_port"`
RemotePort *int `json:"remote_port,omitempty"`
CustomDomains []string `json:"custom_domains"`
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
Options ProxyOptions `json:"options"`
Enabled bool `json:"enabled"`
Remark string `json:"remark,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ServerInput struct {
AgentURL string
AgentToken string
ClearAgentToken bool
SSHHost string
SSHPort int
SSHUser string
SSHAuth string
SSHSecret string
ClearSSHSecret bool
MgmtEnabled bool
Name string
ServerAddr string
ServerPort int
TLS bool
TLSServerName string
Enabled bool
Token string
ClearToken bool
Options ServerOptions
OIDCClientSecret string
ClearOIDCClientSecret bool
DashboardPassword string
ClearDashboardPassword bool
TLSCertificate string
TLSKey string
TLSTrustedCA string
ClearTLSCertificate bool
ClearTLSKey bool
ClearTLSTrustedCA bool
}
type ProxyInput struct {
ServerID int64
Name string
Type string
LocalIP string
LocalPort int
RemotePort *int
CustomDomains []string
HostHeaderRewrite string
Options ProxyOptions
Enabled bool
Remark string
}
// ProxyOptions 对应 FRP [[proxies]] 中除基础地址外的扩展配置。
type ProxyOptions struct {
Subdomain string `json:"subdomain,omitempty"`
// WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头),不写入 frpc.toml
WebSocket bool `json:"websocket,omitempty"`
// 公网反代进阶选项(不写入 frpc.toml):强制跳转 HTTPS / IP 白名单 / Basic Auth 用户名(密码由 agent 生成)
RedirectHTTPS bool `json:"redirect_https,omitempty"`
AllowIPs []string `json:"allow_ips,omitempty"`
BasicAuthUser string `json:"basic_auth_user,omitempty"`
// 公网反代第一梯队部署设置(同属公网 Nginx vhost 选项,不写入 frpc.toml)
DenyIPs []string `json:"deny_ips,omitempty"` // IP 黑名单
BasicAuthPassword string `json:"basic_auth_password,omitempty"` // 自定义 Basic Auth 密码;空则首次自动生成
HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS
ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小,如 50m
ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头
TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
// 第二梯队部署设置(同为公网 Nginx vhost 选项)
RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP
Locations []string `json:"locations,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPUser string `json:"http_user,omitempty"`
HTTPPassword string `json:"http_password,omitempty"`
Multiplexer string `json:"multiplexer,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
AllowUsers []string `json:"allow_users,omitempty"`
Transport ProxyTransportOptions `json:"transport"`
HealthCheck ProxyHealthCheckOptions `json:"health_check"`
LoadBalancer ProxyLoadBalancer `json:"load_balancer"`
Metadatas map[string]string `json:"metadatas,omitempty"`
RequestHeaders map[string]string `json:"request_headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
Plugin *ProxyPluginOptions `json:"plugin,omitempty"`
NATTraversal *ProxyNATTraversal `json:"nat_traversal,omitempty"`
}
type ProxyTransportOptions struct {
BandwidthLimit string `json:"bandwidth_limit,omitempty"`
BandwidthLimitMode string `json:"bandwidth_limit_mode,omitempty"`
UseEncryption bool `json:"use_encryption"`
UseCompression bool `json:"use_compression"`
ProxyProtocolVersion string `json:"proxy_protocol_version,omitempty"`
}
type ProxyHealthCheckOptions struct {
Type string `json:"type,omitempty"`
Path string `json:"path,omitempty"`
IntervalSeconds int `json:"interval_seconds,omitempty"`
MaxFailed int `json:"max_failed,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
HTTPHeaders []ProxyHeader `json:"http_headers,omitempty"`
}
type ProxyHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type ProxyLoadBalancer struct {
Group string `json:"group,omitempty"`
GroupKey string `json:"group_key,omitempty"`
}
type ProxyPluginOptions struct {
Type string `json:"type"`
UnixPath string `json:"unix_path,omitempty"`
LocalPath string `json:"local_path,omitempty"`
StripPrefix string `json:"strip_prefix,omitempty"`
LocalAddr string `json:"local_addr,omitempty"`
HTTPUser string `json:"http_user,omitempty"`
HTTPPassword string `json:"http_password,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
CRTPath string `json:"crt_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
DestinationIP string `json:"destination_ip,omitempty"`
}
type ProxyNATTraversal struct {
DisableAssistedAddrs bool `json:"disable_assisted_addrs"`
}
type RuntimeStatus struct {
Status string `json:"status"`
PID int `json:"pid,omitempty"`
Version string `json:"frpc_version,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"`
LastReloadedAt string `json:"last_reloaded_at,omitempty"`
LastError string `json:"last_error,omitempty"`
Configured bool `json:"configured"`
EnabledProxies int `json:"enabled_proxies"`
}
+104
View File
@@ -0,0 +1,104 @@
package frp
import (
"context"
"fmt"
"path/filepath"
"strings"
)
func (s *Service) managerForServer(serverID int64) *Manager {
s.managersMu.Lock()
defer s.managersMu.Unlock()
if manager, ok := s.managers[serverID]; ok {
return manager
}
bin, err := s.manager.BinaryPath()
if err != nil {
bin = s.cfg.FrpcBin
}
manager := NewManager(
bin,
s.serverConfigPath(serverID),
s.serverLogPath(serverID),
s.logger,
)
s.managers[serverID] = manager
return manager
}
func (s *Service) serverConfigPath(serverID int64) string {
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
}
func (s *Service) serverLogPath(serverID int64) string {
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
}
func (s *Service) managerSnapshot() map[int64]*Manager {
s.managersMu.RLock()
defer s.managersMu.RUnlock()
result := make(map[int64]*Manager, len(s.managers))
for id, manager := range s.managers {
result[id] = manager
}
return result
}
func (s *Service) setManagerBinary(path string) {
s.manager.SetBinary(path)
for _, manager := range s.managerSnapshot() {
manager.SetBinary(path)
}
}
func (s *Service) removeManager(serverID int64) {
s.managersMu.Lock()
delete(s.managers, serverID)
s.managersMu.Unlock()
}
func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
result := RuntimeStatus{Status: "stopped"}
for _, status := range statuses {
if status.Status == "running" {
result.Status = "running"
}
if status.Configured {
result.Configured = true
}
result.EnabledProxies += status.EnabledProxies
if result.PID == 0 && status.PID != 0 {
result.PID = status.PID
}
if status.LastStartedAt > result.LastStartedAt {
result.LastStartedAt = status.LastStartedAt
}
if status.LastReloadedAt > result.LastReloadedAt {
result.LastReloadedAt = status.LastReloadedAt
}
if result.LastError == "" && status.LastError != "" {
result.LastError = status.LastError
}
if status.Version != "" && result.Version == "" {
result.Version = status.Version
}
}
if len(statuses) == 0 {
result.Status = "unknown"
}
return result
}
func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
var messages []string
for id, manager := range s.managerSnapshot() {
if err := action(manager); err != nil {
messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
}
}
if len(messages) > 0 {
return fmt.Errorf("%s", strings.Join(messages, ";"))
}
return nil
}
@@ -0,0 +1,16 @@
//go:build !linux && !darwin && !freebsd && !windows
package frp
import "os"
func probeProcess(pid int) (bool, string) {
if pid <= 0 {
return false, ""
}
process, err := os.FindProcess(pid)
if err != nil {
return false, ""
}
return true, process.String()
}
@@ -0,0 +1,25 @@
//go:build linux || darwin || freebsd
package frp
import (
"os"
"os/exec"
"strconv"
"strings"
)
func probeProcess(pid int) (bool, string) {
if pid <= 0 {
return false, ""
}
if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil {
commandLine := strings.ReplaceAll(string(data), "\x00", " ")
return strings.TrimSpace(commandLine) != "", strings.TrimSpace(commandLine)
}
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
if err != nil {
return false, ""
}
return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
}
@@ -0,0 +1,22 @@
//go:build windows
package frp
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
func probeProcess(pid int) (bool, string) {
if pid <= 0 {
return false, ""
}
script := fmt.Sprintf("$p=Get-CimInstance Win32_Process -Filter \"ProcessId=%s\"; if ($p) { $p.CommandLine }", strconv.Itoa(pid))
output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Output()
if err != nil {
return false, ""
}
return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
}
+216
View File
@@ -0,0 +1,216 @@
package frp
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// 漂移类型:库里的期望(A)与 VPS 上的实际(B/C)之间的差异分类
const (
RouteDriftMissing = "missing" // 库里要部署但 VPS 上没有
RouteDriftOrphan = "orphan" // VPS 有但库里没有(手工添加或已删规则的残留)
RouteDriftNotLoaded = "not_loaded" // 文件在但未被 Nginx 加载
RouteDriftContentMismatch = "content_mismatch" // 上流地址与库里的期望不一致
)
// RouteDriftItem 一条差异
type RouteDriftItem struct {
Domain string `json:"domain"`
Kind string `json:"kind"`
Detail string `json:"detail"`
Expected string `json:"expected,omitempty"`
Actual string `json:"actual,omitempty"`
}
// RouteDriftReport 部署漂移报告:Expected = 库里启用中的规则域名数,Deployed = VPS 上 havline-*.conf 数,Loaded = 其中被加载的数
type RouteDriftReport struct {
ServerID int64 `json:"server_id"`
Expected int `json:"expected"`
Deployed int `json:"deployed"`
Loaded int `json:"loaded"`
Items []RouteDriftItem `json:"items"`
CheckedAt string `json:"checked_at"`
}
// RouteDrift 比对「数据库里启用中的公网反代规则」与「VPS 上 havline-*.conf 的实际状态」。
// 内容比对只做上流地址(proxy_pass):配置文件由 agent 渲染,应用侧不重复实现一套渲染器,
// 因此不做全文 diff,避免两套渲染逻辑漂移带来的误报。
func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftReport, error) {
client, ok := s.agentClient(ctx, serverID)
if !ok {
return RouteDriftReport{}, fmt.Errorf("公网 agent 未配置或 Token 未保存")
}
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
if err != nil {
return RouteDriftReport{}, err
}
// A:库里的期望域名 → 期望上流
expected := map[string]string{}
for _, proxy := range proxies {
if !deployableProxy(proxy) {
continue
}
upstream, uerr := s.agentUpstreamFor(ctx, proxy)
if uerr != nil {
upstream = ""
}
for _, domain := range proxy.CustomDomains {
expected[domain] = upstream
}
}
details, err := client.RoutesDetail(ctx)
if err != nil {
return RouteDriftReport{}, err
}
// B / C:VPS 上的实际
actual := make(map[string]RouteDetail, len(details))
loaded := 0
for _, detail := range details {
actual[detail.Domain] = detail
if detail.Loaded {
loaded++
}
}
report := RouteDriftReport{
ServerID: serverID,
Expected: len(expected),
Deployed: len(details),
Loaded: loaded,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
}
for domain, want := range expected {
detail, ok := actual[domain]
if !ok {
report.Items = append(report.Items, RouteDriftItem{
Domain: domain, Kind: RouteDriftMissing,
Detail: "VPS 上没有该域名的配置,重新部署即可写入",
Expected: want,
})
continue
}
if !detail.Loaded {
report.Items = append(report.Items, RouteDriftItem{
Domain: domain, Kind: RouteDriftNotLoaded,
Detail: "配置文件存在但未被 Nginx 加载(语法未通过,或 include 未覆盖该文件)",
})
continue
}
if want == "" {
continue
}
content, cerr := client.RouteConf(ctx, domain)
if cerr != nil {
continue
}
got := normalizeUpstream(proxyPassTarget(content))
if got != "" && got != normalizeUpstream(want) {
report.Items = append(report.Items, RouteDriftItem{
Domain: domain, Kind: RouteDriftContentMismatch,
Detail: "VPS 上的上流地址与 Havline 记录不一致(可能被手工修改过)",
Expected: want, Actual: got,
})
}
}
for domain := range actual {
if _, ok := expected[domain]; ok {
continue
}
report.Items = append(report.Items, RouteDriftItem{
Domain: domain, Kind: RouteDriftOrphan,
Detail: "VPS 上存在该域名配置,但 Havline 里没有对应规则(手工添加,或已删除规则的残留)",
})
}
sort.Slice(report.Items, func(i, j int) bool {
if report.Items[i].Kind != report.Items[j].Kind {
return report.Items[i].Kind < report.Items[j].Kind
}
return report.Items[i].Domain < report.Items[j].Domain
})
return report, nil
}
// RedeployRouteResult 单条规则的重新部署结果
type RedeployRouteResult struct {
ProxyID int64 `json:"proxy_id"`
Name string `json:"name"`
Domains string `json:"domains"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// RedeployAllResult 全量重部署汇总
type RedeployAllResult struct {
ServerID int64 `json:"server_id"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Results []RedeployRouteResult `json:"results"`
}
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
if err != nil {
return RedeployAllResult{}, err
}
result := RedeployAllResult{ServerID: serverID, Results: []RedeployRouteResult{}}
for _, proxy := range proxies {
if !deployableProxy(proxy) {
continue
}
result.Total++
item := RedeployRouteResult{
ProxyID: proxy.ID,
Name: proxy.Name,
Domains: strings.Join(proxy.CustomDomains, "、"),
}
if err := s.DeployAgentRoute(ctx, proxy.ID); err != nil {
item.Error = err.Error()
result.Failed++
} else {
item.OK = true
result.Succeeded++
}
result.Results = append(result.Results, item)
}
return result, nil
}
// deployableProxy 与 DeployAgentRoute 的准入条件保持一致:启用的 http/https 规则且填了自定义域名
func deployableProxy(proxy Proxy) bool {
if !proxy.Enabled || len(proxy.CustomDomains) == 0 {
return false
}
return proxy.Type == "http" || proxy.Type == "https"
}
// proxyPassTarget 从 Nginx 配置文本里取出第一条 proxy_pass 的目标地址
func proxyPassTarget(content string) string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "proxy_pass ") {
continue
}
value := strings.TrimPrefix(trimmed, "proxy_pass ")
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";"))
}
return ""
}
// normalizeUpstream 去掉协议前缀与结尾斜杠,便于比较(agent 渲染的是 http://127.0.0.1:8080,应用侧期望的是 127.0.0.1:8080)
func normalizeUpstream(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "http://")
value = strings.TrimPrefix(value, "https://")
return strings.TrimSuffix(value, "/")
}
+42
View File
@@ -0,0 +1,42 @@
package frp
import "testing"
func TestProxyPassTarget(t *testing.T) {
content := "server {\n listen 443 ssl;\n location / {\n proxy_pass http://127.0.0.1:8080;\n }\n}\n"
if got := proxyPassTarget(content); got != "http://127.0.0.1:8080" {
t.Fatalf("unexpected target: %q", got)
}
if got := proxyPassTarget("server {\n listen 80;\n}\n"); got != "" {
t.Fatalf("expected empty target, got %q", got)
}
}
func TestNormalizeUpstream(t *testing.T) {
cases := map[string]string{
"http://127.0.0.1:8080": "127.0.0.1:8080",
"https://127.0.0.1:8443/": "127.0.0.1:8443",
"127.0.0.1:8080": "127.0.0.1:8080",
}
for input, want := range cases {
if got := normalizeUpstream(input); got != want {
t.Fatalf("normalizeUpstream(%q) = %q,期望 %q", input, got, want)
}
}
}
func TestDeployableProxy(t *testing.T) {
deployable := Proxy{Enabled: true, Type: "http", CustomDomains: []string{"a.com"}}
if !deployableProxy(deployable) {
t.Fatal("启用的 http 规则且有自定义域名,应可部署")
}
for _, proxy := range []Proxy{
{Enabled: false, Type: "http", CustomDomains: []string{"a.com"}},
{Enabled: true, Type: "tcp", CustomDomains: []string{"a.com"}},
{Enabled: true, Type: "http"},
} {
if deployableProxy(proxy) {
t.Fatalf("不应判定为可部署:%+v", proxy)
}
}
}
+233
View File
@@ -0,0 +1,233 @@
package frp
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// 健康状态取值。unknown 表示「判不出来」(frps 管理接口未配或不可用),
// 不能当成 down —— 这正是 tunnel_known 存在的理由。
const (
RouteHealthUp = "up"
RouteHealthDown = "down"
RouteHealthUnknown = "unknown"
// RouteHealthRetention 状态变更记录的保留期
RouteHealthRetention = 30 * 24 * time.Hour
)
// RouteHealthSample 是一轮巡检得到的一条规则健康结论
type RouteHealthSample struct {
ServerID int64
Domain string
State string
Reason string
Detail string
}
// RouteHealthEvent 一条状态变更记录
type RouteHealthEvent struct {
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// RouteHealthIncident 一段不可用区间;To 为空表示到窗口结束仍在持续
type RouteHealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// RouteUptimeStat 一条规则在窗口内的可用率与不可用区间
type RouteUptimeStat struct {
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []RouteHealthIncident `json:"incidents"`
}
// RecordRouteHealth 只写「状态变更」:与上一条同状态就不写,避免表被每 2 分钟的采样撑大。
// 返回写入条数。
func (s *Service) RecordRouteHealth(ctx context.Context, samples []RouteHealthSample) (int, error) {
if len(samples) == 0 {
return 0, nil
}
current, err := s.store.latestRouteHealthStates(ctx)
if err != nil {
return 0, err
}
now := time.Now().UTC().Format(time.RFC3339)
written := 0
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if domain == "" || sample.State == "" {
continue
}
key := routeHealthKey(sample.ServerID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx,
`INSERT INTO route_health_events(server_id, domain, state, reason, detail, at) VALUES (?, ?, ?, ?, ?, ?)`,
sample.ServerID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return written, err
}
written++
}
return written, nil
}
// RouteUptime 返回窗口内每条规则的可用率与不可用区间
func (s *Service) RouteUptime(ctx context.Context, window time.Duration) ([]RouteUptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.store.routeHealthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeRouteUptime(events, since, now), nil
}
// PruneRouteHealth 清理超过保留期的状态变更
func (s *Service) PruneRouteHealth(ctx context.Context) (int64, error) {
return s.store.pruneRouteHealthEvents(ctx, time.Now().UTC().Add(-RouteHealthRetention))
}
func routeHealthKey(serverID int64, domain string) string {
return fmt.Sprintf("%d|%s", serverID, domain)
}
// ComputeRouteUptime 由状态变更序列推导 [from, to] 窗口内的可用率。
//
// 覆盖时长只算「有记录可依」的部分:窗口开始前有记录时,其状态延续到窗口开始;
// 窗口内才第一次出现的规则,从它第一条记录开始算。窗口内完全没有记录的规则不出现在结果里。
// 可用率按 up/(up+down) 计,unknown 不计入分母(判不出来不能算不可用),但单独给出时长。
func ComputeRouteUptime(events []RouteHealthEvent, from, to time.Time) []RouteUptimeStat {
type point struct {
at time.Time
serverID int64
domain string
state string
reason, detail string
}
byRule := map[string][]point{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := routeHealthKey(event.ServerID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], point{
at: at, serverID: event.ServerID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]RouteUptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
// 窗口起点的生效状态:取 from 之前最后一条;没有则从第一条记录开始覆盖
start := from
index := 0
state := ""
reason, detail := "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
continue
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := RouteUptimeStat{
ServerID: points[0].serverID,
Domain: points[0].domain,
State: state,
Incidents: make([]RouteHealthIncident, 0, 2),
}
upMinutes := 0
// stat.State 要是「窗口末尾的状态」,不是窗口开始时的状态
lastState := state
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case RouteHealthUp:
upMinutes += minutes
case RouteHealthDown:
stat.DownMinutes += minutes
incident := RouteHealthIncident{
From: start.UTC().Format(time.RFC3339),
Reason: reason,
Detail: detail,
Minutes: minutes,
}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
lastState = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat.State = lastState
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
// 窗口内既没有 up 也没有 down(只有 unknown)时不编造可用率
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
stats = append(stats, stat)
}
return stats
}
@@ -0,0 +1,85 @@
package frp
import (
"context"
"database/sql"
"time"
)
// latestRouteHealthStates 取每条规则最新一条记录的状态,用于「同状态不重复写」判断
func (s *Store) latestRouteHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT e.server_id, e.domain, e.state
FROM route_health_events e
JOIN (
SELECT server_id, domain, MAX(id) AS id
FROM route_health_events
GROUP BY server_id, domain
) m ON m.id = e.id`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var (
serverID int64
domain string
state string
)
if err := rows.Scan(&serverID, &domain, &state); err != nil {
return nil, err
}
states[routeHealthKey(serverID, domain)] = state
}
return states, rows.Err()
}
// routeHealthEventsSince 取窗口内的变更,外加每条规则在窗口开始前的最后一条
// ——否则「窗口开始时是什么状态」就无从得知了。
func (s *Store) routeHealthEventsSince(ctx context.Context, since time.Time) ([]RouteHealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.db.QueryContext(ctx, `
SELECT server_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM route_health_events WHERE at >= ?
UNION ALL
SELECT e.server_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM route_health_events e
JOIN (
SELECT server_id, domain, MAX(id) AS id
FROM route_health_events WHERE at < ?
GROUP BY server_id, domain
) m ON m.id = e.id`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]RouteHealthEvent, 0, 32)
for rows.Next() {
var event RouteHealthEvent
if err := rows.Scan(&event.ServerID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// pruneRouteHealthEvents 清理 before 之前的记录,返回删除条数
func (s *Store) pruneRouteHealthEvents(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM route_health_events WHERE at < ?`, before.UTC().Format(time.RFC3339))
if err != nil {
return 0, err
}
affected, err := res.RowsAffected()
if err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
return affected, nil
}
+35
View File
@@ -0,0 +1,35 @@
package frp
import "testing"
func TestSummarizeRouteHealth(t *testing.T) {
items := []RouteHealthCandidate{
{DNSOK: true, CertOK: true, TunnelOK: true, TunnelKnown: true, ServiceOK: true},
{DNSOK: true, CertOK: false, TunnelOK: true, TunnelKnown: true, ServiceOK: false},
{DNSOK: false, CertOK: true, TunnelOK: false, TunnelKnown: true, ServiceOK: true},
// frps 管理接口不可用:隧道状态判不出来,既不算通也不是不通
{DNSOK: true, CertOK: true, ServiceOK: true},
}
summary := summarizeRouteHealth(2, []string{"vps-b"}, items)
if summary.Servers != 2 || summary.Routes != 4 || summary.Healthy != 1 {
t.Fatalf("unexpected summary counts: %+v", summary)
}
if summary.DNSFailed != 1 || summary.CertMissing != 1 || summary.TunnelDown != 1 ||
summary.TunnelUnknown != 1 || summary.ServiceDown != 1 {
t.Fatalf("unexpected segment counts: %+v", summary)
}
if len(summary.FailedServers) != 1 || summary.FailedServers[0] != "vps-b" {
t.Fatalf("unexpected failed servers: %+v", summary.FailedServers)
}
}
func TestSummarizeRouteHealthEmpty(t *testing.T) {
summary := summarizeRouteHealth(0, nil, nil)
if summary.Servers != 0 || summary.Routes != 0 || summary.Healthy != 0 {
t.Fatalf("expected empty summary, got %+v", summary)
}
if len(summary.FailedServers) != 0 {
t.Fatalf("expected no failed servers, got %+v", summary.FailedServers)
}
}
@@ -0,0 +1,108 @@
package frp
import (
"testing"
"time"
)
func findUptimeStat(t *testing.T, stats []RouteUptimeStat, domain string) RouteUptimeStat {
t.Helper()
for _, stat := range stats {
if stat.Domain == domain {
return stat
}
}
t.Fatalf("结果里没有 %s:%+v", domain, stats)
return RouteUptimeStat{}
}
func TestComputeRouteUptimeIncidentAndUnknown(t *testing.T) {
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
at := func(offset time.Duration) string { return from.Add(offset).Format(time.RFC3339) }
events := []RouteHealthEvent{
// 窗口开始前就是 up:状态延续到窗口开始
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: at(-2 * time.Hour)},
{ServerID: 1, Domain: "a.com", State: RouteHealthDown, At: at(time.Hour), Reason: "tunnel"},
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: at(2 * time.Hour)},
// 窗口内才第一次出现
{ServerID: 1, Domain: "b.com", State: RouteHealthUp, At: at(6 * time.Hour)},
// 判不出来
{ServerID: 2, Domain: "c.com", State: RouteHealthUnknown, At: at(-time.Hour)},
}
stats := ComputeRouteUptime(events, from, to)
if len(stats) != 3 {
t.Fatalf("应有 3 条规则的统计,实际 %d:%+v", len(stats), stats)
}
a := findUptimeStat(t, stats, "a.com")
if a.CoveredMinutes != 24*60 {
t.Fatalf("窗口开始前的状态应延续,覆盖 24h,实际 %d 分钟", a.CoveredMinutes)
}
if a.DownMinutes != 60 {
t.Fatalf("不可用应为 60 分钟,实际 %d", a.DownMinutes)
}
if a.Uptime < 95 || a.Uptime > 96 {
t.Fatalf("可用率应约 95.8%%,实际 %.2f", a.Uptime)
}
if len(a.Incidents) != 1 || a.Incidents[0].Minutes != 60 || a.Incidents[0].Reason != "tunnel" {
t.Fatalf("不可用区间不对:%+v", a.Incidents)
}
if a.Incidents[0].To == "" {
t.Fatal("已恢复的区间应有结束时间")
}
if a.State != RouteHealthUp {
t.Fatalf("窗口末尾状态应为 up,实际 %s", a.State)
}
b := findUptimeStat(t, stats, "b.com")
if b.CoveredMinutes != 18*60 {
t.Fatalf("窗口内才出现的规则应从第一条记录起算 18h,实际 %d", b.CoveredMinutes)
}
if b.Uptime != 100 || b.DownMinutes != 0 {
t.Fatalf("没有任何 down 时可用率应为 100:%+v", b)
}
c := findUptimeStat(t, stats, "c.com")
if c.UnknownMinutes != 24*60 || c.DownMinutes != 0 {
t.Fatalf("全程 unknown 不该算作不可用:%+v", c)
}
if c.Uptime != 0 {
t.Fatalf("全程判不出来时不能编造可用率(不给 100),实际 %.2f", c.Uptime)
}
}
func TestComputeRouteUptimeOngoingIncident(t *testing.T) {
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
events := []RouteHealthEvent{
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: from.Add(0).Format(time.RFC3339)},
{ServerID: 1, Domain: "a.com", State: RouteHealthDown, At: from.Add(20 * time.Hour).Format(time.RFC3339), Reason: "service"},
}
stats := ComputeRouteUptime(events, from, to)
stat := findUptimeStat(t, stats, "a.com")
if stat.DownMinutes != 4*60 {
t.Fatalf("到窗口结束仍在持续应计 240 分钟,实际 %d", stat.DownMinutes)
}
if len(stat.Incidents) != 1 {
t.Fatalf("应有 1 段区间:%+v", stat.Incidents)
}
if stat.Incidents[0].To != "" {
t.Fatalf("仍在持续的区间不应有结束时间,实际 %q", stat.Incidents[0].To)
}
if stat.State != RouteHealthDown {
t.Fatalf("窗口末尾状态应为 down,实际 %s", stat.State)
}
}
func TestComputeRouteUptimeSkipsRulesWithoutEvents(t *testing.T) {
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
// 窗口内没有任何记录 → 不出现在结果里,而不是给一个 100%
if stats := ComputeRouteUptime(nil, from, from.Add(time.Hour)); len(stats) != 0 {
t.Fatalf("没有记录时不该有统计:%+v", stats)
}
}
+564
View File
@@ -0,0 +1,564 @@
package frp
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const (
githubReleasesURL = "https://api.github.com/repos/fatedier/frp/releases"
maxArchiveSize = 150 << 20
)
var downloadProxyOptions = map[string]struct{}{
"": {},
"https://gh-proxy.org/": {},
"https://v4.gh-proxy.org/": {},
"https://cdn.gh-proxy.org/": {},
}
var releaseProxyOrder = []string{
"https://gh-proxy.org/",
"https://v4.gh-proxy.org/",
"https://cdn.gh-proxy.org/",
}
type githubRelease struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt time.Time `json:"published_at"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Digest string `json:"digest"`
}
type downloadReporter func(status, message string, downloaded, total int64, failure string)
func runtimePlatform() string {
return runtime.GOOS + "/" + runtime.GOARCH
}
func fetchOfficialReleases(ctx context.Context, selectedProxy string) ([]Release, error) {
var failures []string
for _, url := range releaseListURLs(selectedProxy) {
releases, err := fetchOfficialReleasesAt(ctx, url)
if err == nil {
return releases, nil
}
failures = append(failures, err.Error())
}
return nil, fmt.Errorf("获取 FRP 官方版本列表失败:%s", strings.Join(failures, ";"))
}
func fetchOfficialReleasesAt(ctx context.Context, url string) ([]Release, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/vnd.github+json")
client := &http.Client{Timeout: 30 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
}
var source []githubRelease
if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&source); err != nil {
return nil, fmt.Errorf("响应解析失败:%w", err)
}
return supportedReleases(source), nil
}
func releaseListURLs(selectedProxy string) []string {
urls := []string{}
add := func(proxy string) {
url := githubReleasesURL
if proxy != "" {
url = proxy + githubReleasesURL
}
for _, item := range urls {
if item == url {
return
}
}
urls = append(urls, url)
}
add(selectedProxy)
add("")
for _, proxy := range releaseProxyOrder {
add(proxy)
}
return urls
}
func supportedReleases(source []githubRelease) []Release {
releases := make([]Release, 0, len(source))
for _, item := range source {
if item.Draft || item.Prerelease {
continue
}
asset, ok := findArchiveAsset(item)
if !ok {
continue
}
releases = append(releases, Release{Version: item.TagName, PublishedAt: item.PublishedAt.UTC().Format(time.RFC3339), AssetName: asset.Name, Size: asset.Size, Checksum: strings.TrimPrefix(asset.Digest, "sha256:"), AssetID: asset.ID})
}
return releases
}
func findArchiveAsset(release githubRelease) (githubAsset, bool) {
version := strings.TrimPrefix(release.TagName, "v")
prefix := "frp_" + version + "_" + runtime.GOOS + "_" + runtime.GOARCH
for _, asset := range release.Assets {
if asset.Name == prefix+".zip" || asset.Name == prefix+".tar.gz" {
return asset, true
}
}
return githubAsset{}, false
}
func findRelease(releases []Release, version string) (Release, bool) {
for _, release := range releases {
if release.Version == version {
return release, true
}
}
return Release{}, false
}
func downloadOfficialBinary(ctx context.Context, frpDir string, release Release, proxy string, report downloadReporter) (string, error) {
checksum := release.Checksum
if checksum == "" {
report("queued", "正在获取官方 SHA-256 校验信息", 0, 0, "")
var err error
checksum, err = fetchChecksum(ctx, release)
if err != nil {
return "", err
}
}
archivePath, err := downloadArchive(ctx, frpDir, release, checksum, proxy, report)
if err != nil {
return "", err
}
defer os.Remove(archivePath)
report("extracting", "SHA-256 校验通过,正在解压 frpc", 0, 0, "")
return extractClientBinary(archivePath, frpDir, release)
}
// checksumAssetNames 官方校验文件名候选(按优先级)。
// frp 从早期版本起就一直是 frp_sha256_checksums.txt(不带版本号),
// 下面保留一个旧名字兼底,避开上游再次改名时整条下载链路直接 404。
func checksumAssetCandidates(version string) []string {
return []string{
"frp_sha256_checksums.txt",
"frp_" + version + "_sha256sums.txt",
}
}
func fetchChecksum(ctx context.Context, release Release) (string, error) {
version := strings.TrimPrefix(release.Version, "v")
candidates := checksumAssetCandidates(version)
var lastErr error
for _, name := range candidates {
data, err := fetchBytes(ctx, officialAssetURL(release.Version, name), 1<<20)
if err != nil {
lastErr = err
continue
}
if sum, ok := findChecksumLine(data, release.AssetName); ok {
return sum, nil
}
lastErr = fmt.Errorf("FRP 官方校验文件 %s 中未找到 %s", name, release.AssetName)
}
if lastErr == nil {
lastErr = fmt.Errorf("没有可用的官方校验文件")
}
return "", fmt.Errorf("下载 FRP 官方校验文件失败:%v(已尝试:%s)", lastErr, strings.Join(candidates, "、"))
}
// findChecksumLine 解析 sha256sum 格式(<hash> <文件名>,文件名可能带 * 前缀)。
func findChecksumLine(data []byte, assetName string) (string, bool) {
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == assetName {
return fields[0], true
}
}
return "", false
}
func downloadArchive(ctx context.Context, frpDir string, release Release, checksum, proxy string, report downloadReporter) (string, error) {
report("downloading", "正在下载 FRP 官方安装包", 0, release.Size, "")
data, err := fetchBytesWithProgress(ctx, releaseDownloadURL(release, proxy), maxArchiveSize, func(downloaded, total int64) {
if total <= 0 {
total = release.Size
}
report("downloading", "正在下载 FRP 官方安装包", downloaded, total, "")
})
if err != nil {
return "", fmt.Errorf("下载 FRP 官方安装包失败:%w;请检查网络连接或设置 HTTPS_PROXY", err)
}
report("verifying", "安装包下载完成,正在校验 SHA-256", int64(len(data)), int64(len(data)), "")
sum := fmt.Sprintf("%x", sha256.Sum256(data))
if !strings.EqualFold(sum, checksum) {
return "", fmt.Errorf("FRP 安装包 SHA-256 校验失败")
}
dir := filepath.Join(frpDir, "downloads")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, release.AssetName)
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func fetchBytes(ctx context.Context, url string, limit int64) ([]byte, error) {
return fetchBytesWithProgress(ctx, url, limit, nil)
}
func fetchBytesWithProgress(ctx context.Context, url string, limit int64, progress func(int64, int64)) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", "Havline-FRP-Downloader")
if strings.HasPrefix(url, "https://api.github.com/repos/fatedier/frp/releases/assets/") {
request.Header.Set("Accept", "application/octet-stream")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
}
client := &http.Client{Timeout: 5 * time.Minute}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
}
return readResponseBody(response.Body, response.ContentLength, limit, progress)
}
func readResponseBody(reader io.Reader, total, limit int64, progress func(int64, int64)) ([]byte, error) {
var output bytes.Buffer
buffer := make([]byte, 64*1024)
var downloaded int64
for {
count, err := reader.Read(buffer)
if count > 0 {
downloaded += int64(count)
if downloaded > limit {
return nil, fmt.Errorf("下载内容超过大小限制")
}
_, _ = output.Write(buffer[:count])
if progress != nil {
progress(downloaded, total)
}
}
if err == io.EOF {
return output.Bytes(), nil
}
if err != nil {
return nil, err
}
}
}
func officialAssetURL(version, name string) string {
return "https://github.com/fatedier/frp/releases/download/" + version + "/" + name
}
func releaseDownloadURL(release Release, proxy string) string {
if proxy != "" {
return proxy + officialAssetURL(release.Version, release.AssetName)
}
if release.AssetID > 0 {
return fmt.Sprintf("https://api.github.com/repos/fatedier/frp/releases/assets/%d", release.AssetID)
}
return officialAssetURL(release.Version, release.AssetName)
}
func downloadProxyPath(frpDir string) string {
return filepath.Join(frpDir, "download-proxy.json")
}
func readDownloadProxy(frpDir string) (string, error) {
data, err := os.ReadFile(downloadProxyPath(frpDir))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
var state struct {
Proxy string `json:"proxy"`
}
if err := json.Unmarshal(data, &state); err != nil {
return "", fmt.Errorf("FRP 下载代理配置无效")
}
if !validDownloadProxy(state.Proxy) {
return "", fmt.Errorf("FRP 下载代理配置无效")
}
return state.Proxy, nil
}
func writeDownloadProxy(frpDir, proxy string) error {
if !validDownloadProxy(proxy) {
return fmt.Errorf("不支持的 FRP 下载加速代理")
}
if err := os.MkdirAll(frpDir, 0o700); err != nil {
return err
}
data, err := json.Marshal(struct {
Proxy string `json:"proxy"`
}{Proxy: proxy})
if err != nil {
return err
}
return os.WriteFile(downloadProxyPath(frpDir), data, 0o600)
}
func validDownloadProxy(proxy string) bool {
_, ok := downloadProxyOptions[proxy]
return ok
}
func extractClientBinary(archivePath, frpDir string, release Release) (string, error) {
data, err := os.ReadFile(archivePath)
if err != nil {
return "", err
}
binary, err := extractBinary(data, release.AssetName)
if err != nil {
return "", err
}
path := filepath.Join(frpDir, "bin", release.Version, clientBinaryName())
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(path, binary, 0o700); err != nil {
return "", err
}
return path, nil
}
func extractBinary(data []byte, assetName string) ([]byte, error) {
if strings.HasSuffix(assetName, ".zip") {
return extractZipBinary(data)
}
return extractTarBinary(data)
}
func extractZipBinary(data []byte) ([]byte, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("FRP ZIP 安装包无效:%w", err)
}
for _, file := range reader.File {
if filepath.Base(file.Name) != clientBinaryName() || file.FileInfo().IsDir() {
continue
}
return readZipFile(file)
}
return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
}
func readZipFile(file *zip.File) ([]byte, error) {
if file.UncompressedSize64 > maxArchiveSize {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
reader, err := file.Open()
if err != nil {
return nil, err
}
defer reader.Close()
return boundedRead(reader, maxArchiveSize)
}
func extractTarBinary(data []byte) ([]byte, error) {
gzipReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("FRP TAR.GZ 安装包无效:%w", err)
}
defer gzipReader.Close()
reader := tar.NewReader(gzipReader)
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != clientBinaryName() {
continue
}
if header.Size > maxArchiveSize {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
return boundedRead(reader, maxArchiveSize)
}
return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
}
func boundedRead(reader io.Reader, limit int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
return data, nil
}
func clientBinaryName() string {
if runtime.GOOS == "windows" {
return "frpc.exe"
}
return "frpc"
}
func listBinaries(frpDir, active string) ([]Binary, error) {
root := filepath.Join(frpDir, "bin")
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return []Binary{}, nil
}
if err != nil {
return nil, err
}
binaries := make([]Binary, 0, len(entries))
for _, entry := range entries {
binary, ok := binaryFromEntry(root, entry, active)
if ok {
binaries = append(binaries, binary)
}
}
sort.Slice(binaries, func(i, j int) bool { return binaries[i].Version > binaries[j].Version })
return binaries, nil
}
func binaryFromEntry(root string, entry os.DirEntry, active string) (Binary, bool) {
if !entry.IsDir() || !validVersion(entry.Name()) {
return Binary{}, false
}
path := filepath.Join(root, entry.Name(), clientBinaryName())
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return Binary{}, false
}
return Binary{Version: entry.Name(), Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339), Active: filepath.Clean(path) == filepath.Clean(active)}, true
}
func binaryPathForVersion(frpDir, version string) (string, error) {
if !validVersion(version) {
return "", fmt.Errorf("FRP 版本格式无效")
}
path := filepath.Join(frpDir, "bin", version, clientBinaryName())
if !fileExists(path) {
return "", fmt.Errorf("FRP %s 尚未下载", version)
}
return path, nil
}
func removeBinaryVersion(frpDir, version, binaryPath string) error {
root, err := filepath.Abs(filepath.Join(frpDir, "bin"))
if err != nil {
return err
}
target, err := filepath.Abs(filepath.Dir(binaryPath))
if err != nil {
return err
}
relative, err := filepath.Rel(root, target)
if err != nil || filepath.Clean(relative) != version {
return fmt.Errorf("FRP 客户端目录无效")
}
return os.RemoveAll(target)
}
func validVersion(version string) bool {
if version == "" || len(version) > 32 || strings.ContainsAny(version, `/\\`) {
return false
}
for _, char := range version {
if (char < '0' || char > '9') && char != '.' && char != 'v' && char != '-' {
return false
}
}
return true
}
func activeBinaryPath(frpDir string) string {
return filepath.Join(frpDir, "bin", "active.json")
}
func readActiveBinary(frpDir string) (string, error) {
data, err := os.ReadFile(activeBinaryPath(frpDir))
if err != nil {
return "", err
}
var state struct {
Path string `json:"path"`
}
if err := json.Unmarshal(data, &state); err != nil || versionFromBinaryPath(frpDir, state.Path) == "" || !fileExists(state.Path) {
return "", fmt.Errorf("已启用的 FRP 客户端无效")
}
return state.Path, nil
}
func writeActiveBinary(frpDir, path string) error {
if err := os.MkdirAll(filepath.Dir(activeBinaryPath(frpDir)), 0o700); err != nil {
return err
}
data, err := json.Marshal(struct {
Path string `json:"path"`
}{Path: path})
if err != nil {
return err
}
return os.WriteFile(activeBinaryPath(frpDir), data, 0o600)
}
func versionFromBinaryPath(frpDir, path string) string {
root := filepath.Join(frpDir, "bin")
relative, err := filepath.Rel(root, path)
if err != nil || strings.HasPrefix(relative, "..") {
return ""
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) != 2 || !validVersion(parts[0]) {
return ""
}
return parts[0]
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+88
View File
@@ -0,0 +1,88 @@
package frp
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestSupportedReleasesUsesOfficialDigest(t *testing.T) {
version := "v0.99.0"
extension := ".tar.gz"
if runtime.GOOS == "windows" {
extension = ".zip"
}
assetName := "frp_0.99.0_" + runtime.GOOS + "_" + runtime.GOARCH + extension
releases := supportedReleases([]githubRelease{{
TagName: version,
Assets: []githubAsset{{ID: 123, Name: assetName, Size: 42, Digest: "sha256:abc123"}},
PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC),
}})
if len(releases) != 1 || releases[0].Checksum != "abc123" || releases[0].AssetID != 123 {
t.Fatalf("未读取官方资产摘要:%+v", releases)
}
}
func TestExtractZipBinary(t *testing.T) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
file, err := writer.Create("frp/" + clientBinaryName())
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte("client")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
data, err := extractZipBinary(buffer.Bytes())
if err != nil {
t.Fatal(err)
}
if string(data) != "client" {
t.Fatalf("提取内容错误:%q", data)
}
}
func TestValidDownloadProxy(t *testing.T) {
for _, proxy := range []string{"", "https://gh-proxy.org/", "https://v4.gh-proxy.org/", "https://cdn.gh-proxy.org/"} {
if !validDownloadProxy(proxy) {
t.Fatalf("合法下载代理被拒绝:%s", proxy)
}
}
if validDownloadProxy("https://untrusted.example/") {
t.Fatal("非白名单下载代理不应被接受")
}
}
func TestReleaseListURLsPrioritizesSelectedProxy(t *testing.T) {
urls := releaseListURLs("https://v4.gh-proxy.org/")
if urls[0] != "https://v4.gh-proxy.org/"+githubReleasesURL {
t.Fatalf("未优先使用已选代理:%v", urls)
}
if urls[1] != githubReleasesURL {
t.Fatalf("直连地址缺失:%v", urls)
}
}
func TestRemoveBinaryVersion(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "bin", "v0.71.0", clientBinaryName())
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("client"), 0o700); err != nil {
t.Fatal(err)
}
if err := removeBinaryVersion(root, "v0.71.0", path); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
t.Fatalf("版本目录未删除:%v", err)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
package frp
import (
"context"
"errors"
"strings"
"testing"
)
func TestDiagnoseDialError(t *testing.T) {
err := errors.New("connectex: An attempt was made to access a socket in a way forbidden by its access permissions.")
message := diagnoseDialError(err)
if !strings.HasPrefix(message, "本机网络策略阻止连接:") {
t.Fatalf("unexpected message: %s", message)
}
}
// 23.3:诊断拆分为独立检查项,登录状态从 frpc 日志推断,unknown 不等于失败。
func TestSummarizeFRPCLogin(t *testing.T) {
logLines := strings.Join([]string{
"2026/09/17 10:00:00 [I] [root.go:139] frpc service start",
"2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token error",
"2026/09/17 10:01:00 [I] [control.go:164] login to server success, get run id [abc]",
}, "\n")
state, detail := summarizeFRPCLogin(logLines)
if state != diagStateOK {
t.Fatalf("login success should win over earlier failure, got %s (%s)", state, detail)
}
failedLog := "2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token [wrong] error: authentication failed"
state, detail = summarizeFRPCLogin(failedLog)
if state != diagStateFailed || !strings.Contains(detail, "登录失败") {
t.Fatalf("login failure should be reported, got %s (%s)", state, detail)
}
state, _ = summarizeFRPCLogin("2026/09/17 10:00:00 [I] [root.go:139] frpc service start")
if state != diagStateUnknown {
t.Fatalf("no login evidence should be unknown, got %s", state)
}
}
func TestDiagnoseDNSFormatsRecords(t *testing.T) {
check := diagnoseDNS(context.Background(), "192.168.1.10")
if check.State != diagStateOK || !strings.Contains(check.Detail, "IP 地址") {
t.Fatalf("literal IP should skip resolution: %+v", check)
}
}
func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
detail := summarizeServerSamples([]ServerSample{
{CheckedAt: "2026-09-15 18:00:00", Available: true, LatencyMS: 20},
{CheckedAt: "2026-09-15 18:01:00", Available: false, Error: "连接失败"},
})
if detail.Available {
t.Fatal("latest failed sample should make current status unavailable")
}
if detail.Availability != 50 {
t.Fatalf("unexpected availability: %v", detail.Availability)
}
if detail.AverageLatency != 20 || detail.PeakLatency != 20 {
t.Fatalf("unexpected latency summary: average=%d peak=%d", detail.AverageLatency, detail.PeakLatency)
}
if detail.LastError != "连接失败" {
t.Fatalf("unexpected last error: %s", detail.LastError)
}
}
+185
View File
@@ -0,0 +1,185 @@
package frp
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 havline-agent。
// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
type SSHInstaller struct {
Host string
Port int
User string
Password string
PrivateKey string
Timeout time.Duration
// KnownHostsPath 是主机密钥记录文件:首次连接按 TOFU 记录指纹,之后必须一致
KnownHostsPath string
}
// hostKeyCallback 用 known_hosts 校验主机密钥:首次见到的主机按 TOFU 记录指纹,
// 已记录但与当前不一致则拒绝连接(避免中间人截获 VPS 的 root 凭据)。
func (i SSHInstaller) hostKeyCallback() (ssh.HostKeyCallback, error) {
path := strings.TrimSpace(i.KnownHostsPath)
if path == "" {
return nil, fmt.Errorf("未配置 SSH 主机密钥记录文件")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.WriteFile(path, nil, 0o600); err != nil {
return nil, err
}
}
known, err := knownhosts.New(path)
if err != nil {
return nil, fmt.Errorf("读取 SSH 主机密钥记录失败: %w", err)
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := known(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
if len(keyErr.Want) == 0 {
return appendKnownHost(path, remote, key)
}
return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请删除 %s 后重连",
remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key), path)
}
return fmt.Errorf("SSH 主机密钥校验失败: %w", err)
}, nil
}
// appendKnownHost 把首次见到的主机密钥追加进 known_hosts(TOFU:首次信任,之后必须一致)。
func appendKnownHost(path string, remote net.Addr, key ssh.PublicKey) error {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
line := knownhosts.Line([]string{knownhosts.Normalize(remote.String())}, key)
if _, err := file.WriteString(line + "\n"); err != nil {
return err
}
return nil
}
func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
if i.Port == 0 {
i.Port = 22
}
var auth ssh.AuthMethod
if i.PrivateKey != "" {
signer, err := ssh.ParsePrivateKey([]byte(i.PrivateKey))
if err != nil {
return nil, fmt.Errorf("解析 SSH 私钥失败: %w", err)
}
auth = ssh.PublicKeys(signer)
} else if i.Password != "" {
auth = ssh.Password(i.Password)
} else {
return nil, fmt.Errorf("SSH 密码或私钥不能为空")
}
hostKey, err := i.hostKeyCallback()
if err != nil {
return nil, err
}
config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: hostKey, Timeout: i.Timeout}
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
return ssh.Dial("tcp", fmt.Sprintf("%s:%d", i.Host, i.Port), config)
}
func (i SSHInstaller) Run(ctx context.Context, command string) (string, error) {
client, err := i.client(ctx)
if err != nil {
return "", err
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
var output strings.Builder
session.Stdout = &output
session.Stderr = &output
if err := session.Run(command); err != nil {
return output.String(), fmt.Errorf("远程命令失败: %w: %s", err, strings.TrimSpace(output.String()))
}
return output.String(), nil
}
// Install 只接受本地已构建的 agent 二进制与安装脚本,不接受用户任意命令。
func (i SSHInstaller) Install(ctx context.Context, binary []byte, script []byte) (string, error) {
client, err := i.client(ctx)
if err != nil {
return "", err
}
defer client.Close()
if err := uploadBytes(client, "/tmp/havline-agent", binary, 0o755); err != nil {
return "", err
}
if err := uploadBytes(client, "/tmp/havline-agent-install.sh", script, 0o700); err != nil {
return "", err
}
return runSSHSession(client, "sudo /tmp/havline-agent-install.sh /tmp/havline-agent && test -x /usr/local/bin/havline-agent && /usr/local/bin/havline-agent -version && rm -f /tmp/havline-agent /tmp/havline-agent-install.sh")
}
func uploadBytes(client *ssh.Client, path string, data []byte, mode uint32) error {
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return err
}
command := fmt.Sprintf("umask 077; cat > %s; chmod %o %s", shellPath(path), mode, shellPath(path))
if err := session.Start(command); err != nil {
return err
}
if _, err := stdin.Write(data); err != nil {
return err
}
_ = stdin.Close()
return session.Wait()
}
func runSSHSession(client *ssh.Client, command string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
var output strings.Builder
session.Stdout = &output
session.Stderr = &output
if err := session.Run(command); err != nil {
return output.String(), fmt.Errorf("远程 agent 安装失败: %w: %s", err, strings.TrimSpace(output.String()))
}
return output.String(), nil
}
func shellPath(path string) string {
return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'"
}
var _ io.Reader = (*os.File)(nil)
var _ = filepath.Separator
@@ -0,0 +1,32 @@
package frp
import "testing"
// TestShellPathEscapesSingleQuotes:uploadBytes 把路径拼进远程 shell 命令,
// 单引号必须转义,否则路径里的引号能截断命令。
func TestShellPathEscapesSingleQuotes(t *testing.T) {
cases := []struct{ in, want string }{
{"/tmp/havline-agent", "'/tmp/havline-agent'"},
{"/tmp/it's here", `'/tmp/it'\''s here'`},
{"", "''"},
}
for _, c := range cases {
if got := shellPath(c.in); got != c.want {
t.Errorf("shellPath(%q) = %s,期望 %s", c.in, got, c.want)
}
}
}
// TestIsELFBinary:内嵌二进制在开发构建里是占位文件,必须识别出来并提前报错,
// 否则 VPS 上 systemd 会反复 Exec format error。
func TestIsELFBinary(t *testing.T) {
if isELFBinary([]byte("not an elf")) {
t.Error("普通文本不应被判为 ELF")
}
if isELFBinary(nil) {
t.Error("空内容不应被判为 ELF")
}
if !isELFBinary([]byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}) {
t.Error("ELF 魔数应被识别")
}
}
+566
View File
@@ -0,0 +1,566 @@
package frp
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) ListServers(ctx context.Context) ([]Server, error) {
rows, err := s.db.QueryContext(ctx, serverSelect+` ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
servers := []Server{}
for rows.Next() {
server, err := scanServer(rows)
if err != nil {
return nil, err
}
servers = append(servers, server)
}
return servers, rows.Err()
}
func (s *Store) GetServer(ctx context.Context, id int64) (Server, error) {
server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Server{}, fmt.Errorf("FRP 服务端不存在")
}
return server, err
}
func (s *Store) GetEnabledServer(ctx context.Context) (Server, error) {
server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id LIMIT 1`))
if errors.Is(err, sql.ErrNoRows) {
return Server{}, fmt.Errorf("未启用 FRP 服务端")
}
return server, err
}
func (s *Store) ListEnabledServers(ctx context.Context) ([]Server, error) {
rows, err := s.db.QueryContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
servers := []Server{}
for rows.Next() {
server, err := scanServer(rows)
if err != nil {
return nil, err
}
servers = append(servers, server)
}
return servers, rows.Err()
}
func (s *Store) CreateServer(ctx context.Context, in ServerInput, tokenEnc, agentTokenEnc, sshSecretEnc string) (Server, error) {
opts, _ := json.Marshal(in.Options)
result, err := s.db.ExecContext(ctx, `
INSERT INTO frp_servers(name, server_addr, server_port, auth_token_enc, tls_enabled, tls_server_name, enabled, config_json, agent_url, agent_token_enc, ssh_host, ssh_port, ssh_user, ssh_auth, ssh_secret_enc, mgmt_enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, in.Name, in.ServerAddr, in.ServerPort, tokenEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(opts), in.AgentURL, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, sshSecretEnc, boolInt(in.MgmtEnabled))
if err != nil {
return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
}
id, _ := result.LastInsertId()
return s.GetServer(ctx, id)
}
func (s *Store) UpdateServer(ctx context.Context, id int64, in ServerInput, tokenEnc string, updateToken bool, agentTokenEnc string, updateAgentToken bool, sshSecretEnc string, updateSSHSecret bool) (Server, error) {
options, _ := json.Marshal(in.Options)
query := `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, agent_url = ?, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
args := []any{in.Name, in.ServerAddr, in.ServerPort, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), in.AgentURL, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, boolInt(in.MgmtEnabled), id}
if updateToken || updateAgentToken || updateSSHSecret {
query = `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, auth_token_enc = CASE WHEN ? THEN ? ELSE auth_token_enc END, agent_url = ?, agent_token_enc = CASE WHEN ? THEN ? ELSE agent_token_enc END, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, ssh_secret_enc = CASE WHEN ? THEN ? ELSE ssh_secret_enc END, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
args = []any{in.Name, in.ServerAddr, in.ServerPort, updateToken, tokenEnc, in.AgentURL, updateAgentToken, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, updateSSHSecret, sshSecretEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), boolInt(in.MgmtEnabled), id}
}
result, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
}
count, _ := result.RowsAffected()
if count == 0 {
return Server{}, fmt.Errorf("FRP 服务端不存在")
}
return s.GetServer(ctx, id)
}
func (s *Store) DeleteServer(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM frp_servers WHERE id = ?`, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) ServerRuntimeStatus(ctx context.Context, serverID int64) (ServerRuntimeStatus, error) {
status := ServerRuntimeStatus{
ServerID: serverID, Status: "stopped", DesiredState: "stopped",
ProcessState: "stopped", ConnectionState: "unknown", StateSource: "legacy",
}
var pid, exitCode sql.NullInt64
err := s.db.QueryRowContext(ctx, `
SELECT status, COALESCE(desired_state, status), COALESCE(process_state, 'stopped'),
COALESCE(connection_state, 'unknown'), COALESCE(state_source, 'legacy'),
process_pid, exit_code, COALESCE(last_error, ''), COALESCE(last_started_at, ''),
COALESCE(last_connected_at, ''), COALESCE(last_disconnected_at, '')
FROM frp_runtime_status WHERE server_id = ?
`, serverID).Scan(
&status.Status, &status.DesiredState, &status.ProcessState, &status.ConnectionState,
&status.StateSource, &pid, &exitCode, &status.LastError, &status.LastStartedAt,
&status.LastConnectedAt, &status.LastDisconnectedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return status, nil
}
if pid.Valid {
status.ProcessPID = int(pid.Int64)
}
if exitCode.Valid {
status.ExitCode = int(exitCode.Int64)
}
return status, err
}
func (s *Store) SetServerDesiredState(ctx context.Context, status ServerRuntimeStatus) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO frp_runtime_status(server_id, status, desired_state, state_source, last_started_at, last_error, updated_at)
VALUES (?, ?, ?, 'manual', NULLIF(?, ''), ?, datetime('now'))
ON CONFLICT(server_id) DO UPDATE SET
status = excluded.status,
desired_state = excluded.desired_state,
state_source = 'manual',
last_started_at = COALESCE(excluded.last_started_at, frp_runtime_status.last_started_at),
last_error = excluded.last_error,
updated_at = datetime('now')
`, status.ServerID, status.Status, status.DesiredState, status.LastStartedAt, status.LastError)
return err
}
func (s *Store) SetServerObservedState(ctx context.Context, status ServerRuntimeStatus) error {
desiredState := "stopped"
if status.ProcessState == "running" {
desiredState = "running"
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO frp_runtime_status(
server_id, status, desired_state, process_state, connection_state, state_source,
process_pid, exit_code, last_error, last_started_at, last_connected_at, last_disconnected_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), datetime('now'))
ON CONFLICT(server_id) DO UPDATE SET
process_state = excluded.process_state,
connection_state = excluded.connection_state,
state_source = excluded.state_source,
process_pid = excluded.process_pid,
exit_code = excluded.exit_code,
last_error = excluded.last_error,
last_started_at = COALESCE(NULLIF(excluded.last_started_at, ''), frp_runtime_status.last_started_at),
last_connected_at = COALESCE(NULLIF(excluded.last_connected_at, ''), frp_runtime_status.last_connected_at),
last_disconnected_at = COALESCE(NULLIF(excluded.last_disconnected_at, ''), frp_runtime_status.last_disconnected_at),
updated_at = datetime('now')
`, status.ServerID, desiredState, desiredState, status.ProcessState, status.ConnectionState, status.StateSource,
status.ProcessPID, status.ExitCode, status.LastError, status.LastStartedAt,
status.LastConnectedAt, status.LastDisconnectedAt)
return err
}
func (s *Store) AddServerDiagnostic(ctx context.Context, diagnostic ServerDiagnostics) error {
if _, err := s.db.ExecContext(ctx, `
INSERT INTO frp_server_diagnostics(server_id, checked_at, available, latency_ms, error)
VALUES (?, datetime('now'), ?, NULLIF(?, 0), ?)
`, diagnostic.ServerID, boolInt(diagnostic.Available), diagnostic.LatencyMS, diagnostic.Error); err != nil {
return err
}
// 只保留每个服务端最近 60 条采样,30 分钟曲线窗口远用不到这么多
_, err := s.db.ExecContext(ctx, `
DELETE FROM frp_server_diagnostics
WHERE server_id = ? AND id NOT IN (
SELECT id FROM frp_server_diagnostics WHERE server_id = ? ORDER BY id DESC LIMIT 60
)
`, diagnostic.ServerID, diagnostic.ServerID)
return err
}
func (s *Store) ServerDiagnosticDetail(ctx context.Context, serverID int64) (ServerStatusDetail, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT checked_at, available, COALESCE(latency_ms, 0), COALESCE(error, '')
FROM frp_server_diagnostics
WHERE server_id = ? AND checked_at >= datetime('now', '-30 minutes')
ORDER BY checked_at ASC, id ASC
`, serverID)
if err != nil {
return ServerStatusDetail{}, err
}
defer rows.Close()
detail := ServerStatusDetail{Samples: []ServerSample{}}
for rows.Next() {
var sample ServerSample
var available int
if err := rows.Scan(&sample.CheckedAt, &available, &sample.LatencyMS, &sample.Error); err != nil {
return ServerStatusDetail{}, err
}
sample.Available = available == 1
detail.Samples = append(detail.Samples, sample)
}
if err := rows.Err(); err != nil {
return ServerStatusDetail{}, err
}
return summarizeServerSamples(detail.Samples), nil
}
func summarizeServerSamples(samples []ServerSample) ServerStatusDetail {
detail := ServerStatusDetail{Samples: samples}
if len(samples) == 0 {
return detail
}
detail.Available = samples[len(samples)-1].Available
detail.LastCheckedAt = samples[len(samples)-1].CheckedAt
detail.LastError = samples[len(samples)-1].Error
var total int64
var available int
for _, sample := range samples {
if !sample.Available {
continue
}
available++
total += sample.LatencyMS
if sample.LatencyMS > detail.PeakLatency {
detail.PeakLatency = sample.LatencyMS
}
}
detail.Availability = float64(available) / float64(len(samples)) * 100
if available > 0 {
detail.AverageLatency = total / int64(available)
}
return detail
}
func (s *Store) CountOtherEnabledServers(ctx context.Context, id int64) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM frp_servers WHERE enabled = 1 AND id != ?`, id).Scan(&count)
return count, err
}
func (s *Store) SSHSecret(ctx context.Context, id int64) (string, error) {
var secret string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(ssh_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("FRP 服务端不存在")
}
return secret, err
}
func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
var token string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(agent_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("FRP 服务端不存在")
}
return token, err
}
func (s *Store) SetAgentTransport(ctx context.Context, id int64, agentURL, transport string, localPort int, tlsPin, listenAddr, state, lastError string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_url = ?, agent_transport = ?, agent_local_port = ?, agent_tls_pin = ?,
agent_listen_addr = ?, agent_transport_state = ?, agent_transport_error = ?,
updated_at = datetime('now')
WHERE id = ?
`, agentURL, transport, localPort, tlsPin, listenAddr, state, lastError, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentTLSPinRotation 更新当前证书 pin、过渡期旧 pin 与证书到期时间。
func (s *Store) SetAgentTLSPinRotation(ctx context.Context, id int64, pin, prevPin, prevUntil, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers
SET agent_tls_pin = ?, agent_tls_pin_prev = ?, agent_tls_pin_prev_until = ?, agent_tls_not_after = ?,
updated_at = datetime('now')
WHERE id = ?
`, pin, prevPin, prevUntil, notAfter, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentFirewallState 记录最近一次主机防火墙检测 / 放行结果,供前端展示。
func (s *Store) SetAgentFirewallState(ctx context.Context, id int64, state string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_firewall_state = ?, updated_at = datetime('now') WHERE id = ?
`, state, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
// SetAgentTLSNotAfter 只同步证书到期时间,不改动 pin(用于旧记录补齐)。
func (s *Store) SetAgentTLSNotAfter(ctx context.Context, id int64, notAfter string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE frp_servers SET agent_tls_not_after = ?, updated_at = datetime('now') WHERE id = ?
`, notAfter, 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)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("FRP 服务端不存在")
}
return token, err
}
func (s *Store) OIDCSecret(ctx context.Context, id int64) (string, error) {
var secret string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(oidc_client_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("FRP 服务端不存在")
}
return secret, err
}
func (s *Store) SetOIDCSecret(ctx context.Context, id int64, value string) error {
result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET oidc_client_secret_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) DashboardPassword(ctx context.Context, id int64) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(dashboard_password_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("FRP 服务端不存在")
}
return value, err
}
func (s *Store) SetDashboardPassword(ctx context.Context, id int64, value string) error {
result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET dashboard_password_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 服务端不存在")
}
return nil
}
func (s *Store) ListProxies(ctx context.Context) ([]Proxy, error) {
rows, err := s.db.QueryContext(ctx, proxySelect+` ORDER BY server_id ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProxies(rows)
}
func (s *Store) ListProxiesByServer(ctx context.Context, serverID int64) ([]Proxy, error) {
rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? ORDER BY id ASC`, serverID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProxies(rows)
}
func (s *Store) ListEnabledProxies(ctx context.Context, serverID int64) ([]Proxy, error) {
rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? AND enabled = 1 ORDER BY id ASC`, serverID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProxies(rows)
}
func (s *Store) GetProxy(ctx context.Context, id int64) (Proxy, error) {
proxy, err := scanProxy(s.db.QueryRowContext(ctx, proxySelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Proxy{}, fmt.Errorf("FRP 规则不存在")
}
return proxy, err
}
func (s *Store) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
domains, _ := json.Marshal(in.CustomDomains)
options, _ := json.Marshal(in.Options)
result, err := s.db.ExecContext(ctx, `
INSERT INTO frp_proxies(server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark)
if err != nil {
return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
}
id, _ := result.LastInsertId()
return s.GetProxy(ctx, id)
}
func (s *Store) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
domains, _ := json.Marshal(in.CustomDomains)
options, _ := json.Marshal(in.Options)
result, err := s.db.ExecContext(ctx, `
UPDATE frp_proxies SET server_id = ?, name = ?, type = ?, local_ip = ?, local_port = ?, remote_port = ?, custom_domains_json = ?, host_header_rewrite = ?, config_json = ?, enabled = ?, remark = ?, updated_at = datetime('now')
WHERE id = ?
`, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark, id)
if err != nil {
return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
}
count, _ := result.RowsAffected()
if count == 0 {
return Proxy{}, fmt.Errorf("FRP 规则不存在")
}
return s.GetProxy(ctx, id)
}
func (s *Store) DeleteProxy(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM frp_proxies WHERE id = ?`, id)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return fmt.Errorf("FRP 规则不存在")
}
return nil
}
func scanProxies(rows *sql.Rows) ([]Proxy, error) {
proxies := []Proxy{}
for rows.Next() {
proxy, err := scanProxy(rows)
if err != nil {
return nil, err
}
proxies = append(proxies, proxy)
}
return proxies, rows.Err()
}
const serverSelect = `
SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled,
CASE WHEN auth_token_enc IS NOT NULL AND auth_token_enc != '' THEN 1 ELSE 0 END,
CASE WHEN oidc_client_secret_enc IS NOT NULL AND oidc_client_secret_enc != '' THEN 1 ELSE 0 END,
CASE WHEN dashboard_password_enc IS NOT NULL AND dashboard_password_enc != '' THEN 1 ELSE 0 END,
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_tls_pin_prev, ''), COALESCE(agent_tls_pin_prev_until, ''), COALESCE(agent_tls_not_after, ''),
COALESCE(agent_firewall_state, ''),
COALESCE(agent_listen_addr, ''), COALESCE(agent_transport_state, 'unknown'), COALESCE(agent_transport_error, ''),
COALESCE(config_json, '{}'), created_at, updated_at
FROM frp_servers`
const proxySelect = `
SELECT id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
FROM frp_proxies`
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, &server.AgentTransport, &server.AgentLocalPort, &server.AgentTLSPin, &server.AgentTLSPinPrev, &server.AgentTLSPinPrevUntil, &server.AgentTLSNotAfter, &server.AgentFirewallState, &server.AgentListenAddr, &server.AgentTransportState, &server.AgentTransportError, &configJSON, &created, &updated)
if err != nil {
return Server{}, err
}
server.TLS = tls == 1
server.Enabled = enabled == 1
server.HasToken = token == 1
server.HasOIDCSecret = oidcSecret == 1
server.DashboardHasPwd = dashboardPwd == 1
server.AgentConfigured = agentConfigured == 1
server.SSHConfigured = sshConfigured == 1
server.MgmtEnabled = mgmtEnabled == 1
_ = json.Unmarshal([]byte(configJSON), &server.Options)
server.Options = normalizeServerOptions(server.Options)
server.CreatedAt = parseTime(created)
server.UpdatedAt = parseTime(updated)
return server, nil
}
func scanProxy(row interface{ Scan(dest ...any) error }) (Proxy, error) {
var proxy Proxy
var domains, configJSON, created, updated string
var enabled int
if err := row.Scan(&proxy.ID, &proxy.ServerID, &proxy.Name, &proxy.Type, &proxy.LocalIP, &proxy.LocalPort, &proxy.RemotePort, &domains, &proxy.HostHeaderRewrite, &configJSON, &enabled, &proxy.Remark, &created, &updated); err != nil {
return Proxy{}, err
}
_ = json.Unmarshal([]byte(domains), &proxy.CustomDomains)
_ = json.Unmarshal([]byte(configJSON), &proxy.Options)
proxy.Enabled = enabled == 1
proxy.CreatedAt = parseTime(created)
proxy.UpdatedAt = parseTime(updated)
return proxy, nil
}
func mapUniqueError(err error, message string) error {
if strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf(message)
}
return err
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func parseTime(value string) time.Time {
parsed, err := time.Parse("2006-01-02 15:04:05", value)
if err != nil {
parsed, _ = time.Parse(time.RFC3339, value)
}
return parsed.UTC()
}
+226
View File
@@ -0,0 +1,226 @@
package frp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
type frpsProxyStatus struct {
Name string `json:"name"`
Type string `json:"type"`
TodayTrafficIn int64 `json:"today_traffic_in"`
TodayTrafficOut int64 `json:"today_traffic_out"`
CurrentConns int `json:"cur_conns"`
}
type ProxyTraffic struct {
TrafficIn int64 `json:"traffic_in"`
TrafficOut int64 `json:"traffic_out"`
CurConns int `json:"cur_conns"`
TrafficInRate int64 `json:"traffic_in_rate"`
TrafficOutRate int64 `json:"traffic_out_rate"`
}
type ServerTraffic struct {
ServerID int64 `json:"server_id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Proxies map[string]ProxyTraffic `json:"proxies"`
ProxyNamesByDomain map[string]string `json:"proxy_names_by_domain,omitempty"`
}
type ProxiesTraffic struct {
Items map[int64]ProxyTraffic `json:"items"`
Errors map[int64]string `json:"errors,omitempty"`
SampledAt string `json:"sampled_at"`
}
type trafficSample struct {
at time.Time
in int64
out int64
}
type frpsTrafficClient struct {
httpClient *http.Client
}
func newFRPSTrafficClient() *frpsTrafficClient {
return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
}
// fetchServerTraffic 版本自适应采集:
// - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
// - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
//
// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
empty := map[string]ProxyTraffic{}
if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
}
// 优先 V2 API(v0.71.0+)
v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
body, status, err := c.getJSON(ctx, v2URL, server, password)
if err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
}
if status == http.StatusOK {
return c.parseV2Proxies(server, body)
}
if status != http.StatusNotFound {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
}
// 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
// fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
}
// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
type legacyProxyInfo struct {
Name string `json:"name"`
User string `json:"user"`
Conf struct {
CustomDomains []string `json:"customDomains"`
} `json:"conf"`
Status string `json:"status"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
}
// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
body, status, err := c.getJSON(ctx, url, server, password)
if err != nil {
return legacyProxyInfo{}, 0, err
}
if status != http.StatusOK {
return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
}
var info legacyProxyInfo
if err := json.Unmarshal(body, &info); err != nil {
return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
}
return info, status, nil
}
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
empty := map[string]ProxyTraffic{}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
var lastErr error
for _, name := range names {
proxyName := name
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
proxyName = user + "." + proxyName
}
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
if err != nil {
lastErr = err
continue
}
traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
proxies[info.Name] = traffic
for _, domain := range info.Conf.CustomDomains {
if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = info.Name
}
}
}
if len(proxies) == 0 && lastErr != nil {
return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
req.SetBasicAuth(user, password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
// parseV2Proxies 解析 V2 API 响应(实测结构):
// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
empty := map[string]ProxyTraffic{}
var payload struct {
Data struct {
Items []struct {
Name string `json:"name"`
User string `json:"user"`
Spec struct {
Type string `json:"type"`
HTTP struct {
CustomDomains []string `json:"customDomains"`
} `json:"http"`
} `json:"spec"`
Status struct {
Phase string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
} `json:"status"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
for _, item := range payload.Data.Items {
traffic := ProxyTraffic{
TrafficIn: item.Status.TodayTrafficIn,
TrafficOut: item.Status.TodayTrafficOut,
CurConns: item.Status.CurConns,
}
proxies[item.Name] = traffic
// 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
for _, d := range item.Spec.HTTP.CustomDomains {
domain := strings.ToLower(strings.TrimSpace(d))
if domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = item.Name
}
}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
func validDashboardAddr(addr string) bool {
addr = strings.TrimSpace(addr)
if addr == "" {
return true
}
return validHost(addr)
}
+361
View File
@@ -0,0 +1,361 @@
package frp
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
)
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func validateServerInput(in ServerInput) error {
if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
return fmt.Errorf("服务端名称长度应为 1-64 个字符")
}
if !validHost(in.ServerAddr) {
return fmt.Errorf("FRP 服务端地址无效")
}
if !validPort(in.ServerPort) {
return fmt.Errorf("FRP 服务端端口无效")
}
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
return fmt.Errorf("frps 管理接口端口无效")
}
if !validDashboardAddr(in.Options.DashboardAddr) {
return fmt.Errorf("frps 管理接口地址无效")
}
if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
return fmt.Errorf("TLS 服务名无效")
}
if !validAuthMethod(in.Options.AuthMethod) {
return fmt.Errorf("不支持的认证方式")
}
if !validLogLevel(in.Options.LogLevel) {
return fmt.Errorf("日志级别无效")
}
if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
return fmt.Errorf("日志保留天数应为 0-3650")
}
if !validProtocol(in.Options.Protocol) {
return fmt.Errorf("传输协议无效")
}
if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
return fmt.Errorf("代理或 OIDC 地址无效")
}
if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
}
if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
return fmt.Errorf("传输参数不能小于 0")
}
if len([]rune(in.Options.Location)) > 120 {
return fmt.Errorf("服务端位置不能超过 120 个字符")
}
return nil
}
func validateProxyInput(in ProxyInput) error {
if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
}
if in.Options.Plugin == nil {
if !validHost(in.LocalIP) {
return fmt.Errorf("内网地址无效")
}
if !validPort(in.LocalPort) {
return fmt.Errorf("内网端口无效")
}
}
switch in.Type {
case "tcp", "udp":
if in.RemotePort == nil || !validPort(*in.RemotePort) {
return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
}
if len(in.CustomDomains) > 0 {
return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
}
case "http", "https":
if in.RemotePort != nil {
return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
case "tcpmux":
if in.Options.Multiplexer != "httpconnect" {
return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
if in.RemotePort != nil {
return fmt.Errorf("TCPMUX 规则不能设置远程端口")
}
case "stcp", "sudp", "xtcp":
if strings.TrimSpace(in.Options.SecretKey) == "" {
return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
}
if in.RemotePort != nil {
return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
}
default:
return fmt.Errorf("不支持的 FRP 规则类型")
}
if err := validateProxyOptions(in); err != nil {
return err
}
return nil
}
func validateProxyOptions(in ProxyInput) error {
opts := in.Options
if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
return fmt.Errorf("限速位置只能是 client 或 server")
}
if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
return fmt.Errorf("代理协议版本只能是 v1 或 v2")
}
if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
return fmt.Errorf("健康检查类型只能是 tcp 或 http")
}
if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
return fmt.Errorf("健康检查参数不能小于 0")
}
if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
return fmt.Errorf("负载均衡组设置后必须填写组密钥")
}
return validateProxyPlugin(opts.Plugin)
}
// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
func validateProxyPlugin(plugin *ProxyPluginOptions) error {
if plugin == nil {
return nil
}
pluginType := strings.TrimSpace(plugin.Type)
if pluginType == "" {
return fmt.Errorf("插件类型不能为空")
}
validAddr := func(addr string, label string) error {
addr = strings.TrimSpace(addr)
host, portText, ok := strings.Cut(addr, ":")
if !ok || !validHost(host) || !validPort(parseInt(portText)) {
return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
}
return nil
}
validSecretFile := func(path, label string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
}
return nil
}
switch pluginType {
case "unix_domain_socket":
if strings.TrimSpace(plugin.UnixPath) == "" {
return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
}
case "static_file":
if strings.TrimSpace(plugin.LocalPath) == "" {
return fmt.Errorf("插件 static_file 需要提供静态文件目录")
}
case "http_proxy", "socks5":
// 用户名/密码可选
case "http2https", "http2http":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
case "https2http", "https2https", "tls2raw":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
return err
}
if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
return err
}
default:
return fmt.Errorf("不支持的插件类型:%s", pluginType)
}
return nil
}
// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
func parseInt(value string) int {
value = strings.TrimSpace(value)
number := 0
if value == "" {
return 0
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return 0
}
number = number*10 + int(ch-'0')
}
return number
}
// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
func ValidateProxy(in ProxyInput) error {
return validateProxyInput(normalizeProxyInput(in))
}
func normalizeServerInput(in ServerInput) ServerInput {
in.Name = strings.TrimSpace(in.Name)
in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
in.Token = strings.TrimSpace(in.Token)
in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
in.Options = normalizeServerOptions(in.Options)
return in
}
func normalizeServerOptions(in ServerOptions) ServerOptions {
in.User = strings.TrimSpace(in.User)
in.Location = strings.TrimSpace(in.Location)
in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
in.OIDCScope = strings.TrimSpace(in.OIDCScope)
in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
in.ProxyURL = strings.TrimSpace(in.ProxyURL)
in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
in.DNSServer = strings.TrimSpace(in.DNSServer)
in.Remark = strings.TrimSpace(in.Remark)
if in.AuthMethod == "" {
in.AuthMethod = "token"
}
if in.LogLevel == "" {
in.LogLevel = "info"
}
if in.Protocol == "" {
in.Protocol = "tcp"
}
if in.LogMaxDays == 0 {
in.LogMaxDays = 3
}
if in.DialServerTimeout == 0 {
in.DialServerTimeout = 10
}
if in.DialServerKeepalive == 0 {
in.DialServerKeepalive = 7200
}
if in.TCPMuxKeepaliveInterval == 0 {
in.TCPMuxKeepaliveInterval = 60
}
if in.HeartbeatInterval == 0 {
in.HeartbeatInterval = 30
}
if in.HeartbeatTimeout == 0 {
in.HeartbeatTimeout = 90
}
return in
}
func validAuthMethod(value string) bool {
return value == "none" || value == "token" || value == "oidc"
}
func validLogLevel(value string) bool {
return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
}
func validProtocol(value string) bool {
return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
}
func validOptionalURL(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.Host != ""
}
func normalizeProxyInput(in ProxyInput) ProxyInput {
in.Name = strings.TrimSpace(in.Name)
in.Type = strings.ToLower(strings.TrimSpace(in.Type))
in.LocalIP = strings.TrimSpace(in.LocalIP)
in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
in.Remark = strings.TrimSpace(in.Remark)
in.Options = normalizeProxyOptions(in.Options)
seen := map[string]bool{}
domains := make([]string, 0, len(in.CustomDomains))
for _, domain := range in.CustomDomains {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain != "" && !seen[domain] {
seen[domain] = true
domains = append(domains, domain)
}
}
in.CustomDomains = domains
return in
}
func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
in.Subdomain = strings.TrimSpace(in.Subdomain)
in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
in.HTTPUser = strings.TrimSpace(in.HTTPUser)
in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
in.SecretKey = strings.TrimSpace(in.SecretKey)
for index, user := range in.AllowUsers {
in.AllowUsers[index] = strings.TrimSpace(user)
}
in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
return in
}
func validPort(port int) bool {
return port > 0 && port <= 65535
}
func validHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
return false
}
if net.ParseIP(host) != nil || host == "localhost" {
return true
}
return validDomain(host)
}
func validDomain(domain string) bool {
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
return false
}
for _, label := range strings.Split(domain, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, ch := range label {
if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
return false
}
}
}
return true
}
+43
View File
@@ -0,0 +1,43 @@
package frp
import "testing"
func TestValidateProxyInput(t *testing.T) {
port := 60022
cases := []struct {
name string
input ProxyInput
wantErr bool
}{
{name: "tcp", input: ProxyInput{Name: "nas_ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port}},
{name: "http", input: ProxyInput{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}}},
{name: "missing remote port", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
{name: "invalid name", input: ProxyInput{Name: "bad name", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"nas.example.com"}}, wantErr: true},
{name: "invalid domain", input: ProxyInput{Name: "nas", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"bad domain"}}, wantErr: true},
{name: "udp", input: ProxyInput{Name: "dns", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
{name: "stcp", input: ProxyInput{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "abc"}}},
{name: "tcpmux missing multiplexer", input: ProxyInput{Name: "mux", Type: "tcpmux", LocalIP: "127.0.0.1", LocalPort: 80}, wantErr: true},
{name: "invalid bandwidth mode", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Transport: ProxyTransportOptions{BandwidthLimitMode: "wrong"}}}, wantErr: true},
{name: "udp remote port", input: ProxyInput{Name: "dns2", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
{name: "xtcp missing secret key", input: ProxyInput{Name: "p2p", Type: "xtcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
{name: "plugin http2https missing local addr", input: ProxyInput{Name: "p1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", CRTPath: "cert.pem", KeyPath: "key.pem"}}}, wantErr: true},
{name: "plugin https2http missing cert", input: ProxyInput{Name: "p2", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", LocalAddr: "127.0.0.1:80"}}}, wantErr: true},
{name: "plugin socks5 optional auth", input: ProxyInput{Name: "p3", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "socks5"}}}},
{name: "plugin unix_domain_socket missing path", input: ProxyInput{Name: "p4", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "unix_domain_socket"}}}, wantErr: true},
{name: "plugin static_file missing local path", input: ProxyInput{Name: "p5", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "static_file"}}}, wantErr: true},
{name: "plugin unknown type", input: ProxyInput{Name: "p6", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "whatever"}}}, wantErr: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
err := validateProxyInput(testCase.input)
if (err != nil) != testCase.wantErr {
t.Fatalf("validateProxyInput() error = %v, wantErr %v", err, testCase.wantErr)
}
// 对外导出的校验入口应与内部入口行为一致(关系表模式校验复用此函数)
if (ValidateProxy(testCase.input) != nil) != testCase.wantErr {
t.Fatalf("ValidateProxy() error = %v, wantErr %v", ValidateProxy(testCase.input), testCase.wantErr)
}
})
}
}
+27
View File
@@ -0,0 +1,27 @@
package frp
import (
"path/filepath"
"testing"
)
func TestVersionFromBinaryPath(t *testing.T) {
frpDir := filepath.Join("data", "frp")
cases := []struct {
name string
path string
want string
}{
{"带 v 前缀的版本目录", filepath.Join(frpDir, "bin", "v0.71.0", "frpc.exe"), "v0.71.0"},
{"纯数字版本目录", filepath.Join(frpDir, "bin", "0.62.1", "frpc"), "0.62.1"},
{"不在 bin/<版本>/ 下", filepath.Join(frpDir, "frpc.exe"), ""},
{"不在 FrpDir 下", filepath.Join("other", "bin", "v1.0.0", "frpc"), ""},
{"多一层子目录", filepath.Join(frpDir, "bin", "v1.0.0", "extra", "frpc"), ""},
{"目录名不是版本", filepath.Join(frpDir, "bin", "latest", "frpc"), ""},
}
for _, tc := range cases {
if got := versionFromBinaryPath(frpDir, tc.path); got != tc.want {
t.Fatalf("%s:versionFromBinaryPath(%q) = %q,期望 %q", tc.name, tc.path, got, tc.want)
}
}
}