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:
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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.
@@ -0,0 +1,247 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type renderedConfig struct {
|
||||
Server Server
|
||||
Token string
|
||||
OIDCClientSecret string
|
||||
LogPath string
|
||||
TLSCertPath string
|
||||
TLSKeyPath string
|
||||
TLSTrustedCAPath string
|
||||
Proxies []Proxy
|
||||
}
|
||||
|
||||
func renderTOML(cfg renderedConfig) string {
|
||||
var out strings.Builder
|
||||
writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
|
||||
writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
|
||||
options := normalizeServerOptions(cfg.Server.Options)
|
||||
if options.User != "" {
|
||||
writeKeyValue(&out, "user", options.User)
|
||||
}
|
||||
if options.AuthMethod != "none" {
|
||||
writeKeyValue(&out, "auth.method", options.AuthMethod)
|
||||
}
|
||||
if options.AuthMethod == "token" {
|
||||
writeKeyValue(&out, "auth.token", cfg.Token)
|
||||
}
|
||||
if options.AuthMethod == "oidc" {
|
||||
writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
|
||||
writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
|
||||
writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
|
||||
if options.OIDCAudience != "" {
|
||||
writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
|
||||
}
|
||||
if options.OIDCScope != "" {
|
||||
writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
|
||||
}
|
||||
}
|
||||
if cfg.LogPath != "" {
|
||||
writeKeyValue(&out, "log.to", cfg.LogPath)
|
||||
}
|
||||
writeKeyValue(&out, "log.level", options.LogLevel)
|
||||
writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
|
||||
writeKeyValue(&out, "transport.protocol", options.Protocol)
|
||||
writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
|
||||
writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
|
||||
if cfg.Server.TLSServerName != "" {
|
||||
writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
|
||||
}
|
||||
if cfg.TLSCertPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
|
||||
}
|
||||
if cfg.TLSKeyPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
|
||||
}
|
||||
if cfg.TLSTrustedCAPath != "" {
|
||||
writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
|
||||
}
|
||||
if options.ProxyURL != "" {
|
||||
writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
|
||||
}
|
||||
writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
|
||||
writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
|
||||
if options.ConnectServerLocalIP != "" {
|
||||
writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
|
||||
}
|
||||
if options.DNSServer != "" {
|
||||
writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
|
||||
}
|
||||
writeKeyValue(&out, "transport.poolCount", options.PoolCount)
|
||||
writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
|
||||
writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
|
||||
writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
|
||||
writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
|
||||
writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
|
||||
|
||||
for _, proxy := range cfg.Proxies {
|
||||
out.WriteString("\n[[proxies]]\n")
|
||||
writeKeyValue(&out, "name", proxy.Name)
|
||||
writeKeyValue(&out, "type", proxy.Type)
|
||||
writeKeyValue(&out, "localIP", proxy.LocalIP)
|
||||
writeKeyValue(&out, "localPort", proxy.LocalPort)
|
||||
if proxy.Type == "tcp" && proxy.RemotePort != nil {
|
||||
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
|
||||
}
|
||||
if proxy.Type == "udp" && proxy.RemotePort != nil {
|
||||
writeKeyValue(&out, "remotePort", *proxy.RemotePort)
|
||||
}
|
||||
if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
|
||||
writeStringSlice(&out, "customDomains", proxy.CustomDomains)
|
||||
}
|
||||
if proxy.HostHeaderRewrite != "" {
|
||||
writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
|
||||
}
|
||||
writeProxyOptions(&out, proxy)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func writeProxyOptions(out *strings.Builder, proxy Proxy) {
|
||||
opts := proxy.Options
|
||||
if opts.Subdomain != "" {
|
||||
writeKeyValue(out, "subdomain", opts.Subdomain)
|
||||
}
|
||||
if len(opts.Locations) > 0 {
|
||||
writeStringSlice(out, "locations", opts.Locations)
|
||||
}
|
||||
if opts.RouteByHTTPUser != "" {
|
||||
writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
|
||||
}
|
||||
if opts.HTTPUser != "" {
|
||||
writeKeyValue(out, "httpUser", opts.HTTPUser)
|
||||
}
|
||||
if opts.HTTPPassword != "" {
|
||||
writeKeyValue(out, "httpPassword", opts.HTTPPassword)
|
||||
}
|
||||
if opts.Multiplexer != "" {
|
||||
writeKeyValue(out, "multiplexer", opts.Multiplexer)
|
||||
}
|
||||
if opts.SecretKey != "" {
|
||||
writeKeyValue(out, "secretKey", opts.SecretKey)
|
||||
}
|
||||
if len(opts.AllowUsers) > 0 {
|
||||
writeStringSlice(out, "allowUsers", opts.AllowUsers)
|
||||
}
|
||||
writeProxyTransport(out, opts.Transport)
|
||||
writeProxyHealthCheck(out, opts.HealthCheck)
|
||||
if opts.LoadBalancer.Group != "" {
|
||||
writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
|
||||
}
|
||||
if opts.LoadBalancer.GroupKey != "" {
|
||||
writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
|
||||
}
|
||||
writeStringMap(out, "metadatas", opts.Metadatas)
|
||||
writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
|
||||
writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
|
||||
// natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
|
||||
// disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
|
||||
if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
|
||||
writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
|
||||
}
|
||||
if opts.Plugin != nil {
|
||||
writeProxyPlugin(out, *opts.Plugin)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
|
||||
if opts.BandwidthLimit != "" {
|
||||
writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
|
||||
}
|
||||
if opts.BandwidthLimitMode != "" {
|
||||
writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
|
||||
}
|
||||
if opts.UseEncryption {
|
||||
writeKeyValue(out, "transport.useEncryption", true)
|
||||
}
|
||||
if opts.UseCompression {
|
||||
writeKeyValue(out, "transport.useCompression", true)
|
||||
}
|
||||
if opts.ProxyProtocolVersion != "" {
|
||||
writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
|
||||
if opts.Type == "" {
|
||||
return
|
||||
}
|
||||
writeKeyValue(out, "healthCheck.type", opts.Type)
|
||||
if opts.Path != "" {
|
||||
writeKeyValue(out, "healthCheck.path", opts.Path)
|
||||
}
|
||||
if opts.IntervalSeconds > 0 {
|
||||
writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
|
||||
}
|
||||
if opts.MaxFailed > 0 {
|
||||
writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
|
||||
}
|
||||
if opts.TimeoutSeconds > 0 {
|
||||
writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
|
||||
}
|
||||
if len(opts.HTTPHeaders) > 0 {
|
||||
values := make([]string, 0, len(opts.HTTPHeaders))
|
||||
for _, header := range opts.HTTPHeaders {
|
||||
values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
|
||||
}
|
||||
fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
|
||||
out.WriteString("[proxies.plugin]\n")
|
||||
writeKeyValue(out, "type", plugin.Type)
|
||||
for _, item := range []struct{ key, value string }{
|
||||
{"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
|
||||
{"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
|
||||
{"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
|
||||
{"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
|
||||
} {
|
||||
if item.value != "" {
|
||||
writeKeyValue(out, item.key, item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
writeKeyValue(out, prefix+"."+key, values[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderMaskedTOML(server Server, proxies []Proxy) string {
|
||||
return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
|
||||
}
|
||||
|
||||
func writeKeyValue(out *strings.Builder, key string, value any) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
|
||||
case int:
|
||||
fmt.Fprintf(out, "%s = %d\n", key, v)
|
||||
case bool:
|
||||
fmt.Fprintf(out, "%s = %t\n", key, v)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStringSlice(out *strings.Builder, key string, values []string) {
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, strconv.Quote(value))
|
||||
}
|
||||
fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTOML(t *testing.T) {
|
||||
remotePort := 60022
|
||||
content := renderTOML(renderedConfig{
|
||||
Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
|
||||
Token: "token with \"quotes\"",
|
||||
Proxies: []Proxy{
|
||||
{Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
|
||||
{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
|
||||
},
|
||||
})
|
||||
|
||||
for _, expected := range []string{
|
||||
`serverAddr = "vps.example.com"`,
|
||||
`auth.token = "token with \"quotes\""`,
|
||||
`remotePort = 60022`,
|
||||
`customDomains = ["nas.example.com"]`,
|
||||
} {
|
||||
if !strings.Contains(content, expected) {
|
||||
t.Fatalf("配置缺少 %q:\n%s", expected, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
|
||||
content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
|
||||
if !strings.Contains(content, `auth.token = "********"`) {
|
||||
t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProxyOptions(t *testing.T) {
|
||||
content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
|
||||
{Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
|
||||
{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
|
||||
}})
|
||||
for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
|
||||
if !strings.Contains(content, expected) {
|
||||
t.Fatalf("配置缺少 %q:\n%s", expected, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
|
||||
omitted := renderTOML(renderedConfig{Proxies: []Proxy{
|
||||
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
|
||||
}})
|
||||
if strings.Contains(omitted, "natTraversal") {
|
||||
t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
|
||||
}
|
||||
enabled := renderTOML(renderedConfig{Proxies: []Proxy{
|
||||
{Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
|
||||
}})
|
||||
if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
|
||||
t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(value int) *int { return &value }
|
||||
@@ -0,0 +1,46 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxConnectionLogRead = 128 << 10
|
||||
|
||||
func inspectConnectionLog(path, processState string) (string, string) {
|
||||
if processState != "running" {
|
||||
if processState == "error" {
|
||||
return "error", "frpc 进程已退出"
|
||||
}
|
||||
return "disconnected", ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "unknown", ""
|
||||
}
|
||||
if len(data) > maxConnectionLogRead {
|
||||
data = data[len(data)-maxConnectionLogRead:]
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
line := strings.TrimSpace(lines[index])
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(lower, "login to server success"):
|
||||
return "connected", ""
|
||||
case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
|
||||
return "error", trimConnectionError(line)
|
||||
case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
|
||||
return "disconnected", ""
|
||||
}
|
||||
}
|
||||
return "unknown", ""
|
||||
}
|
||||
|
||||
func trimConnectionError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 500 {
|
||||
return value[:500]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
|
||||
const (
|
||||
diagStateOK = "ok"
|
||||
diagStateFailed = "failed"
|
||||
diagStateUnknown = "unknown"
|
||||
|
||||
diagTCPTimeout = 3 * time.Second
|
||||
diagDNSTimeout = 2 * time.Second
|
||||
diagnosticsCacheTTL = 15 * time.Second
|
||||
diagnosticsMaxInFlight = 4
|
||||
)
|
||||
|
||||
type diagnosticsCacheEntry struct {
|
||||
diagnostics ServerDiagnostics
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
|
||||
// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
|
||||
func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
|
||||
server, err := s.store.GetServer(ctx, id)
|
||||
if err != nil {
|
||||
return ServerDiagnostics{}, err
|
||||
}
|
||||
return s.runDiagnostics(ctx, server), nil
|
||||
}
|
||||
|
||||
func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
|
||||
s.diagMu.Lock()
|
||||
if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
|
||||
cached := entry.diagnostics
|
||||
s.diagMu.Unlock()
|
||||
return cached
|
||||
}
|
||||
// 并发上限:超限时返回旧结果或 unknown,不排队等待
|
||||
if s.diagInFlight >= diagnosticsMaxInFlight {
|
||||
cached, ok := s.diagCache[server.ID]
|
||||
s.diagMu.Unlock()
|
||||
if ok {
|
||||
return cached.diagnostics
|
||||
}
|
||||
return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
|
||||
}
|
||||
s.diagInFlight++
|
||||
s.diagMu.Unlock()
|
||||
defer func() {
|
||||
s.diagMu.Lock()
|
||||
s.diagInFlight--
|
||||
s.diagMu.Unlock()
|
||||
}()
|
||||
|
||||
result := s.computeDiagnostics(ctx, server)
|
||||
s.diagMu.Lock()
|
||||
s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
|
||||
s.diagMu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
|
||||
// 绝不写入 Error(Error 仅表示控制端口探测失败)。
|
||||
func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
|
||||
dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
|
||||
tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
|
||||
diagnostics := ServerDiagnostics{
|
||||
ServerID: server.ID,
|
||||
Available: tcpCheck.State == diagStateOK,
|
||||
LatencyMS: tcpCheck.LatencyMS,
|
||||
Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
|
||||
}
|
||||
if tcpCheck.State == diagStateFailed {
|
||||
diagnostics.Error = tcpCheck.Detail
|
||||
}
|
||||
|
||||
// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
|
||||
// 不让列表页同步等待外部定位 API。
|
||||
location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
|
||||
diagnostics.Location = location
|
||||
locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
|
||||
if !resolved {
|
||||
locationCheck.State = diagStateUnknown
|
||||
locationCheck.Detail = "定位信息后台获取中"
|
||||
go s.refreshLocation(server.ServerAddr)
|
||||
}
|
||||
diagnostics.Checks = append(diagnostics.Checks, locationCheck)
|
||||
s.store.AddServerDiagnostic(ctx, diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
|
||||
func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "tcp_reachable"}
|
||||
if dnsCheck.State == diagStateFailed {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = "已跳过:" + dnsCheck.Detail
|
||||
return check
|
||||
}
|
||||
dialer := net.Dialer{Timeout: diagTCPTimeout}
|
||||
started := time.Now()
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
|
||||
check.LatencyMS = time.Since(started).Milliseconds()
|
||||
if check.LatencyMS < 1 {
|
||||
check.LatencyMS = 1
|
||||
}
|
||||
if err != nil {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = diagnoseDialError(err)
|
||||
return check
|
||||
}
|
||||
_ = conn.Close()
|
||||
check.State = diagStateOK
|
||||
check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
|
||||
return check
|
||||
}
|
||||
|
||||
// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
|
||||
func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "dns_resolution"}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
check.State = diagStateOK
|
||||
check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
|
||||
return check
|
||||
}
|
||||
resolver := net.DefaultResolver
|
||||
ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
|
||||
elapsed := time.Since(started).Milliseconds()
|
||||
if err != nil || len(addresses) == 0 {
|
||||
check.State = diagStateFailed
|
||||
check.Detail = "域名解析失败:" + diagnoseResolveError(err)
|
||||
return check
|
||||
}
|
||||
var ipv4, ipv6 []string
|
||||
for _, address := range addresses {
|
||||
if address.IP.To4() != nil {
|
||||
ipv4 = append(ipv4, address.IP.String())
|
||||
} else {
|
||||
ipv6 = append(ipv6, address.IP.String())
|
||||
}
|
||||
}
|
||||
summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
|
||||
if len(ipv6) > 0 {
|
||||
summary += ";AAAA=" + strings.Join(ipv6, ", ")
|
||||
} else {
|
||||
summary += ";AAAA=无"
|
||||
}
|
||||
check.LatencyMS = elapsed
|
||||
check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
|
||||
return check
|
||||
}
|
||||
|
||||
func diagnoseResolveError(err error) string {
|
||||
if err == nil {
|
||||
return "无解析结果"
|
||||
}
|
||||
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
|
||||
return "域名不存在"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
|
||||
func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
|
||||
check := DiagnosticCheck{Name: "frpc_login"}
|
||||
data, err := os.ReadFile(s.serverLogPath(serverID))
|
||||
if os.IsNotExist(err) {
|
||||
check.State = diagStateUnknown
|
||||
check.Detail = "frpc 尚未运行,无登录记录"
|
||||
return check
|
||||
}
|
||||
if err != nil {
|
||||
check.State = diagStateUnknown
|
||||
check.Detail = "读取 frpc 日志失败"
|
||||
return check
|
||||
}
|
||||
state, detail := summarizeFRPCLogin(string(data))
|
||||
check.State = state
|
||||
check.Detail = detail
|
||||
return check
|
||||
}
|
||||
|
||||
// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
|
||||
func summarizeFRPCLogin(log string) (string, string) {
|
||||
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
|
||||
const window = 200
|
||||
if len(lines) > window {
|
||||
lines = lines[len(lines)-window:]
|
||||
}
|
||||
failureDetail := ""
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
line := lines[index]
|
||||
if prefix := strings.Index(line, "] "); prefix >= 0 {
|
||||
line = line[prefix+2:]
|
||||
}
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return diagStateOK, "frpc 已登录 frps"
|
||||
}
|
||||
if failureDetail == "" && isLoginFailureLine(line) {
|
||||
failureDetail = line
|
||||
}
|
||||
}
|
||||
if failureDetail != "" {
|
||||
if len(failureDetail) > 160 {
|
||||
failureDetail = failureDetail[:160] + "…"
|
||||
}
|
||||
return diagStateFailed, "frpc 登录失败:" + failureDetail
|
||||
}
|
||||
return diagStateUnknown, "日志中暂无登录结果记录"
|
||||
}
|
||||
|
||||
func isLoginFailureLine(line string) bool {
|
||||
if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(line, "token") && strings.Contains(line, "error")
|
||||
}
|
||||
|
||||
// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
|
||||
func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
|
||||
ip := resolvePublicIP(ctx, host)
|
||||
if ip == "" {
|
||||
// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
|
||||
return strings.TrimSpace(host), true
|
||||
}
|
||||
s.locationMu.Lock()
|
||||
cached, ok := s.locations[ip]
|
||||
s.locationMu.Unlock()
|
||||
if ok && time.Now().Before(cached.expiresAt) {
|
||||
return cached.value, true
|
||||
}
|
||||
return ip, false
|
||||
}
|
||||
|
||||
// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
|
||||
func (s *Service) refreshLocation(host string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
_ = s.serverLocation(ctx, host)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const locationCacheTTL = 24 * time.Hour
|
||||
|
||||
type locationCache struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type ipLocationResponse struct {
|
||||
Success bool `json:"success"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func (s *Service) serverLocation(ctx context.Context, host string) string {
|
||||
ip := resolvePublicIP(ctx, host)
|
||||
if ip == "" {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
s.locationMu.Lock()
|
||||
if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
|
||||
s.locationMu.Unlock()
|
||||
return cached.value
|
||||
}
|
||||
s.locationMu.Unlock()
|
||||
value := lookupIPLocation(ctx, ip)
|
||||
// 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
|
||||
s.locationMu.Lock()
|
||||
s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
|
||||
s.locationMu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
func resolvePublicIP(ctx context.Context, host string) string {
|
||||
if ip := net.ParseIP(host); isPublicIP(ip) {
|
||||
return ip.String()
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if isPublicIP(address.IP) {
|
||||
return address.IP.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
func lookupIPLocation(ctx context.Context, ip string) string {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
|
||||
if err != nil {
|
||||
return "未定位"
|
||||
}
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "未定位"
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "未定位"
|
||||
}
|
||||
var data ipLocationResponse
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
|
||||
return "未定位"
|
||||
}
|
||||
return formatLocation(data)
|
||||
}
|
||||
|
||||
func formatLocation(data ipLocationResponse) string {
|
||||
parts := []string{data.City, data.Region, data.Country}
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "未定位"
|
||||
}
|
||||
return strings.Join(values, " · ")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatLocation(t *testing.T) {
|
||||
value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
|
||||
if value != "Hong Kong · China" {
|
||||
t.Fatalf("位置格式错误:%s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
if !isPublicIP(net.ParseIP("38.95.74.87")) {
|
||||
t.Fatal("公网 IP 应被识别")
|
||||
}
|
||||
if isPublicIP(net.ParseIP("192.168.1.1")) {
|
||||
t.Fatal("私网 IP 不应被定位")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
bin string
|
||||
configPath string
|
||||
logPath string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
orphan *os.Process
|
||||
orphanPID int
|
||||
pid int
|
||||
version string
|
||||
started time.Time
|
||||
reloaded time.Time
|
||||
lastErr string
|
||||
}
|
||||
|
||||
func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
|
||||
return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) Verify(ctx context.Context, configPath string) error {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) VerifyBinary(ctx context.Context) error {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.runningLocked() {
|
||||
return nil
|
||||
}
|
||||
bin, err := m.binaryPathLocked()
|
||||
if err != nil {
|
||||
m.lastErr = err.Error()
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(bin, "-c", m.configPath)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
m.lastErr = err.Error()
|
||||
return fmt.Errorf("启动 frpc 失败:%w", err)
|
||||
}
|
||||
m.cmd = cmd
|
||||
m.pid = cmd.Process.Pid
|
||||
m.started = time.Now().UTC()
|
||||
m.reloaded = m.started
|
||||
m.lastErr = ""
|
||||
go m.wait(cmd, logFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Restart(ctx context.Context) error {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start()
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
cmd := m.cmd
|
||||
orphan := m.orphan
|
||||
m.mu.Unlock()
|
||||
if cmd == nil && orphan == nil {
|
||||
return nil
|
||||
}
|
||||
process := orphan
|
||||
if cmd != nil {
|
||||
process = cmd.Process
|
||||
}
|
||||
if process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := process.Kill(); err != nil {
|
||||
return fmt.Errorf("停止 frpc 失败:%w", err)
|
||||
}
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if !m.running() {
|
||||
m.mu.Lock()
|
||||
m.lastErr = ""
|
||||
m.orphan = nil
|
||||
m.orphanPID = 0
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("等待 frpc 停止超时")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() RuntimeStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.statusLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) statusLocked() RuntimeStatus {
|
||||
status := RuntimeStatus{
|
||||
Status: "stopped",
|
||||
PID: m.pid,
|
||||
Version: m.version,
|
||||
LastError: m.lastErr,
|
||||
}
|
||||
if m.runningLocked() {
|
||||
status.Status = "running"
|
||||
if status.PID == 0 {
|
||||
status.PID = m.orphanPID
|
||||
}
|
||||
} else if m.orphan != nil {
|
||||
status.PID = 0
|
||||
}
|
||||
if !m.started.IsZero() {
|
||||
status.LastStartedAt = m.started.Format(time.RFC3339)
|
||||
}
|
||||
if !m.reloaded.IsZero() {
|
||||
status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (m *Manager) RefreshVersion(ctx context.Context) {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, bin, "--version").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.version = strings.TrimSpace(string(output))
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
|
||||
err := cmd.Wait()
|
||||
logFile.Close()
|
||||
m.mu.Lock()
|
||||
if m.cmd != cmd {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.cmd = nil
|
||||
m.pid = 0
|
||||
if err != nil {
|
||||
// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
|
||||
m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
|
||||
m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
|
||||
func tailFile(path string, maxLines int) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[len(lines)-maxLines:]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
|
||||
func (m *Manager) running() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.runningLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) runningLocked() bool {
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
return true
|
||||
}
|
||||
return m.orphan != nil && processAlive(m.orphanPID)
|
||||
}
|
||||
|
||||
func (m *Manager) Attach(pid int) bool {
|
||||
bin, err := m.BinaryPath()
|
||||
if err != nil || !processMatches(pid, bin, m.configPath) {
|
||||
return false
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.orphan = process
|
||||
m.orphanPID = pid
|
||||
m.pid = pid
|
||||
m.lastErr = ""
|
||||
m.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
alive, _ := probeProcess(pid)
|
||||
return alive
|
||||
}
|
||||
|
||||
func processMatches(pid int, binary, configPath string) bool {
|
||||
alive, commandLine := probeProcess(pid)
|
||||
if !alive {
|
||||
return false
|
||||
}
|
||||
commandLine = strings.ToLower(filepath.ToSlash(commandLine))
|
||||
return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
|
||||
strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
|
||||
}
|
||||
|
||||
func (m *Manager) BinaryPath() (string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.binaryPathLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) binaryPathLocked() (string, error) {
|
||||
if strings.ContainsAny(m.bin, `/\\`) {
|
||||
if _, err := os.Stat(m.bin); err != nil {
|
||||
return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
|
||||
}
|
||||
return m.bin, nil
|
||||
}
|
||||
path, err := exec.LookPath(m.bin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到 frpc 二进制,请设置 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) managerForServer(serverID int64) *Manager {
|
||||
s.managersMu.Lock()
|
||||
defer s.managersMu.Unlock()
|
||||
if manager, ok := s.managers[serverID]; ok {
|
||||
return manager
|
||||
}
|
||||
bin, err := s.manager.BinaryPath()
|
||||
if err != nil {
|
||||
bin = s.cfg.FrpcBin
|
||||
}
|
||||
manager := NewManager(
|
||||
bin,
|
||||
s.serverConfigPath(serverID),
|
||||
s.serverLogPath(serverID),
|
||||
s.logger,
|
||||
)
|
||||
s.managers[serverID] = manager
|
||||
return manager
|
||||
}
|
||||
|
||||
func (s *Service) serverConfigPath(serverID int64) string {
|
||||
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
|
||||
}
|
||||
|
||||
func (s *Service) serverLogPath(serverID int64) string {
|
||||
return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
|
||||
}
|
||||
|
||||
func (s *Service) managerSnapshot() map[int64]*Manager {
|
||||
s.managersMu.RLock()
|
||||
defer s.managersMu.RUnlock()
|
||||
result := make(map[int64]*Manager, len(s.managers))
|
||||
for id, manager := range s.managers {
|
||||
result[id] = manager
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) setManagerBinary(path string) {
|
||||
s.manager.SetBinary(path)
|
||||
for _, manager := range s.managerSnapshot() {
|
||||
manager.SetBinary(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) removeManager(serverID int64) {
|
||||
s.managersMu.Lock()
|
||||
delete(s.managers, serverID)
|
||||
s.managersMu.Unlock()
|
||||
}
|
||||
|
||||
func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
|
||||
result := RuntimeStatus{Status: "stopped"}
|
||||
for _, status := range statuses {
|
||||
if status.Status == "running" {
|
||||
result.Status = "running"
|
||||
}
|
||||
if status.Configured {
|
||||
result.Configured = true
|
||||
}
|
||||
result.EnabledProxies += status.EnabledProxies
|
||||
if result.PID == 0 && status.PID != 0 {
|
||||
result.PID = status.PID
|
||||
}
|
||||
if status.LastStartedAt > result.LastStartedAt {
|
||||
result.LastStartedAt = status.LastStartedAt
|
||||
}
|
||||
if status.LastReloadedAt > result.LastReloadedAt {
|
||||
result.LastReloadedAt = status.LastReloadedAt
|
||||
}
|
||||
if result.LastError == "" && status.LastError != "" {
|
||||
result.LastError = status.LastError
|
||||
}
|
||||
if status.Version != "" && result.Version == "" {
|
||||
result.Version = status.Version
|
||||
}
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
result.Status = "unknown"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
|
||||
var messages []string
|
||||
for id, manager := range s.managerSnapshot() {
|
||||
if err := action(manager); err != nil {
|
||||
messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
|
||||
}
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(messages, ";"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package frp
|
||||
|
||||
import "os"
|
||||
|
||||
func probeProcess(pid int) (bool, string) {
|
||||
if pid <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, process.String()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func probeProcess(pid int) (bool, string) {
|
||||
if pid <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil {
|
||||
commandLine := strings.ReplaceAll(string(data), "\x00", " ")
|
||||
return strings.TrimSpace(commandLine) != "", strings.TrimSpace(commandLine)
|
||||
}
|
||||
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func probeProcess(pid int) (bool, string) {
|
||||
if pid <= 0 {
|
||||
return false, ""
|
||||
}
|
||||
script := fmt.Sprintf("$p=Get-CimInstance Win32_Process -Filter \"ProcessId=%s\"; if ($p) { $p.CommandLine }", strconv.Itoa(pid))
|
||||
output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSupportedReleasesUsesOfficialDigest(t *testing.T) {
|
||||
version := "v0.99.0"
|
||||
extension := ".tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
extension = ".zip"
|
||||
}
|
||||
assetName := "frp_0.99.0_" + runtime.GOOS + "_" + runtime.GOARCH + extension
|
||||
releases := supportedReleases([]githubRelease{{
|
||||
TagName: version,
|
||||
Assets: []githubAsset{{ID: 123, Name: assetName, Size: 42, Digest: "sha256:abc123"}},
|
||||
PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC),
|
||||
}})
|
||||
if len(releases) != 1 || releases[0].Checksum != "abc123" || releases[0].AssetID != 123 {
|
||||
t.Fatalf("未读取官方资产摘要:%+v", releases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZipBinary(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
file, err := writer.Create("frp/" + clientBinaryName())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write([]byte("client")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := extractZipBinary(buffer.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "client" {
|
||||
t.Fatalf("提取内容错误:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDownloadProxy(t *testing.T) {
|
||||
for _, proxy := range []string{"", "https://gh-proxy.org/", "https://v4.gh-proxy.org/", "https://cdn.gh-proxy.org/"} {
|
||||
if !validDownloadProxy(proxy) {
|
||||
t.Fatalf("合法下载代理被拒绝:%s", proxy)
|
||||
}
|
||||
}
|
||||
if validDownloadProxy("https://untrusted.example/") {
|
||||
t.Fatal("非白名单下载代理不应被接受")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseListURLsPrioritizesSelectedProxy(t *testing.T) {
|
||||
urls := releaseListURLs("https://v4.gh-proxy.org/")
|
||||
if urls[0] != "https://v4.gh-proxy.org/"+githubReleasesURL {
|
||||
t.Fatalf("未优先使用已选代理:%v", urls)
|
||||
}
|
||||
if urls[1] != githubReleasesURL {
|
||||
t.Fatalf("直连地址缺失:%v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBinaryVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "bin", "v0.71.0", clientBinaryName())
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("client"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeBinaryVersion(root, "v0.71.0", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("版本目录未删除:%v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiagnoseDialError(t *testing.T) {
|
||||
err := errors.New("connectex: An attempt was made to access a socket in a way forbidden by its access permissions.")
|
||||
message := diagnoseDialError(err)
|
||||
if !strings.HasPrefix(message, "本机网络策略阻止连接:") {
|
||||
t.Fatalf("unexpected message: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
// 23.3:诊断拆分为独立检查项,登录状态从 frpc 日志推断,unknown 不等于失败。
|
||||
func TestSummarizeFRPCLogin(t *testing.T) {
|
||||
logLines := strings.Join([]string{
|
||||
"2026/09/17 10:00:00 [I] [root.go:139] frpc service start",
|
||||
"2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token error",
|
||||
"2026/09/17 10:01:00 [I] [control.go:164] login to server success, get run id [abc]",
|
||||
}, "\n")
|
||||
state, detail := summarizeFRPCLogin(logLines)
|
||||
if state != diagStateOK {
|
||||
t.Fatalf("login success should win over earlier failure, got %s (%s)", state, detail)
|
||||
}
|
||||
failedLog := "2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token [wrong] error: authentication failed"
|
||||
state, detail = summarizeFRPCLogin(failedLog)
|
||||
if state != diagStateFailed || !strings.Contains(detail, "登录失败") {
|
||||
t.Fatalf("login failure should be reported, got %s (%s)", state, detail)
|
||||
}
|
||||
state, _ = summarizeFRPCLogin("2026/09/17 10:00:00 [I] [root.go:139] frpc service start")
|
||||
if state != diagStateUnknown {
|
||||
t.Fatalf("no login evidence should be unknown, got %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseDNSFormatsRecords(t *testing.T) {
|
||||
check := diagnoseDNS(context.Background(), "192.168.1.10")
|
||||
if check.State != diagStateOK || !strings.Contains(check.Detail, "IP 地址") {
|
||||
t.Fatalf("literal IP should skip resolution: %+v", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
|
||||
detail := summarizeServerSamples([]ServerSample{
|
||||
{CheckedAt: "2026-09-15 18:00:00", Available: true, LatencyMS: 20},
|
||||
{CheckedAt: "2026-09-15 18:01:00", Available: false, Error: "连接失败"},
|
||||
})
|
||||
if detail.Available {
|
||||
t.Fatal("latest failed sample should make current status unavailable")
|
||||
}
|
||||
if detail.Availability != 50 {
|
||||
t.Fatalf("unexpected availability: %v", detail.Availability)
|
||||
}
|
||||
if detail.AverageLatency != 20 || detail.PeakLatency != 20 {
|
||||
t.Fatalf("unexpected latency summary: average=%d peak=%d", detail.AverageLatency, detail.PeakLatency)
|
||||
}
|
||||
if detail.LastError != "连接失败" {
|
||||
t.Fatalf("unexpected last error: %s", detail.LastError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
|
||||
type frpsProxyStatus struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TodayTrafficIn int64 `json:"today_traffic_in"`
|
||||
TodayTrafficOut int64 `json:"today_traffic_out"`
|
||||
CurrentConns int `json:"cur_conns"`
|
||||
}
|
||||
|
||||
type ProxyTraffic struct {
|
||||
TrafficIn int64 `json:"traffic_in"`
|
||||
TrafficOut int64 `json:"traffic_out"`
|
||||
CurConns int `json:"cur_conns"`
|
||||
TrafficInRate int64 `json:"traffic_in_rate"`
|
||||
TrafficOutRate int64 `json:"traffic_out_rate"`
|
||||
}
|
||||
|
||||
type ServerTraffic struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Proxies map[string]ProxyTraffic `json:"proxies"`
|
||||
ProxyNamesByDomain map[string]string `json:"proxy_names_by_domain,omitempty"`
|
||||
}
|
||||
|
||||
type ProxiesTraffic struct {
|
||||
Items map[int64]ProxyTraffic `json:"items"`
|
||||
Errors map[int64]string `json:"errors,omitempty"`
|
||||
SampledAt string `json:"sampled_at"`
|
||||
}
|
||||
|
||||
type trafficSample struct {
|
||||
at time.Time
|
||||
in int64
|
||||
out int64
|
||||
}
|
||||
|
||||
type frpsTrafficClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newFRPSTrafficClient() *frpsTrafficClient {
|
||||
return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
|
||||
}
|
||||
|
||||
// fetchServerTraffic 版本自适应采集:
|
||||
// - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
|
||||
// - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
|
||||
//
|
||||
// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
|
||||
func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
|
||||
empty := map[string]ProxyTraffic{}
|
||||
if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
|
||||
return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
|
||||
}
|
||||
|
||||
// 优先 V2 API(v0.71.0+)
|
||||
v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
|
||||
body, status, err := c.getJSON(ctx, v2URL, server, password)
|
||||
if err != nil {
|
||||
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
return c.parseV2Proxies(server, body)
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
|
||||
}
|
||||
|
||||
// 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
|
||||
// fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
|
||||
return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
|
||||
}
|
||||
|
||||
// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
|
||||
type legacyProxyInfo struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Conf struct {
|
||||
CustomDomains []string `json:"customDomains"`
|
||||
} `json:"conf"`
|
||||
Status string `json:"status"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int `json:"curConns"`
|
||||
}
|
||||
|
||||
// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
|
||||
// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
|
||||
func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
|
||||
url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
|
||||
body, status, err := c.getJSON(ctx, url, server, password)
|
||||
if err != nil {
|
||||
return legacyProxyInfo{}, 0, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var info legacyProxyInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
|
||||
}
|
||||
return info, status, nil
|
||||
}
|
||||
|
||||
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
|
||||
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
|
||||
empty := map[string]ProxyTraffic{}
|
||||
proxies := make(map[string]ProxyTraffic)
|
||||
proxyNamesByDomain := make(map[string]string)
|
||||
var lastErr error
|
||||
for _, name := range names {
|
||||
proxyName := name
|
||||
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
|
||||
proxyName = user + "." + proxyName
|
||||
}
|
||||
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
|
||||
proxies[info.Name] = traffic
|
||||
for _, domain := range info.Conf.CustomDomains {
|
||||
if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
|
||||
proxies["domain:"+domain] = traffic
|
||||
proxyNamesByDomain[domain] = info.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(proxies) == 0 && lastErr != nil {
|
||||
return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
|
||||
}
|
||||
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
|
||||
}
|
||||
|
||||
// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
|
||||
func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
|
||||
req.SetBasicAuth(user, password)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// parseV2Proxies 解析 V2 API 响应(实测结构):
|
||||
// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
|
||||
func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
|
||||
empty := map[string]ProxyTraffic{}
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Spec struct {
|
||||
Type string `json:"type"`
|
||||
HTTP struct {
|
||||
CustomDomains []string `json:"customDomains"`
|
||||
} `json:"http"`
|
||||
} `json:"spec"`
|
||||
Status struct {
|
||||
Phase string `json:"phase"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int `json:"curConns"`
|
||||
} `json:"status"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
|
||||
}
|
||||
proxies := make(map[string]ProxyTraffic)
|
||||
proxyNamesByDomain := make(map[string]string)
|
||||
for _, item := range payload.Data.Items {
|
||||
traffic := ProxyTraffic{
|
||||
TrafficIn: item.Status.TodayTrafficIn,
|
||||
TrafficOut: item.Status.TodayTrafficOut,
|
||||
CurConns: item.Status.CurConns,
|
||||
}
|
||||
proxies[item.Name] = traffic
|
||||
// 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
|
||||
for _, d := range item.Spec.HTTP.CustomDomains {
|
||||
domain := strings.ToLower(strings.TrimSpace(d))
|
||||
if domain != "" {
|
||||
proxies["domain:"+domain] = traffic
|
||||
proxyNamesByDomain[domain] = item.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
|
||||
}
|
||||
|
||||
// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
|
||||
func validDashboardAddr(addr string) bool {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return true
|
||||
}
|
||||
return validHost(addr)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
func validateServerInput(in ServerInput) error {
|
||||
if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
|
||||
return fmt.Errorf("服务端名称长度应为 1-64 个字符")
|
||||
}
|
||||
if !validHost(in.ServerAddr) {
|
||||
return fmt.Errorf("FRP 服务端地址无效")
|
||||
}
|
||||
if !validPort(in.ServerPort) {
|
||||
return fmt.Errorf("FRP 服务端端口无效")
|
||||
}
|
||||
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
|
||||
return fmt.Errorf("frps 管理接口端口无效")
|
||||
}
|
||||
if !validDashboardAddr(in.Options.DashboardAddr) {
|
||||
return fmt.Errorf("frps 管理接口地址无效")
|
||||
}
|
||||
if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
|
||||
return fmt.Errorf("TLS 服务名无效")
|
||||
}
|
||||
if !validAuthMethod(in.Options.AuthMethod) {
|
||||
return fmt.Errorf("不支持的认证方式")
|
||||
}
|
||||
if !validLogLevel(in.Options.LogLevel) {
|
||||
return fmt.Errorf("日志级别无效")
|
||||
}
|
||||
if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
|
||||
return fmt.Errorf("日志保留天数应为 0-3650")
|
||||
}
|
||||
if !validProtocol(in.Options.Protocol) {
|
||||
return fmt.Errorf("传输协议无效")
|
||||
}
|
||||
if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
|
||||
return fmt.Errorf("代理或 OIDC 地址无效")
|
||||
}
|
||||
if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
|
||||
return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
|
||||
}
|
||||
if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
|
||||
return fmt.Errorf("传输参数不能小于 0")
|
||||
}
|
||||
if len([]rune(in.Options.Location)) > 120 {
|
||||
return fmt.Errorf("服务端位置不能超过 120 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxyInput(in ProxyInput) error {
|
||||
if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
|
||||
return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
|
||||
}
|
||||
if in.Options.Plugin == nil {
|
||||
if !validHost(in.LocalIP) {
|
||||
return fmt.Errorf("内网地址无效")
|
||||
}
|
||||
if !validPort(in.LocalPort) {
|
||||
return fmt.Errorf("内网端口无效")
|
||||
}
|
||||
}
|
||||
switch in.Type {
|
||||
case "tcp", "udp":
|
||||
if in.RemotePort == nil || !validPort(*in.RemotePort) {
|
||||
return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
|
||||
}
|
||||
if len(in.CustomDomains) > 0 {
|
||||
return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
|
||||
}
|
||||
case "http", "https":
|
||||
if in.RemotePort != nil {
|
||||
return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
|
||||
}
|
||||
if len(in.CustomDomains) == 0 {
|
||||
return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
|
||||
}
|
||||
for _, domain := range in.CustomDomains {
|
||||
if !validDomain(domain) {
|
||||
return fmt.Errorf("自定义域名无效:%s", domain)
|
||||
}
|
||||
}
|
||||
case "tcpmux":
|
||||
if in.Options.Multiplexer != "httpconnect" {
|
||||
return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
|
||||
}
|
||||
if len(in.CustomDomains) == 0 {
|
||||
return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
|
||||
}
|
||||
for _, domain := range in.CustomDomains {
|
||||
if !validDomain(domain) {
|
||||
return fmt.Errorf("自定义域名无效:%s", domain)
|
||||
}
|
||||
}
|
||||
if in.RemotePort != nil {
|
||||
return fmt.Errorf("TCPMUX 规则不能设置远程端口")
|
||||
}
|
||||
case "stcp", "sudp", "xtcp":
|
||||
if strings.TrimSpace(in.Options.SecretKey) == "" {
|
||||
return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
|
||||
}
|
||||
if in.RemotePort != nil {
|
||||
return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的 FRP 规则类型")
|
||||
}
|
||||
if err := validateProxyOptions(in); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxyOptions(in ProxyInput) error {
|
||||
opts := in.Options
|
||||
if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
|
||||
return fmt.Errorf("限速位置只能是 client 或 server")
|
||||
}
|
||||
if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
|
||||
return fmt.Errorf("代理协议版本只能是 v1 或 v2")
|
||||
}
|
||||
if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
|
||||
return fmt.Errorf("健康检查类型只能是 tcp 或 http")
|
||||
}
|
||||
if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
|
||||
return fmt.Errorf("健康检查参数不能小于 0")
|
||||
}
|
||||
if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
|
||||
return fmt.Errorf("负载均衡组设置后必须填写组密钥")
|
||||
}
|
||||
return validateProxyPlugin(opts.Plugin)
|
||||
}
|
||||
|
||||
// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
|
||||
func validateProxyPlugin(plugin *ProxyPluginOptions) error {
|
||||
if plugin == nil {
|
||||
return nil
|
||||
}
|
||||
pluginType := strings.TrimSpace(plugin.Type)
|
||||
if pluginType == "" {
|
||||
return fmt.Errorf("插件类型不能为空")
|
||||
}
|
||||
validAddr := func(addr string, label string) error {
|
||||
addr = strings.TrimSpace(addr)
|
||||
host, portText, ok := strings.Cut(addr, ":")
|
||||
if !ok || !validHost(host) || !validPort(parseInt(portText)) {
|
||||
return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validSecretFile := func(path, label string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch pluginType {
|
||||
case "unix_domain_socket":
|
||||
if strings.TrimSpace(plugin.UnixPath) == "" {
|
||||
return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
|
||||
}
|
||||
case "static_file":
|
||||
if strings.TrimSpace(plugin.LocalPath) == "" {
|
||||
return fmt.Errorf("插件 static_file 需要提供静态文件目录")
|
||||
}
|
||||
case "http_proxy", "socks5":
|
||||
// 用户名/密码可选
|
||||
case "http2https", "http2http":
|
||||
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
|
||||
return err
|
||||
}
|
||||
case "https2http", "https2https", "tls2raw":
|
||||
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的插件类型:%s", pluginType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
|
||||
func parseInt(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
number := 0
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0
|
||||
}
|
||||
number = number*10 + int(ch-'0')
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
|
||||
// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
|
||||
func ValidateProxy(in ProxyInput) error {
|
||||
return validateProxyInput(normalizeProxyInput(in))
|
||||
}
|
||||
|
||||
func normalizeServerInput(in ServerInput) ServerInput {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
|
||||
in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
|
||||
in.Token = strings.TrimSpace(in.Token)
|
||||
in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
|
||||
in.Options = normalizeServerOptions(in.Options)
|
||||
return in
|
||||
}
|
||||
|
||||
func normalizeServerOptions(in ServerOptions) ServerOptions {
|
||||
in.User = strings.TrimSpace(in.User)
|
||||
in.Location = strings.TrimSpace(in.Location)
|
||||
in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
|
||||
in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
|
||||
in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
|
||||
in.OIDCScope = strings.TrimSpace(in.OIDCScope)
|
||||
in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
|
||||
in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
|
||||
in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
|
||||
in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
|
||||
in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
|
||||
in.ProxyURL = strings.TrimSpace(in.ProxyURL)
|
||||
in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
|
||||
in.DNSServer = strings.TrimSpace(in.DNSServer)
|
||||
in.Remark = strings.TrimSpace(in.Remark)
|
||||
if in.AuthMethod == "" {
|
||||
in.AuthMethod = "token"
|
||||
}
|
||||
if in.LogLevel == "" {
|
||||
in.LogLevel = "info"
|
||||
}
|
||||
if in.Protocol == "" {
|
||||
in.Protocol = "tcp"
|
||||
}
|
||||
if in.LogMaxDays == 0 {
|
||||
in.LogMaxDays = 3
|
||||
}
|
||||
if in.DialServerTimeout == 0 {
|
||||
in.DialServerTimeout = 10
|
||||
}
|
||||
if in.DialServerKeepalive == 0 {
|
||||
in.DialServerKeepalive = 7200
|
||||
}
|
||||
if in.TCPMuxKeepaliveInterval == 0 {
|
||||
in.TCPMuxKeepaliveInterval = 60
|
||||
}
|
||||
if in.HeartbeatInterval == 0 {
|
||||
in.HeartbeatInterval = 30
|
||||
}
|
||||
if in.HeartbeatTimeout == 0 {
|
||||
in.HeartbeatTimeout = 90
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func validAuthMethod(value string) bool {
|
||||
return value == "none" || value == "token" || value == "oidc"
|
||||
}
|
||||
func validLogLevel(value string) bool {
|
||||
return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
|
||||
}
|
||||
func validProtocol(value string) bool {
|
||||
return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
|
||||
}
|
||||
func validOptionalURL(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
return err == nil && parsed.Scheme != "" && parsed.Host != ""
|
||||
}
|
||||
|
||||
func normalizeProxyInput(in ProxyInput) ProxyInput {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Type = strings.ToLower(strings.TrimSpace(in.Type))
|
||||
in.LocalIP = strings.TrimSpace(in.LocalIP)
|
||||
in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
|
||||
in.Remark = strings.TrimSpace(in.Remark)
|
||||
in.Options = normalizeProxyOptions(in.Options)
|
||||
seen := map[string]bool{}
|
||||
domains := make([]string, 0, len(in.CustomDomains))
|
||||
for _, domain := range in.CustomDomains {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain != "" && !seen[domain] {
|
||||
seen[domain] = true
|
||||
domains = append(domains, domain)
|
||||
}
|
||||
}
|
||||
in.CustomDomains = domains
|
||||
return in
|
||||
}
|
||||
|
||||
func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
|
||||
in.Subdomain = strings.TrimSpace(in.Subdomain)
|
||||
in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
|
||||
in.HTTPUser = strings.TrimSpace(in.HTTPUser)
|
||||
in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
|
||||
in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
|
||||
in.SecretKey = strings.TrimSpace(in.SecretKey)
|
||||
for index, user := range in.AllowUsers {
|
||||
in.AllowUsers[index] = strings.TrimSpace(user)
|
||||
}
|
||||
in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
|
||||
in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
|
||||
in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
|
||||
in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
|
||||
in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
|
||||
return in
|
||||
}
|
||||
|
||||
func validPort(port int) bool {
|
||||
return port > 0 && port <= 65535
|
||||
}
|
||||
|
||||
func validHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(host) != nil || host == "localhost" {
|
||||
return true
|
||||
}
|
||||
return validDomain(host)
|
||||
}
|
||||
|
||||
func validDomain(domain string) bool {
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
|
||||
if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(domain, ".") {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, ch := range label {
|
||||
if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateProxyInput(t *testing.T) {
|
||||
port := 60022
|
||||
cases := []struct {
|
||||
name string
|
||||
input ProxyInput
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "tcp", input: ProxyInput{Name: "nas_ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port}},
|
||||
{name: "http", input: ProxyInput{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}}},
|
||||
{name: "missing remote port", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
|
||||
{name: "invalid name", input: ProxyInput{Name: "bad name", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"nas.example.com"}}, wantErr: true},
|
||||
{name: "invalid domain", input: ProxyInput{Name: "nas", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"bad domain"}}, wantErr: true},
|
||||
{name: "udp", input: ProxyInput{Name: "dns", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
|
||||
{name: "stcp", input: ProxyInput{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "abc"}}},
|
||||
{name: "tcpmux missing multiplexer", input: ProxyInput{Name: "mux", Type: "tcpmux", LocalIP: "127.0.0.1", LocalPort: 80}, wantErr: true},
|
||||
{name: "invalid bandwidth mode", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Transport: ProxyTransportOptions{BandwidthLimitMode: "wrong"}}}, wantErr: true},
|
||||
{name: "udp remote port", input: ProxyInput{Name: "dns2", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
|
||||
{name: "xtcp missing secret key", input: ProxyInput{Name: "p2p", Type: "xtcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
|
||||
{name: "plugin http2https missing local addr", input: ProxyInput{Name: "p1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", CRTPath: "cert.pem", KeyPath: "key.pem"}}}, wantErr: true},
|
||||
{name: "plugin https2http missing cert", input: ProxyInput{Name: "p2", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", LocalAddr: "127.0.0.1:80"}}}, wantErr: true},
|
||||
{name: "plugin socks5 optional auth", input: ProxyInput{Name: "p3", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "socks5"}}}},
|
||||
{name: "plugin unix_domain_socket missing path", input: ProxyInput{Name: "p4", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "unix_domain_socket"}}}, wantErr: true},
|
||||
{name: "plugin static_file missing local path", input: ProxyInput{Name: "p5", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "static_file"}}}, wantErr: true},
|
||||
{name: "plugin unknown type", input: ProxyInput{Name: "p6", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "whatever"}}}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := validateProxyInput(testCase.input)
|
||||
if (err != nil) != testCase.wantErr {
|
||||
t.Fatalf("validateProxyInput() error = %v, wantErr %v", err, testCase.wantErr)
|
||||
}
|
||||
// 对外导出的校验入口应与内部入口行为一致(关系表模式校验复用此函数)
|
||||
if (ValidateProxy(testCase.input) != nil) != testCase.wantErr {
|
||||
t.Fatalf("ValidateProxy() error = %v, wantErr %v", ValidateProxy(testCase.input), testCase.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user