chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
package frp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// AgentClient 调用公网 VPS 上的 fonu-agent。凭据由调用方解密后只在内存中使用。
type AgentClient struct {
baseURL string
token string
http *http.Client
}
func NewAgentClient(baseURL, token string) *AgentClient {
return &AgentClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: token,
// 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
http: &http.Client{Timeout: 60 * time.Second},
}
}
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, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("agent 返回 HTTP %d: %s", resp.StatusCode, 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) 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
}
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)
}
+172
View File
@@ -0,0 +1,172 @@
package frp
import (
"context"
"fmt"
"strings"
"github.com/fonu/fonu/internal/frp/agentbin"
)
// AgentInstaller 负责把内嵌的 fonu-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/fonu-agent && echo yes || echo no") == "yes",
"agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"),
"agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"),
"agent_token": out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"),
"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/fonu-agent-linux-amd64 ./cmd/fonu-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/fonu-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
}
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
}
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/fonu-agent}
PREFIX=/usr/local/bin/fonu-agent
CONF_DIR=/etc/fonu-agent
CONF_FILE="$CONF_DIR/agent.env"
UNIT=/etc/systemd/system/fonu-agent.service
if [ "${1:-}" = "--uninstall" ]; then
systemctl stop fonu-agent 2>/dev/null || true
systemctl disable fonu-agent 2>/dev/null || true
rm -f "$UNIT" "$PREFIX" /usr/local/bin/fonu-agent-uninstall
systemctl daemon-reload
if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/fonu-agent; fi
echo "fonu-agent 已卸载"
exit 0
fi
mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
systemctl stop fonu-agent 2>/dev/null || true
TMP_PREFIX="${PREFIX}.new"
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
printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE"
fi
cat > "$UNIT" <<'UNIT'
[Unit]
Description=Fonu FRPS management agent
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/fonu-agent/agent.env
ExecStart=/usr/local/bin/fonu-agent
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl restart fonu-agent
systemctl enable fonu-agent
cp "$0" /usr/local/bin/fonu-agent-uninstall
echo "fonu-agent installed"
echo "Token: $(sed -n 's/^FONU_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'
}
+25
View File
@@ -0,0 +1,25 @@
// Package agentbin 内嵌各架构 fonu-agent 二进制,供 SSH 安装器按 VPS 架构选择。
// 仓库中的占位文件保证开发机构建可通过;Docker 构建阶段会用真实二进制覆盖它们
// (见 Dockerfile:先交叉编译 agent,再构建主程序)。
package agentbin
import (
_ "embed"
)
//go:embed fonu-agent-linux-amd64
var LinuxAMD64 []byte
//go:embed fonu-agent-linux-arm64
var LinuxARM64 []byte
// BinaryForArch 返回对应架构的 agent 二进制;arch 取 uname -m 输出(x86_64/aarch64)。
func BinaryForArch(arch string) ([]byte, bool) {
switch arch {
case "x86_64", "amd64":
return LinuxAMD64, true
case "aarch64", "arm64":
return LinuxARM64, true
}
return nil, false
}
Binary file not shown.
Binary file not shown.
+247
View File
@@ -0,0 +1,247 @@
package frp
import (
"fmt"
"sort"
"strconv"
"strings"
)
type renderedConfig struct {
Server Server
Token string
OIDCClientSecret string
LogPath string
TLSCertPath string
TLSKeyPath string
TLSTrustedCAPath string
Proxies []Proxy
}
func renderTOML(cfg renderedConfig) string {
var out strings.Builder
writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
options := normalizeServerOptions(cfg.Server.Options)
if options.User != "" {
writeKeyValue(&out, "user", options.User)
}
if options.AuthMethod != "none" {
writeKeyValue(&out, "auth.method", options.AuthMethod)
}
if options.AuthMethod == "token" {
writeKeyValue(&out, "auth.token", cfg.Token)
}
if options.AuthMethod == "oidc" {
writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
if options.OIDCAudience != "" {
writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
}
if options.OIDCScope != "" {
writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
}
}
if cfg.LogPath != "" {
writeKeyValue(&out, "log.to", cfg.LogPath)
}
writeKeyValue(&out, "log.level", options.LogLevel)
writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
writeKeyValue(&out, "transport.protocol", options.Protocol)
writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
if cfg.Server.TLSServerName != "" {
writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
}
if cfg.TLSCertPath != "" {
writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
}
if cfg.TLSKeyPath != "" {
writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
}
if cfg.TLSTrustedCAPath != "" {
writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
}
if options.ProxyURL != "" {
writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
}
writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
if options.ConnectServerLocalIP != "" {
writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
}
if options.DNSServer != "" {
writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
}
writeKeyValue(&out, "transport.poolCount", options.PoolCount)
writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
for _, proxy := range cfg.Proxies {
out.WriteString("\n[[proxies]]\n")
writeKeyValue(&out, "name", proxy.Name)
writeKeyValue(&out, "type", proxy.Type)
writeKeyValue(&out, "localIP", proxy.LocalIP)
writeKeyValue(&out, "localPort", proxy.LocalPort)
if proxy.Type == "tcp" && proxy.RemotePort != nil {
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
}
if proxy.Type == "udp" && proxy.RemotePort != nil {
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
}
if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
writeStringSlice(&out, "customDomains", proxy.CustomDomains)
}
if proxy.HostHeaderRewrite != "" {
writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
}
writeProxyOptions(&out, proxy)
}
return out.String()
}
func writeProxyOptions(out *strings.Builder, proxy Proxy) {
opts := proxy.Options
if opts.Subdomain != "" {
writeKeyValue(out, "subdomain", opts.Subdomain)
}
if len(opts.Locations) > 0 {
writeStringSlice(out, "locations", opts.Locations)
}
if opts.RouteByHTTPUser != "" {
writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
}
if opts.HTTPUser != "" {
writeKeyValue(out, "httpUser", opts.HTTPUser)
}
if opts.HTTPPassword != "" {
writeKeyValue(out, "httpPassword", opts.HTTPPassword)
}
if opts.Multiplexer != "" {
writeKeyValue(out, "multiplexer", opts.Multiplexer)
}
if opts.SecretKey != "" {
writeKeyValue(out, "secretKey", opts.SecretKey)
}
if len(opts.AllowUsers) > 0 {
writeStringSlice(out, "allowUsers", opts.AllowUsers)
}
writeProxyTransport(out, opts.Transport)
writeProxyHealthCheck(out, opts.HealthCheck)
if opts.LoadBalancer.Group != "" {
writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
}
if opts.LoadBalancer.GroupKey != "" {
writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
}
writeStringMap(out, "metadatas", opts.Metadatas)
writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
// natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
// disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
}
if opts.Plugin != nil {
writeProxyPlugin(out, *opts.Plugin)
}
}
func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
if opts.BandwidthLimit != "" {
writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
}
if opts.BandwidthLimitMode != "" {
writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
}
if opts.UseEncryption {
writeKeyValue(out, "transport.useEncryption", true)
}
if opts.UseCompression {
writeKeyValue(out, "transport.useCompression", true)
}
if opts.ProxyProtocolVersion != "" {
writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
}
}
func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
if opts.Type == "" {
return
}
writeKeyValue(out, "healthCheck.type", opts.Type)
if opts.Path != "" {
writeKeyValue(out, "healthCheck.path", opts.Path)
}
if opts.IntervalSeconds > 0 {
writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
}
if opts.MaxFailed > 0 {
writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
}
if opts.TimeoutSeconds > 0 {
writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
}
if len(opts.HTTPHeaders) > 0 {
values := make([]string, 0, len(opts.HTTPHeaders))
for _, header := range opts.HTTPHeaders {
values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
}
fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
}
}
func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
out.WriteString("[proxies.plugin]\n")
writeKeyValue(out, "type", plugin.Type)
for _, item := range []struct{ key, value string }{
{"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
{"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
{"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
{"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
} {
if item.value != "" {
writeKeyValue(out, item.key, item.value)
}
}
}
func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if key != "" {
writeKeyValue(out, prefix+"."+key, values[key])
}
}
}
func renderMaskedTOML(server Server, proxies []Proxy) string {
return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
}
func writeKeyValue(out *strings.Builder, key string, value any) {
switch v := value.(type) {
case string:
fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
case int:
fmt.Fprintf(out, "%s = %d\n", key, v)
case bool:
fmt.Fprintf(out, "%s = %t\n", key, v)
}
}
func writeStringSlice(out *strings.Builder, key string, values []string) {
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, strconv.Quote(value))
}
fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
}
+65
View File
@@ -0,0 +1,65 @@
package frp
import (
"strings"
"testing"
)
func TestRenderTOML(t *testing.T) {
remotePort := 60022
content := renderTOML(renderedConfig{
Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
Token: "token with \"quotes\"",
Proxies: []Proxy{
{Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
},
})
for _, expected := range []string{
`serverAddr = "vps.example.com"`,
`auth.token = "token with \"quotes\""`,
`remotePort = 60022`,
`customDomains = ["nas.example.com"]`,
} {
if !strings.Contains(content, expected) {
t.Fatalf("配置缺少 %q:\n%s", expected, content)
}
}
}
func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
if !strings.Contains(content, `auth.token = "********"`) {
t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
}
}
func TestRenderProxyOptions(t *testing.T) {
content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
{Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
}})
for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
if !strings.Contains(content, expected) {
t.Fatalf("配置缺少 %q:\n%s", expected, content)
}
}
}
func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
omitted := renderTOML(renderedConfig{Proxies: []Proxy{
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
}})
if strings.Contains(omitted, "natTraversal") {
t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
}
enabled := renderTOML(renderedConfig{Proxies: []Proxy{
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
}})
if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
}
}
func intPtr(value int) *int { return &value }
+46
View File
@@ -0,0 +1,46 @@
package frp
import (
"os"
"strings"
)
const maxConnectionLogRead = 128 << 10
func inspectConnectionLog(path, processState string) (string, string) {
if processState != "running" {
if processState == "error" {
return "error", "frpc 进程已退出"
}
return "disconnected", ""
}
data, err := os.ReadFile(path)
if err != nil {
return "unknown", ""
}
if len(data) > maxConnectionLogRead {
data = data[len(data)-maxConnectionLogRead:]
}
lines := strings.Split(string(data), "\n")
for index := len(lines) - 1; index >= 0; index-- {
line := strings.TrimSpace(lines[index])
lower := strings.ToLower(line)
switch {
case strings.Contains(lower, "login to server success"):
return "connected", ""
case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
return "error", trimConnectionError(line)
case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
return "disconnected", ""
}
}
return "unknown", ""
}
func trimConnectionError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 500 {
return value[:500]
}
return value
}
+251
View File
@@ -0,0 +1,251 @@
package frp
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
)
// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
const (
diagStateOK = "ok"
diagStateFailed = "failed"
diagStateUnknown = "unknown"
diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second
diagnosticsMaxInFlight = 4
)
type diagnosticsCacheEntry struct {
diagnostics ServerDiagnostics
expiresAt time.Time
}
// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return ServerDiagnostics{}, err
}
return s.runDiagnostics(ctx, server), nil
}
func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
s.diagMu.Lock()
if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
cached := entry.diagnostics
s.diagMu.Unlock()
return cached
}
// 并发上限:超限时返回旧结果或 unknown,不排队等待
if s.diagInFlight >= diagnosticsMaxInFlight {
cached, ok := s.diagCache[server.ID]
s.diagMu.Unlock()
if ok {
return cached.diagnostics
}
return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
}
s.diagInFlight++
s.diagMu.Unlock()
defer func() {
s.diagMu.Lock()
s.diagInFlight--
s.diagMu.Unlock()
}()
result := s.computeDiagnostics(ctx, server)
s.diagMu.Lock()
s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
s.diagMu.Unlock()
return result
}
// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
// 绝不写入 Error(Error 仅表示控制端口探测失败)。
func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
diagnostics := ServerDiagnostics{
ServerID: server.ID,
Available: tcpCheck.State == diagStateOK,
LatencyMS: tcpCheck.LatencyMS,
Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
}
if tcpCheck.State == diagStateFailed {
diagnostics.Error = tcpCheck.Detail
}
// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
// 不让列表页同步等待外部定位 API。
location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
diagnostics.Location = location
locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
if !resolved {
locationCheck.State = diagStateUnknown
locationCheck.Detail = "定位信息后台获取中"
go s.refreshLocation(server.ServerAddr)
}
diagnostics.Checks = append(diagnostics.Checks, locationCheck)
s.store.AddServerDiagnostic(ctx, diagnostics)
return diagnostics
}
// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
check := DiagnosticCheck{Name: "tcp_reachable"}
if dnsCheck.State == diagStateFailed {
check.State = diagStateFailed
check.Detail = "已跳过:" + dnsCheck.Detail
return check
}
dialer := net.Dialer{Timeout: diagTCPTimeout}
started := time.Now()
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
check.LatencyMS = time.Since(started).Milliseconds()
if check.LatencyMS < 1 {
check.LatencyMS = 1
}
if err != nil {
check.State = diagStateFailed
check.Detail = diagnoseDialError(err)
return check
}
_ = conn.Close()
check.State = diagStateOK
check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
return check
}
// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
check := DiagnosticCheck{Name: "dns_resolution"}
if ip := net.ParseIP(host); ip != nil {
check.State = diagStateOK
check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
return check
}
resolver := net.DefaultResolver
ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
defer cancel()
started := time.Now()
addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
elapsed := time.Since(started).Milliseconds()
if err != nil || len(addresses) == 0 {
check.State = diagStateFailed
check.Detail = "域名解析失败:" + diagnoseResolveError(err)
return check
}
var ipv4, ipv6 []string
for _, address := range addresses {
if address.IP.To4() != nil {
ipv4 = append(ipv4, address.IP.String())
} else {
ipv6 = append(ipv6, address.IP.String())
}
}
summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
if len(ipv6) > 0 {
summary += ";AAAA=" + strings.Join(ipv6, ", ")
} else {
summary += ";AAAA=无"
}
check.LatencyMS = elapsed
check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
return check
}
func diagnoseResolveError(err error) string {
if err == nil {
return "无解析结果"
}
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return "域名不存在"
}
return err.Error()
}
// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
check := DiagnosticCheck{Name: "frpc_login"}
data, err := os.ReadFile(s.serverLogPath(serverID))
if os.IsNotExist(err) {
check.State = diagStateUnknown
check.Detail = "frpc 尚未运行,无登录记录"
return check
}
if err != nil {
check.State = diagStateUnknown
check.Detail = "读取 frpc 日志失败"
return check
}
state, detail := summarizeFRPCLogin(string(data))
check.State = state
check.Detail = detail
return check
}
// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
func summarizeFRPCLogin(log string) (string, string) {
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
const window = 200
if len(lines) > window {
lines = lines[len(lines)-window:]
}
failureDetail := ""
for index := len(lines) - 1; index >= 0; index-- {
line := lines[index]
if prefix := strings.Index(line, "] "); prefix >= 0 {
line = line[prefix+2:]
}
if strings.Contains(line, "login to server success") {
return diagStateOK, "frpc 已登录 frps"
}
if failureDetail == "" && isLoginFailureLine(line) {
failureDetail = line
}
}
if failureDetail != "" {
if len(failureDetail) > 160 {
failureDetail = failureDetail[:160] + "…"
}
return diagStateFailed, "frpc 登录失败:" + failureDetail
}
return diagStateUnknown, "日志中暂无登录结果记录"
}
func isLoginFailureLine(line string) bool {
if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
return true
}
return strings.Contains(line, "token") && strings.Contains(line, "error")
}
// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
ip := resolvePublicIP(ctx, host)
if ip == "" {
// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
return strings.TrimSpace(host), true
}
s.locationMu.Lock()
cached, ok := s.locations[ip]
s.locationMu.Unlock()
if ok && time.Now().Before(cached.expiresAt) {
return cached.value, true
}
return ip, false
}
// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
func (s *Service) refreshLocation(host string) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = s.serverLocation(ctx, host)
}
+216
View File
@@ -0,0 +1,216 @@
package frp
import (
"context"
"fmt"
"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("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;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;保存后在 Fonu 重新生成本文件以保持两端一致\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 暴露穿透流量与连接信息,建议用防火墙限制仅 Fonu 所在机器访问该端口\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")
}
// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return "", err
}
return s.buildFRPSServerConfig(ctx, server)
}
+99
View File
@@ -0,0 +1,99 @@
package frp
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
)
const locationCacheTTL = 24 * time.Hour
type locationCache struct {
value string
expiresAt time.Time
}
type ipLocationResponse struct {
Success bool `json:"success"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
}
func (s *Service) serverLocation(ctx context.Context, host string) string {
ip := resolvePublicIP(ctx, host)
if ip == "" {
return strings.TrimSpace(host)
}
s.locationMu.Lock()
if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
s.locationMu.Unlock()
return cached.value
}
s.locationMu.Unlock()
value := lookupIPLocation(ctx, ip)
// 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
s.locationMu.Lock()
s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
s.locationMu.Unlock()
return value
}
func resolvePublicIP(ctx context.Context, host string) string {
if ip := net.ParseIP(host); isPublicIP(ip) {
return ip.String()
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return ""
}
for _, address := range addresses {
if isPublicIP(address.IP) {
return address.IP.String()
}
}
return ""
}
func isPublicIP(ip net.IP) bool {
return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}
func lookupIPLocation(ctx context.Context, ip string) string {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
if err != nil {
return "未定位"
}
client := &http.Client{Timeout: 4 * time.Second}
response, err := client.Do(request)
if err != nil {
return "未定位"
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "未定位"
}
var data ipLocationResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
return "未定位"
}
return formatLocation(data)
}
func formatLocation(data ipLocationResponse) string {
parts := []string{data.City, data.Region, data.Country}
values := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
values = append(values, part)
}
}
if len(values) == 0 {
return "未定位"
}
return strings.Join(values, " · ")
}
+22
View File
@@ -0,0 +1,22 @@
package frp
import (
"net"
"testing"
)
func TestFormatLocation(t *testing.T) {
value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
if value != "Hong Kong · China" {
t.Fatalf("位置格式错误:%s", value)
}
}
func TestIsPublicIP(t *testing.T) {
if !isPublicIP(net.ParseIP("38.95.74.87")) {
t.Fatal("公网 IP 应被识别")
}
if isPublicIP(net.ParseIP("192.168.1.1")) {
t.Fatal("私网 IP 不应被定位")
}
}
+300
View File
@@ -0,0 +1,300 @@
package frp
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type Manager struct {
bin string
configPath string
logPath string
logger *slog.Logger
mu sync.RWMutex
cmd *exec.Cmd
orphan *os.Process
orphanPID int
pid int
version string
started time.Time
reloaded time.Time
lastErr string
}
func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
}
func (m *Manager) Verify(ctx context.Context, configPath string) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) VerifyBinary(ctx context.Context) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.runningLocked() {
return nil
}
bin, err := m.binaryPathLocked()
if err != nil {
m.lastErr = err.Error()
return err
}
logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(bin, "-c", m.configPath)
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
m.lastErr = err.Error()
return fmt.Errorf("启动 frpc 失败:%w", err)
}
m.cmd = cmd
m.pid = cmd.Process.Pid
m.started = time.Now().UTC()
m.reloaded = m.started
m.lastErr = ""
go m.wait(cmd, logFile)
return nil
}
func (m *Manager) Restart(ctx context.Context) error {
if err := m.Stop(ctx); err != nil {
return err
}
return m.Start()
}
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
cmd := m.cmd
orphan := m.orphan
m.mu.Unlock()
if cmd == nil && orphan == nil {
return nil
}
process := orphan
if cmd != nil {
process = cmd.Process
}
if process == nil {
return nil
}
if err := process.Kill(); err != nil {
return fmt.Errorf("停止 frpc 失败:%w", err)
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
if !m.running() {
m.mu.Lock()
m.lastErr = ""
m.orphan = nil
m.orphanPID = 0
m.mu.Unlock()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("等待 frpc 停止超时")
case <-time.After(50 * time.Millisecond):
}
}
}
func (m *Manager) Status() RuntimeStatus {
m.mu.RLock()
defer m.mu.RUnlock()
return m.statusLocked()
}
func (m *Manager) statusLocked() RuntimeStatus {
status := RuntimeStatus{
Status: "stopped",
PID: m.pid,
Version: m.version,
LastError: m.lastErr,
}
if m.runningLocked() {
status.Status = "running"
if status.PID == 0 {
status.PID = m.orphanPID
}
} else if m.orphan != nil {
status.PID = 0
}
if !m.started.IsZero() {
status.LastStartedAt = m.started.Format(time.RFC3339)
}
if !m.reloaded.IsZero() {
status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
}
return status
}
func (m *Manager) RefreshVersion(ctx context.Context) {
bin, err := m.BinaryPath()
if err != nil {
return
}
output, err := exec.CommandContext(ctx, bin, "--version").Output()
if err != nil {
return
}
m.mu.Lock()
m.version = strings.TrimSpace(string(output))
m.mu.Unlock()
}
func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
logFile.Close()
m.mu.Lock()
if m.cmd != cmd {
m.mu.Unlock()
return
}
m.cmd = nil
m.pid = 0
if err != nil {
// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
}
m.mu.Unlock()
}
// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
func tailFile(path string, maxLines int) string {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return strings.Join(lines, " | ")
}
func (m *Manager) running() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.runningLocked()
}
func (m *Manager) runningLocked() bool {
if m.cmd != nil && m.cmd.Process != nil {
return true
}
return m.orphan != nil && processAlive(m.orphanPID)
}
func (m *Manager) Attach(pid int) bool {
bin, err := m.BinaryPath()
if err != nil || !processMatches(pid, bin, m.configPath) {
return false
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
m.mu.Lock()
m.orphan = process
m.orphanPID = pid
m.pid = pid
m.lastErr = ""
m.mu.Unlock()
return true
}
func processAlive(pid int) bool {
alive, _ := probeProcess(pid)
return alive
}
func processMatches(pid int, binary, configPath string) bool {
alive, commandLine := probeProcess(pid)
if !alive {
return false
}
commandLine = strings.ToLower(filepath.ToSlash(commandLine))
return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
}
func (m *Manager) BinaryPath() (string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.binaryPathLocked()
}
func (m *Manager) binaryPathLocked() (string, error) {
if strings.ContainsAny(m.bin, `/\\`) {
if _, err := os.Stat(m.bin); err != nil {
return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
}
return m.bin, nil
}
path, err := exec.LookPath(m.bin)
if err != nil {
return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_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
}
+345
View File
@@ -0,0 +1,345 @@
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"`
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),仅用于 Fonu 拉取穿透流量等只读信息,不写入 frpc.toml
DashboardAddr string `json:"dashboard_addr,omitempty"`
DashboardPort int `json:"dashboard_port,omitempty"`
DashboardUser string `json:"dashboard_user,omitempty"`
DashboardHasPwd bool `json:"dashboard_has_pwd"`
LogLevel string `json:"log_level"`
LogMaxDays int `json:"log_max_days"`
Protocol string `json:"protocol"`
ProxyURL string `json:"proxy_url,omitempty"`
DialServerTimeout int `json:"dial_server_timeout"`
DialServerKeepalive int `json:"dial_server_keepalive"`
ConnectServerLocalIP string `json:"connect_server_local_ip,omitempty"`
DNSServer string `json:"dns_server,omitempty"`
PoolCount int `json:"pool_count"`
TCPMux bool `json:"tcp_mux"`
TCPMuxKeepaliveInterval int `json:"tcp_mux_keepalive_interval"`
HeartbeatInterval int `json:"heartbeat_interval"`
HeartbeatTimeout int `json:"heartbeat_timeout"`
LoginFailExit bool `json:"login_fail_exit"`
TLSDisableCustomFirstByte bool `json:"tls_disable_custom_first_byte"`
AutoStart bool `json:"auto_start"`
Remark string `json:"remark,omitempty"`
TLSCertificateConfigured bool `json:"tls_certificate_configured"`
TLSKeyConfigured bool `json:"tls_key_configured"`
TLSTrustedCAConfigured bool `json:"tls_trusted_ca_configured"`
}
type Proxy struct {
ID int64 `json:"id"`
ServerID int64 `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
LocalIP string `json:"local_ip"`
LocalPort int `json:"local_port"`
RemotePort *int `json:"remote_port,omitempty"`
CustomDomains []string `json:"custom_domains"`
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
Options ProxyOptions `json:"options"`
Enabled bool `json:"enabled"`
Remark string `json:"remark,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ServerInput struct {
AgentURL string
AgentToken string
ClearAgentToken bool
SSHHost string
SSHPort int
SSHUser string
SSHAuth string
SSHSecret string
ClearSSHSecret bool
MgmtEnabled bool
Name string
ServerAddr string
ServerPort int
TLS bool
TLSServerName string
Enabled bool
Token string
ClearToken bool
Options ServerOptions
OIDCClientSecret string
ClearOIDCClientSecret bool
DashboardPassword string
ClearDashboardPassword bool
TLSCertificate string
TLSKey string
TLSTrustedCA string
ClearTLSCertificate bool
ClearTLSKey bool
ClearTLSTrustedCA bool
}
type ProxyInput struct {
ServerID int64
Name string
Type string
LocalIP string
LocalPort int
RemotePort *int
CustomDomains []string
HostHeaderRewrite string
Options ProxyOptions
Enabled bool
Remark string
}
// ProxyOptions 对应 FRP [[proxies]] 中除基础地址外的扩展配置。
type ProxyOptions struct {
Subdomain string `json:"subdomain,omitempty"`
// WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头),不写入 frpc.toml
WebSocket bool `json:"websocket,omitempty"`
// 公网反代进阶选项(不写入 frpc.toml):强制跳转 HTTPS / IP 白名单 / Basic Auth 用户名(密码由 agent 生成)
RedirectHTTPS bool `json:"redirect_https,omitempty"`
AllowIPs []string `json:"allow_ips,omitempty"`
BasicAuthUser string `json:"basic_auth_user,omitempty"`
// 公网反代第一梯队部署设置(同属公网 Nginx vhost 选项,不写入 frpc.toml)
DenyIPs []string `json:"deny_ips,omitempty"` // IP 黑名单
BasicAuthPassword string `json:"basic_auth_password,omitempty"` // 自定义 Basic Auth 密码;空则首次自动生成
HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS
ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小,如 50m
ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头
TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
// 第二梯队部署设置(同为公网 Nginx vhost 选项)
RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP
Locations []string `json:"locations,omitempty"`
RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
HTTPUser string `json:"http_user,omitempty"`
HTTPPassword string `json:"http_password,omitempty"`
Multiplexer string `json:"multiplexer,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
AllowUsers []string `json:"allow_users,omitempty"`
Transport ProxyTransportOptions `json:"transport"`
HealthCheck ProxyHealthCheckOptions `json:"health_check"`
LoadBalancer ProxyLoadBalancer `json:"load_balancer"`
Metadatas map[string]string `json:"metadatas,omitempty"`
RequestHeaders map[string]string `json:"request_headers,omitempty"`
ResponseHeaders map[string]string `json:"response_headers,omitempty"`
Plugin *ProxyPluginOptions `json:"plugin,omitempty"`
NATTraversal *ProxyNATTraversal `json:"nat_traversal,omitempty"`
}
type ProxyTransportOptions struct {
BandwidthLimit string `json:"bandwidth_limit,omitempty"`
BandwidthLimitMode string `json:"bandwidth_limit_mode,omitempty"`
UseEncryption bool `json:"use_encryption"`
UseCompression bool `json:"use_compression"`
ProxyProtocolVersion string `json:"proxy_protocol_version,omitempty"`
}
type ProxyHealthCheckOptions struct {
Type string `json:"type,omitempty"`
Path string `json:"path,omitempty"`
IntervalSeconds int `json:"interval_seconds,omitempty"`
MaxFailed int `json:"max_failed,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
HTTPHeaders []ProxyHeader `json:"http_headers,omitempty"`
}
type ProxyHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type ProxyLoadBalancer struct {
Group string `json:"group,omitempty"`
GroupKey string `json:"group_key,omitempty"`
}
type ProxyPluginOptions struct {
Type string `json:"type"`
UnixPath string `json:"unix_path,omitempty"`
LocalPath string `json:"local_path,omitempty"`
StripPrefix string `json:"strip_prefix,omitempty"`
LocalAddr string `json:"local_addr,omitempty"`
HTTPUser string `json:"http_user,omitempty"`
HTTPPassword string `json:"http_password,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
CRTPath string `json:"crt_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
DestinationIP string `json:"destination_ip,omitempty"`
}
type ProxyNATTraversal struct {
DisableAssistedAddrs bool `json:"disable_assisted_addrs"`
}
type RuntimeStatus struct {
Status string `json:"status"`
PID int `json:"pid,omitempty"`
Version string `json:"frpc_version,omitempty"`
LastStartedAt string `json:"last_started_at,omitempty"`
LastReloadedAt string `json:"last_reloaded_at,omitempty"`
LastError string `json:"last_error,omitempty"`
Configured bool `json:"configured"`
EnabledProxies int `json:"enabled_proxies"`
}
+104
View File
@@ -0,0 +1,104 @@
package frp
import (
"context"
"fmt"
"path/filepath"
"strings"
)
func (s *Service) managerForServer(serverID int64) *Manager {
s.managersMu.Lock()
defer s.managersMu.Unlock()
if manager, ok := s.managers[serverID]; ok {
return manager
}
bin, err := s.manager.BinaryPath()
if err != nil {
bin = s.cfg.FrpcBin
}
manager := NewManager(
bin,
s.serverConfigPath(serverID),
s.serverLogPath(serverID),
s.logger,
)
s.managers[serverID] = manager
return manager
}
func (s *Service) serverConfigPath(serverID int64) string {
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
}
func (s *Service) serverLogPath(serverID int64) string {
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
}
func (s *Service) managerSnapshot() map[int64]*Manager {
s.managersMu.RLock()
defer s.managersMu.RUnlock()
result := make(map[int64]*Manager, len(s.managers))
for id, manager := range s.managers {
result[id] = manager
}
return result
}
func (s *Service) setManagerBinary(path string) {
s.manager.SetBinary(path)
for _, manager := range s.managerSnapshot() {
manager.SetBinary(path)
}
}
func (s *Service) removeManager(serverID int64) {
s.managersMu.Lock()
delete(s.managers, serverID)
s.managersMu.Unlock()
}
func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
result := RuntimeStatus{Status: "stopped"}
for _, status := range statuses {
if status.Status == "running" {
result.Status = "running"
}
if status.Configured {
result.Configured = true
}
result.EnabledProxies += status.EnabledProxies
if result.PID == 0 && status.PID != 0 {
result.PID = status.PID
}
if status.LastStartedAt > result.LastStartedAt {
result.LastStartedAt = status.LastStartedAt
}
if status.LastReloadedAt > result.LastReloadedAt {
result.LastReloadedAt = status.LastReloadedAt
}
if result.LastError == "" && status.LastError != "" {
result.LastError = status.LastError
}
if status.Version != "" && result.Version == "" {
result.Version = status.Version
}
}
if len(statuses) == 0 {
result.Status = "unknown"
}
return result
}
func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
var messages []string
for id, manager := range s.managerSnapshot() {
if err := action(manager); err != nil {
messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
}
}
if len(messages) > 0 {
return fmt.Errorf("%s", strings.Join(messages, ";"))
}
return nil
}
+16
View File
@@ -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()
}
+25
View File
@@ -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))
}
+22
View File
@@ -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))
}
+539
View File
@@ -0,0 +1,539 @@
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)
}
func fetchChecksum(ctx context.Context, release Release) (string, error) {
version := strings.TrimPrefix(release.Version, "v")
name := "frp_" + version + "_sha256sums.txt"
url := officialAssetURL(release.Version, name)
data, err := fetchBytes(ctx, url, 1<<20)
if err != nil {
return "", fmt.Errorf("下载 FRP 官方校验文件失败:%w", err)
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == release.AssetName {
return fields[0], nil
}
}
return "", fmt.Errorf("FRP 官方校验文件中未找到 %s", release.AssetName)
}
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", "Fonu-FRP-Downloader")
if strings.HasPrefix(url, "https://api.github.com/repos/fatedier/frp/releases/assets/") {
request.Header.Set("Accept", "application/octet-stream")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
}
client := &http.Client{Timeout: 5 * time.Minute}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", response.StatusCode)
}
return readResponseBody(response.Body, response.ContentLength, limit, progress)
}
func readResponseBody(reader io.Reader, total, limit int64, progress func(int64, int64)) ([]byte, error) {
var output bytes.Buffer
buffer := make([]byte, 64*1024)
var downloaded int64
for {
count, err := reader.Read(buffer)
if count > 0 {
downloaded += int64(count)
if downloaded > limit {
return nil, fmt.Errorf("下载内容超过大小限制")
}
_, _ = output.Write(buffer[:count])
if progress != nil {
progress(downloaded, total)
}
}
if err == io.EOF {
return output.Bytes(), nil
}
if err != nil {
return nil, err
}
}
}
func officialAssetURL(version, name string) string {
return "https://github.com/fatedier/frp/releases/download/" + version + "/" + name
}
func releaseDownloadURL(release Release, proxy string) string {
if proxy != "" {
return proxy + officialAssetURL(release.Version, release.AssetName)
}
if release.AssetID > 0 {
return fmt.Sprintf("https://api.github.com/repos/fatedier/frp/releases/assets/%d", release.AssetID)
}
return officialAssetURL(release.Version, release.AssetName)
}
func downloadProxyPath(frpDir string) string {
return filepath.Join(frpDir, "download-proxy.json")
}
func readDownloadProxy(frpDir string) (string, error) {
data, err := os.ReadFile(downloadProxyPath(frpDir))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
var state struct {
Proxy string `json:"proxy"`
}
if err := json.Unmarshal(data, &state); err != nil {
return "", fmt.Errorf("FRP 下载代理配置无效")
}
if !validDownloadProxy(state.Proxy) {
return "", fmt.Errorf("FRP 下载代理配置无效")
}
return state.Proxy, nil
}
func writeDownloadProxy(frpDir, proxy string) error {
if !validDownloadProxy(proxy) {
return fmt.Errorf("不支持的 FRP 下载加速代理")
}
if err := os.MkdirAll(frpDir, 0o700); err != nil {
return err
}
data, err := json.Marshal(struct {
Proxy string `json:"proxy"`
}{Proxy: proxy})
if err != nil {
return err
}
return os.WriteFile(downloadProxyPath(frpDir), data, 0o600)
}
func validDownloadProxy(proxy string) bool {
_, ok := downloadProxyOptions[proxy]
return ok
}
func extractClientBinary(archivePath, frpDir string, release Release) (string, error) {
data, err := os.ReadFile(archivePath)
if err != nil {
return "", err
}
binary, err := extractBinary(data, release.AssetName)
if err != nil {
return "", err
}
path := filepath.Join(frpDir, "bin", release.Version, clientBinaryName())
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(path, binary, 0o700); err != nil {
return "", err
}
return path, nil
}
func extractBinary(data []byte, assetName string) ([]byte, error) {
if strings.HasSuffix(assetName, ".zip") {
return extractZipBinary(data)
}
return extractTarBinary(data)
}
func extractZipBinary(data []byte) ([]byte, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("FRP ZIP 安装包无效:%w", err)
}
for _, file := range reader.File {
if filepath.Base(file.Name) != clientBinaryName() || file.FileInfo().IsDir() {
continue
}
return readZipFile(file)
}
return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
}
func readZipFile(file *zip.File) ([]byte, error) {
if file.UncompressedSize64 > maxArchiveSize {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
reader, err := file.Open()
if err != nil {
return nil, err
}
defer reader.Close()
return boundedRead(reader, maxArchiveSize)
}
func extractTarBinary(data []byte) ([]byte, error) {
gzipReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("FRP TAR.GZ 安装包无效:%w", err)
}
defer gzipReader.Close()
reader := tar.NewReader(gzipReader)
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != clientBinaryName() {
continue
}
if header.Size > maxArchiveSize {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
return boundedRead(reader, maxArchiveSize)
}
return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
}
func boundedRead(reader io.Reader, limit int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
}
return data, nil
}
func clientBinaryName() string {
if runtime.GOOS == "windows" {
return "frpc.exe"
}
return "frpc"
}
func listBinaries(frpDir, active string) ([]Binary, error) {
root := filepath.Join(frpDir, "bin")
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return []Binary{}, nil
}
if err != nil {
return nil, err
}
binaries := make([]Binary, 0, len(entries))
for _, entry := range entries {
binary, ok := binaryFromEntry(root, entry, active)
if ok {
binaries = append(binaries, binary)
}
}
sort.Slice(binaries, func(i, j int) bool { return binaries[i].Version > binaries[j].Version })
return binaries, nil
}
func binaryFromEntry(root string, entry os.DirEntry, active string) (Binary, bool) {
if !entry.IsDir() || !validVersion(entry.Name()) {
return Binary{}, false
}
path := filepath.Join(root, entry.Name(), clientBinaryName())
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return Binary{}, false
}
return Binary{Version: entry.Name(), Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339), Active: filepath.Clean(path) == filepath.Clean(active)}, true
}
func binaryPathForVersion(frpDir, version string) (string, error) {
if !validVersion(version) {
return "", fmt.Errorf("FRP 版本格式无效")
}
path := filepath.Join(frpDir, "bin", version, clientBinaryName())
if !fileExists(path) {
return "", fmt.Errorf("FRP %s 尚未下载", version)
}
return path, nil
}
func removeBinaryVersion(frpDir, version, binaryPath string) error {
root, err := filepath.Abs(filepath.Join(frpDir, "bin"))
if err != nil {
return err
}
target, err := filepath.Abs(filepath.Dir(binaryPath))
if err != nil {
return err
}
relative, err := filepath.Rel(root, target)
if err != nil || filepath.Clean(relative) != version {
return fmt.Errorf("FRP 客户端目录无效")
}
return os.RemoveAll(target)
}
func validVersion(version string) bool {
if version == "" || len(version) > 32 || strings.ContainsAny(version, `/\\`) {
return false
}
for _, char := range version {
if (char < '0' || char > '9') && char != '.' && char != 'v' && char != '-' {
return false
}
}
return true
}
func activeBinaryPath(frpDir string) string {
return filepath.Join(frpDir, "bin", "active.json")
}
func readActiveBinary(frpDir string) (string, error) {
data, err := os.ReadFile(activeBinaryPath(frpDir))
if err != nil {
return "", err
}
var state struct {
Path string `json:"path"`
}
if err := json.Unmarshal(data, &state); err != nil || versionFromBinaryPath(frpDir, state.Path) == "" || !fileExists(state.Path) {
return "", fmt.Errorf("已启用的 FRP 客户端无效")
}
return state.Path, nil
}
func writeActiveBinary(frpDir, path string) error {
if err := os.MkdirAll(filepath.Dir(activeBinaryPath(frpDir)), 0o700); err != nil {
return err
}
data, err := json.Marshal(struct {
Path string `json:"path"`
}{Path: path})
if err != nil {
return err
}
return os.WriteFile(activeBinaryPath(frpDir), data, 0o600)
}
func versionFromBinaryPath(frpDir, path string) string {
root := filepath.Join(frpDir, "bin")
relative, err := filepath.Rel(root, path)
if err != nil || strings.HasPrefix(relative, "..") {
return ""
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) != 2 || !validVersion(parts[0]) {
return ""
}
return parts[0]
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+88
View File
@@ -0,0 +1,88 @@
package frp
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestSupportedReleasesUsesOfficialDigest(t *testing.T) {
version := "v0.99.0"
extension := ".tar.gz"
if runtime.GOOS == "windows" {
extension = ".zip"
}
assetName := "frp_0.99.0_" + runtime.GOOS + "_" + runtime.GOARCH + extension
releases := supportedReleases([]githubRelease{{
TagName: version,
Assets: []githubAsset{{ID: 123, Name: assetName, Size: 42, Digest: "sha256:abc123"}},
PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC),
}})
if len(releases) != 1 || releases[0].Checksum != "abc123" || releases[0].AssetID != 123 {
t.Fatalf("未读取官方资产摘要:%+v", releases)
}
}
func TestExtractZipBinary(t *testing.T) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
file, err := writer.Create("frp/" + clientBinaryName())
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte("client")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
data, err := extractZipBinary(buffer.Bytes())
if err != nil {
t.Fatal(err)
}
if string(data) != "client" {
t.Fatalf("提取内容错误:%q", data)
}
}
func TestValidDownloadProxy(t *testing.T) {
for _, proxy := range []string{"", "https://gh-proxy.org/", "https://v4.gh-proxy.org/", "https://cdn.gh-proxy.org/"} {
if !validDownloadProxy(proxy) {
t.Fatalf("合法下载代理被拒绝:%s", proxy)
}
}
if validDownloadProxy("https://untrusted.example/") {
t.Fatal("非白名单下载代理不应被接受")
}
}
func TestReleaseListURLsPrioritizesSelectedProxy(t *testing.T) {
urls := releaseListURLs("https://v4.gh-proxy.org/")
if urls[0] != "https://v4.gh-proxy.org/"+githubReleasesURL {
t.Fatalf("未优先使用已选代理:%v", urls)
}
if urls[1] != githubReleasesURL {
t.Fatalf("直连地址缺失:%v", urls)
}
}
func TestRemoveBinaryVersion(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "bin", "v0.71.0", clientBinaryName())
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("client"), 0o700); err != nil {
t.Fatal(err)
}
if err := removeBinaryVersion(root, "v0.71.0", path); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
t.Fatalf("版本目录未删除:%v", err)
}
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -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)
}
}
+126
View File
@@ -0,0 +1,126 @@
package frp
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
type SSHInstaller struct {
Host string
Port int
User string
Password string
PrivateKey string
Timeout time.Duration
}
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 密码或私钥不能为空")
}
config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), 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/fonu-agent", binary, 0o755); err != nil {
return "", err
}
if err := uploadBytes(client, "/tmp/fonu-agent-install.sh", script, 0o700); err != nil {
return "", err
}
return runSSHSession(client, "sudo /tmp/fonu-agent-install.sh /tmp/fonu-agent && test -x /usr/local/bin/fonu-agent && /usr/local/bin/fonu-agent -version && rm -f /tmp/fonu-agent /tmp/fonu-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
+496
View File
@@ -0,0 +1,496 @@
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) 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(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, &configJSON, &created, &updated)
if err != nil {
return Server{}, err
}
server.TLS = tls == 1
server.Enabled = enabled == 1
server.HasToken = token == 1
server.HasOIDCSecret = oidcSecret == 1
server.DashboardHasPwd = dashboardPwd == 1
server.AgentConfigured = agentConfigured == 1
server.SSHConfigured = sshConfigured == 1
server.MgmtEnabled = mgmtEnabled == 1
_ = json.Unmarshal([]byte(configJSON), &server.Options)
server.Options = normalizeServerOptions(server.Options)
server.CreatedAt = parseTime(created)
server.UpdatedAt = parseTime(updated)
return server, nil
}
func scanProxy(row interface{ Scan(dest ...any) error }) (Proxy, error) {
var proxy Proxy
var domains, configJSON, created, updated string
var enabled int
if err := row.Scan(&proxy.ID, &proxy.ServerID, &proxy.Name, &proxy.Type, &proxy.LocalIP, &proxy.LocalPort, &proxy.RemotePort, &domains, &proxy.HostHeaderRewrite, &configJSON, &enabled, &proxy.Remark, &created, &updated); err != nil {
return Proxy{}, err
}
_ = json.Unmarshal([]byte(domains), &proxy.CustomDomains)
_ = json.Unmarshal([]byte(configJSON), &proxy.Options)
proxy.Enabled = enabled == 1
proxy.CreatedAt = parseTime(created)
proxy.UpdatedAt = parseTime(updated)
return proxy, nil
}
func mapUniqueError(err error, message string) error {
if strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf(message)
}
return err
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func parseTime(value string) time.Time {
parsed, err := time.Parse("2006-01-02 15:04:05", value)
if err != nil {
parsed, _ = time.Parse(time.RFC3339, value)
}
return parsed.UTC()
}
+226
View File
@@ -0,0 +1,226 @@
package frp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
type frpsProxyStatus struct {
Name string `json:"name"`
Type string `json:"type"`
TodayTrafficIn int64 `json:"today_traffic_in"`
TodayTrafficOut int64 `json:"today_traffic_out"`
CurrentConns int `json:"cur_conns"`
}
type ProxyTraffic struct {
TrafficIn int64 `json:"traffic_in"`
TrafficOut int64 `json:"traffic_out"`
CurConns int `json:"cur_conns"`
TrafficInRate int64 `json:"traffic_in_rate"`
TrafficOutRate int64 `json:"traffic_out_rate"`
}
type ServerTraffic struct {
ServerID int64 `json:"server_id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Proxies map[string]ProxyTraffic `json:"proxies"`
ProxyNamesByDomain map[string]string `json:"proxy_names_by_domain,omitempty"`
}
type ProxiesTraffic struct {
Items map[int64]ProxyTraffic `json:"items"`
Errors map[int64]string `json:"errors,omitempty"`
SampledAt string `json:"sampled_at"`
}
type trafficSample struct {
at time.Time
in int64
out int64
}
type frpsTrafficClient struct {
httpClient *http.Client
}
func newFRPSTrafficClient() *frpsTrafficClient {
return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
}
// fetchServerTraffic 版本自适应采集:
// - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
// - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
//
// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
empty := map[string]ProxyTraffic{}
if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
}
// 优先 V2 API(v0.71.0+)
v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
body, status, err := c.getJSON(ctx, v2URL, server, password)
if err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
}
if status == http.StatusOK {
return c.parseV2Proxies(server, body)
}
if status != http.StatusNotFound {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
}
// 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
// fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
}
// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
type legacyProxyInfo struct {
Name string `json:"name"`
User string `json:"user"`
Conf struct {
CustomDomains []string `json:"customDomains"`
} `json:"conf"`
Status string `json:"status"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
}
// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
body, status, err := c.getJSON(ctx, url, server, password)
if err != nil {
return legacyProxyInfo{}, 0, err
}
if status != http.StatusOK {
return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
}
var info legacyProxyInfo
if err := json.Unmarshal(body, &info); err != nil {
return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
}
return info, status, nil
}
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
empty := map[string]ProxyTraffic{}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
var lastErr error
for _, name := range names {
proxyName := name
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
proxyName = user + "." + proxyName
}
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
if err != nil {
lastErr = err
continue
}
traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
proxies[info.Name] = traffic
for _, domain := range info.Conf.CustomDomains {
if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = info.Name
}
}
}
if len(proxies) == 0 && lastErr != nil {
return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
req.SetBasicAuth(user, password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
// parseV2Proxies 解析 V2 API 响应(实测结构):
// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
empty := map[string]ProxyTraffic{}
var payload struct {
Data struct {
Items []struct {
Name string `json:"name"`
User string `json:"user"`
Spec struct {
Type string `json:"type"`
HTTP struct {
CustomDomains []string `json:"customDomains"`
} `json:"http"`
} `json:"spec"`
Status struct {
Phase string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
} `json:"status"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
for _, item := range payload.Data.Items {
traffic := ProxyTraffic{
TrafficIn: item.Status.TodayTrafficIn,
TrafficOut: item.Status.TodayTrafficOut,
CurConns: item.Status.CurConns,
}
proxies[item.Name] = traffic
// 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
for _, d := range item.Spec.HTTP.CustomDomains {
domain := strings.ToLower(strings.TrimSpace(d))
if domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = item.Name
}
}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
func validDashboardAddr(addr string) bool {
addr = strings.TrimSpace(addr)
if addr == "" {
return true
}
return validHost(addr)
}
+361
View File
@@ -0,0 +1,361 @@
package frp
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
)
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func validateServerInput(in ServerInput) error {
if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
return fmt.Errorf("服务端名称长度应为 1-64 个字符")
}
if !validHost(in.ServerAddr) {
return fmt.Errorf("FRP 服务端地址无效")
}
if !validPort(in.ServerPort) {
return fmt.Errorf("FRP 服务端端口无效")
}
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
return fmt.Errorf("frps 管理接口端口无效")
}
if !validDashboardAddr(in.Options.DashboardAddr) {
return fmt.Errorf("frps 管理接口地址无效")
}
if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
return fmt.Errorf("TLS 服务名无效")
}
if !validAuthMethod(in.Options.AuthMethod) {
return fmt.Errorf("不支持的认证方式")
}
if !validLogLevel(in.Options.LogLevel) {
return fmt.Errorf("日志级别无效")
}
if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
return fmt.Errorf("日志保留天数应为 0-3650")
}
if !validProtocol(in.Options.Protocol) {
return fmt.Errorf("传输协议无效")
}
if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
return fmt.Errorf("代理或 OIDC 地址无效")
}
if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
}
if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
return fmt.Errorf("传输参数不能小于 0")
}
if len([]rune(in.Options.Location)) > 120 {
return fmt.Errorf("服务端位置不能超过 120 个字符")
}
return nil
}
func validateProxyInput(in ProxyInput) error {
if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
}
if in.Options.Plugin == nil {
if !validHost(in.LocalIP) {
return fmt.Errorf("内网地址无效")
}
if !validPort(in.LocalPort) {
return fmt.Errorf("内网端口无效")
}
}
switch in.Type {
case "tcp", "udp":
if in.RemotePort == nil || !validPort(*in.RemotePort) {
return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
}
if len(in.CustomDomains) > 0 {
return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
}
case "http", "https":
if in.RemotePort != nil {
return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
case "tcpmux":
if in.Options.Multiplexer != "httpconnect" {
return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
if in.RemotePort != nil {
return fmt.Errorf("TCPMUX 规则不能设置远程端口")
}
case "stcp", "sudp", "xtcp":
if strings.TrimSpace(in.Options.SecretKey) == "" {
return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
}
if in.RemotePort != nil {
return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
}
default:
return fmt.Errorf("不支持的 FRP 规则类型")
}
if err := validateProxyOptions(in); err != nil {
return err
}
return nil
}
func validateProxyOptions(in ProxyInput) error {
opts := in.Options
if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
return fmt.Errorf("限速位置只能是 client 或 server")
}
if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
return fmt.Errorf("代理协议版本只能是 v1 或 v2")
}
if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
return fmt.Errorf("健康检查类型只能是 tcp 或 http")
}
if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
return fmt.Errorf("健康检查参数不能小于 0")
}
if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
return fmt.Errorf("负载均衡组设置后必须填写组密钥")
}
return validateProxyPlugin(opts.Plugin)
}
// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
func validateProxyPlugin(plugin *ProxyPluginOptions) error {
if plugin == nil {
return nil
}
pluginType := strings.TrimSpace(plugin.Type)
if pluginType == "" {
return fmt.Errorf("插件类型不能为空")
}
validAddr := func(addr string, label string) error {
addr = strings.TrimSpace(addr)
host, portText, ok := strings.Cut(addr, ":")
if !ok || !validHost(host) || !validPort(parseInt(portText)) {
return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
}
return nil
}
validSecretFile := func(path, label string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
}
return nil
}
switch pluginType {
case "unix_domain_socket":
if strings.TrimSpace(plugin.UnixPath) == "" {
return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
}
case "static_file":
if strings.TrimSpace(plugin.LocalPath) == "" {
return fmt.Errorf("插件 static_file 需要提供静态文件目录")
}
case "http_proxy", "socks5":
// 用户名/密码可选
case "http2https", "http2http":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
case "https2http", "https2https", "tls2raw":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
return err
}
if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
return err
}
default:
return fmt.Errorf("不支持的插件类型:%s", pluginType)
}
return nil
}
// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
func parseInt(value string) int {
value = strings.TrimSpace(value)
number := 0
if value == "" {
return 0
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return 0
}
number = number*10 + int(ch-'0')
}
return number
}
// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
func ValidateProxy(in ProxyInput) error {
return validateProxyInput(normalizeProxyInput(in))
}
func normalizeServerInput(in ServerInput) ServerInput {
in.Name = strings.TrimSpace(in.Name)
in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
in.Token = strings.TrimSpace(in.Token)
in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
in.Options = normalizeServerOptions(in.Options)
return in
}
func normalizeServerOptions(in ServerOptions) ServerOptions {
in.User = strings.TrimSpace(in.User)
in.Location = strings.TrimSpace(in.Location)
in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
in.OIDCScope = strings.TrimSpace(in.OIDCScope)
in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
in.ProxyURL = strings.TrimSpace(in.ProxyURL)
in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
in.DNSServer = strings.TrimSpace(in.DNSServer)
in.Remark = strings.TrimSpace(in.Remark)
if in.AuthMethod == "" {
in.AuthMethod = "token"
}
if in.LogLevel == "" {
in.LogLevel = "info"
}
if in.Protocol == "" {
in.Protocol = "tcp"
}
if in.LogMaxDays == 0 {
in.LogMaxDays = 3
}
if in.DialServerTimeout == 0 {
in.DialServerTimeout = 10
}
if in.DialServerKeepalive == 0 {
in.DialServerKeepalive = 7200
}
if in.TCPMuxKeepaliveInterval == 0 {
in.TCPMuxKeepaliveInterval = 60
}
if in.HeartbeatInterval == 0 {
in.HeartbeatInterval = 30
}
if in.HeartbeatTimeout == 0 {
in.HeartbeatTimeout = 90
}
return in
}
func validAuthMethod(value string) bool {
return value == "none" || value == "token" || value == "oidc"
}
func validLogLevel(value string) bool {
return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
}
func validProtocol(value string) bool {
return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
}
func validOptionalURL(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.Host != ""
}
func normalizeProxyInput(in ProxyInput) ProxyInput {
in.Name = strings.TrimSpace(in.Name)
in.Type = strings.ToLower(strings.TrimSpace(in.Type))
in.LocalIP = strings.TrimSpace(in.LocalIP)
in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
in.Remark = strings.TrimSpace(in.Remark)
in.Options = normalizeProxyOptions(in.Options)
seen := map[string]bool{}
domains := make([]string, 0, len(in.CustomDomains))
for _, domain := range in.CustomDomains {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain != "" && !seen[domain] {
seen[domain] = true
domains = append(domains, domain)
}
}
in.CustomDomains = domains
return in
}
func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
in.Subdomain = strings.TrimSpace(in.Subdomain)
in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
in.HTTPUser = strings.TrimSpace(in.HTTPUser)
in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
in.SecretKey = strings.TrimSpace(in.SecretKey)
for index, user := range in.AllowUsers {
in.AllowUsers[index] = strings.TrimSpace(user)
}
in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
return in
}
func validPort(port int) bool {
return port > 0 && port <= 65535
}
func validHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
return false
}
if net.ParseIP(host) != nil || host == "localhost" {
return true
}
return validDomain(host)
}
func validDomain(domain string) bool {
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
return false
}
for _, label := range strings.Split(domain, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, ch := range label {
if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
return false
}
}
}
return true
}
+43
View File
@@ -0,0 +1,43 @@
package frp
import "testing"
func TestValidateProxyInput(t *testing.T) {
port := 60022
cases := []struct {
name string
input ProxyInput
wantErr bool
}{
{name: "tcp", input: ProxyInput{Name: "nas_ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port}},
{name: "http", input: ProxyInput{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}}},
{name: "missing remote port", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
{name: "invalid name", input: ProxyInput{Name: "bad name", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"nas.example.com"}}, wantErr: true},
{name: "invalid domain", input: ProxyInput{Name: "nas", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"bad domain"}}, wantErr: true},
{name: "udp", input: ProxyInput{Name: "dns", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
{name: "stcp", input: ProxyInput{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "abc"}}},
{name: "tcpmux missing multiplexer", input: ProxyInput{Name: "mux", Type: "tcpmux", LocalIP: "127.0.0.1", LocalPort: 80}, wantErr: true},
{name: "invalid bandwidth mode", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Transport: ProxyTransportOptions{BandwidthLimitMode: "wrong"}}}, wantErr: true},
{name: "udp remote port", input: ProxyInput{Name: "dns2", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
{name: "xtcp missing secret key", input: ProxyInput{Name: "p2p", Type: "xtcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
{name: "plugin http2https missing local addr", input: ProxyInput{Name: "p1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", CRTPath: "cert.pem", KeyPath: "key.pem"}}}, wantErr: true},
{name: "plugin https2http missing cert", input: ProxyInput{Name: "p2", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", LocalAddr: "127.0.0.1:80"}}}, wantErr: true},
{name: "plugin socks5 optional auth", input: ProxyInput{Name: "p3", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "socks5"}}}},
{name: "plugin unix_domain_socket missing path", input: ProxyInput{Name: "p4", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "unix_domain_socket"}}}, wantErr: true},
{name: "plugin static_file missing local path", input: ProxyInput{Name: "p5", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "static_file"}}}, wantErr: true},
{name: "plugin unknown type", input: ProxyInput{Name: "p6", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "whatever"}}}, wantErr: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
err := validateProxyInput(testCase.input)
if (err != nil) != testCase.wantErr {
t.Fatalf("validateProxyInput() error = %v, wantErr %v", err, testCase.wantErr)
}
// 对外导出的校验入口应与内部入口行为一致(关系表模式校验复用此函数)
if (ValidateProxy(testCase.input) != nil) != testCase.wantErr {
t.Fatalf("ValidateProxy() error = %v, wantErr %v", ValidateProxy(testCase.input), testCase.wantErr)
}
})
}
}