feat: create standalone native fnOS repository
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("不存在的文件不应匹配到哈希")
|
||||
}
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, " · ")
|
||||
}
|
||||
@@ -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 不应被定位")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 魔数应被识别")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user