This commit is contained in:
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/go-acme/lego/v4/providers/dns/dnspod"
|
||||
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package acme
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
func TestValidateDNSCredentialsVolcengine(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
func NormalizeCertDomains(raw []string) ([]string, error) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
)
|
||||
|
||||
type DownloadPart string
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
)
|
||||
|
||||
type JobEvent struct {
|
||||
|
||||
@@ -24,12 +24,12 @@ import (
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
type volcengineDNS struct {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -130,7 +130,7 @@ func (s *Server) nginxLogPath(kind string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := strings.TrimSpace(os.Getenv("FONU_AGENT_NGINX_LOG_DIR"))
|
||||
dir := strings.TrimSpace(os.Getenv("HAVLINE_AGENT_NGINX_LOG_DIR"))
|
||||
if dir == "" {
|
||||
dir = "/var/log/nginx"
|
||||
}
|
||||
@@ -210,7 +210,7 @@ type vpsCertificate struct {
|
||||
DaysLeft int `json:"days_left"`
|
||||
}
|
||||
|
||||
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Fonu 证书库交叉核对,也能看到宝塔自行申请的证书
|
||||
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Havline 证书库交叉核对,也能看到宝塔自行申请的证书
|
||||
func (s *Server) certs(w http.ResponseWriter, _ *http.Request) {
|
||||
dir := strings.TrimSpace(s.cfg.CertsDir)
|
||||
payload := map[string]any{
|
||||
|
||||
@@ -31,10 +31,10 @@ func TestValidateRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNginxRouteRendersUpstream:vhost 模板的关键行(标记、server_name、proxy_pass)。
|
||||
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/fonu-agent 写 htpasswd,不适合在单测里触发。
|
||||
// 不构造 Basic Auth 场景——那条路径会往 /var/lib/havline-agent 写 htpasswd,不适合在单测里触发。
|
||||
func TestNginxRouteRendersUpstream(t *testing.T) {
|
||||
out := nginxRoute(routeRequest{Domain: "a.example.com", Upstream: "127.0.0.1:8080"}, "")
|
||||
if !strings.HasPrefix(out, "# Managed by fonu-agent.") {
|
||||
if !strings.HasPrefix(out, "# Managed by havline-agent.") {
|
||||
t.Errorf("缺少托管标记,其它工具无法识别该文件归属:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server_name a.example.com;") {
|
||||
@@ -63,16 +63,16 @@ func TestNginxRouteSharedTemplateSemantics(t *testing.T) {
|
||||
RateLimitBurst: 20,
|
||||
ConnLimitMax: 5,
|
||||
ChinaOnly: true,
|
||||
}, "/etc/fonu-agent/certs/a.example.com")
|
||||
}, "/etc/havline-agent/certs/a.example.com")
|
||||
for _, want := range []string{
|
||||
"ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
`add_header Referrer-Policy "strict-origin-when-cross-origin" always;`,
|
||||
"deny 8.8.8.8;",
|
||||
"if ($fonu_china_client = 0) { return 403; }",
|
||||
"if ($havline_china_client = 0) { return 403; }",
|
||||
"allow 192.168.1.0/24;",
|
||||
"deny all;",
|
||||
"limit_conn fonu_a_example_com_conn 5;",
|
||||
"limit_req zone=fonu_a_example_com_req burst=20 nodelay;",
|
||||
"limit_conn havline_a_example_com_conn 5;",
|
||||
"limit_req zone=havline_a_example_com_req burst=20 nodelay;",
|
||||
"proxy_set_header Upgrade $http_upgrade;",
|
||||
`proxy_set_header Connection "upgrade";`,
|
||||
} {
|
||||
|
||||
+49
-49
@@ -26,14 +26,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/nginxtmpl"
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
||||
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
|
||||
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。
|
||||
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。
|
||||
// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
|
||||
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
||||
@@ -61,9 +61,9 @@ import (
|
||||
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
|
||||
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
|
||||
// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。
|
||||
const AgentVersion = "0.14.1"
|
||||
const AgentVersion = "0.15.0"
|
||||
|
||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
AllowRemote bool
|
||||
@@ -157,7 +157,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
cfg.NginxBinary = "nginx"
|
||||
}
|
||||
if strings.TrimSpace(cfg.CertsDir) == "" {
|
||||
cfg.CertsDir = "/var/lib/fonu-agent/certs"
|
||||
cfg.CertsDir = "/var/lib/havline-agent/certs"
|
||||
}
|
||||
if strings.TrimSpace(cfg.FrpsConfigPath) == "" {
|
||||
cfg.FrpsConfigPath = "/etc/frp/frps.toml"
|
||||
@@ -169,7 +169,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
cfg.FrpsBinary = "/usr/local/bin/" + filepath.Base(cfg.FrpsBinary)
|
||||
}
|
||||
if strings.TrimSpace(cfg.DataDir) == "" {
|
||||
cfg.DataDir = "/var/lib/fonu-agent"
|
||||
cfg.DataDir = "/var/lib/havline-agent"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
return nil, errors.New("agent token is required")
|
||||
@@ -201,7 +201,7 @@ func validateListenAddr(addr string, allowRemote bool) (bool, error) {
|
||||
loopback = ip.IsLoopback()
|
||||
}
|
||||
if !loopback && !allowRemote {
|
||||
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 FONU_AGENT_ALLOW_REMOTE=1", addr)
|
||||
return false, fmt.Errorf("检测到 Agent 将监听非回环地址 %s;默认安全模式只监听 127.0.0.1:7700。如确认需要远程监听,请设置 HAVLINE_AGENT_ALLOW_REMOTE=1", addr)
|
||||
}
|
||||
return loopback, nil
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func secureTokenEqual(got, want string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "havline-agent"})
|
||||
}
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -312,7 +312,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
||||
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": "fonu-agent",
|
||||
"service": "havline-agent",
|
||||
"agent_version": AgentVersion,
|
||||
"listen_addr": s.cfg.ListenAddr,
|
||||
"listen_scope": listenScope(s.listenLoopback),
|
||||
@@ -460,7 +460,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if writeUnit {
|
||||
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by fonu-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
|
||||
unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
|
||||
if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
|
||||
return
|
||||
@@ -624,7 +624,7 @@ func execCommandOutput(name string, args ...string) string {
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// dashboardAddr 已废弃:面板地址改由 Fonu 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
|
||||
// dashboardAddr 已废弃:面板地址改由 Havline 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
|
||||
func dashboardAddr(configPath string) string {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
@@ -754,7 +754,7 @@ func nginxBinaryPath(configured string) string {
|
||||
func detectNginxConfDir(binary string) (string, error) {
|
||||
nginx := nginxBinaryPath(binary)
|
||||
if nginx == "" {
|
||||
return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
|
||||
return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 HAVLINE_AGENT_NGINX_BIN 显式指定路径")
|
||||
}
|
||||
// 旧版 Agent 可能把 <配置>.versions 目录留在 include 范围内,先按常见目录清理,避免 nginx -T 直接失败。
|
||||
for _, dir := range []string{"/etc/nginx/sites-enabled", "/etc/nginx/conf.d", "/etc/nginx"} {
|
||||
@@ -802,8 +802,8 @@ func detectNginxConfDir(binary string) (string, error) {
|
||||
lastReason = fmt.Sprintf("%s: %v", dir, err)
|
||||
continue
|
||||
}
|
||||
probe := filepath.Join(dir, "fonu-probe.conf")
|
||||
probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n"
|
||||
probe := filepath.Join(dir, "havline-probe.conf")
|
||||
probeBody := "# Managed by havline-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name havline-probe.invalid;\n}\n"
|
||||
if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil {
|
||||
lastReason = fmt.Sprintf("%s: %v", dir, err)
|
||||
continue
|
||||
@@ -822,11 +822,11 @@ func detectNginxConfDir(binary string) (string, error) {
|
||||
continue
|
||||
}
|
||||
// 该目录不接受完整 server 块(片段目录):删除 probe 文件。
|
||||
// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
|
||||
// 若 -t 失败信息指向历史残留的 havline-*.conf,也一并清理(仅限 Agent 生成的文件)。
|
||||
_ = os.Remove(probe)
|
||||
if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil {
|
||||
lastReason = fmt.Sprintf("%s: nginx -t 失败:%s", dir, tailText(string(tOut), 300))
|
||||
cleanupLegacyFonuConf(string(tOut))
|
||||
cleanupLegacyHavlineConf(string(tOut))
|
||||
if err := runCommand(nginx, "-t"); err == nil {
|
||||
continue // 清理残留后配置恢复可用,换下一个目录
|
||||
}
|
||||
@@ -837,7 +837,7 @@ func detectNginxConfDir(binary string) (string, error) {
|
||||
if lastReason == "" {
|
||||
lastReason = "没有可尝试的目录"
|
||||
}
|
||||
return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);最后一次失败原因:%s;也可用 FONU_AGENT_NGINX_CONF_DIR 显式指定(Debian/Ubuntu 通常是 /etc/nginx/conf.d 或 /etc/nginx/sites-enabled)", len(dirs), strings.Join(dirs, ", "), lastReason)
|
||||
return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);最后一次失败原因:%s;也可用 HAVLINE_AGENT_NGINX_CONF_DIR 显式指定(Debian/Ubuntu 通常是 /etc/nginx/conf.d 或 /etc/nginx/sites-enabled)", len(dirs), strings.Join(dirs, ", "), lastReason)
|
||||
}
|
||||
|
||||
// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。
|
||||
@@ -857,10 +857,10 @@ func dirHasVhostConf(dir string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanupLegacyFonuConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 fonu- 前缀)。
|
||||
func cleanupLegacyFonuConf(nginxTestOutput string) {
|
||||
// cleanupLegacyHavlineConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 havline- 前缀)。
|
||||
func cleanupLegacyHavlineConf(nginxTestOutput string) {
|
||||
for _, line := range strings.Split(nginxTestOutput, "\n") {
|
||||
idx := strings.Index(line, "fonu-")
|
||||
idx := strings.Index(line, "havline-")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
@@ -878,7 +878,7 @@ func cleanupLegacyFonuConf(nginxTestOutput string) {
|
||||
if !strings.HasSuffix(path, ".conf") {
|
||||
continue
|
||||
}
|
||||
if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
|
||||
if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by havline-agent.") {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
@@ -905,10 +905,10 @@ func (s *Server) routeDetails() []map[string]any {
|
||||
}
|
||||
details := []map[string]any{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "havline-") || !strings.HasSuffix(entry.Name(), ".conf") {
|
||||
continue
|
||||
}
|
||||
domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf")
|
||||
domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "havline-"), ".conf")
|
||||
path := filepath.Join(s.cfg.NginxConfDir, entry.Name())
|
||||
item := map[string]any{
|
||||
"domain": domain,
|
||||
@@ -935,7 +935,7 @@ type routeRequest struct {
|
||||
TLS bool `json:"tls"`
|
||||
CertDir string `json:"cert_dir,omitempty"`
|
||||
WebSocket bool `json:"websocket,omitempty"`
|
||||
VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Fonu 传入本次部署的 frps vhost 端口
|
||||
VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Havline 传入本次部署的 frps vhost 端口
|
||||
RedirectTLS bool `json:"redirect_https,omitempty"` // HTTP 80 强制跳转 HTTPS
|
||||
AllowIPs []string `json:"allow_ips,omitempty"` // IP 白名单;空则不限制
|
||||
BasicAuthUser string `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成)
|
||||
@@ -1143,7 +1143,7 @@ type nginxInstallResult struct {
|
||||
// 需要 agent 以 root 运行(systemd 单元默认如此)。
|
||||
func (s *Server) installNginx(w http.ResponseWriter, r *http.Request) {
|
||||
if os.Geteuid() != 0 {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 fonu-agent"})
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 havline-agent"})
|
||||
return
|
||||
}
|
||||
if probe := s.probeNginx(); probe.OK {
|
||||
@@ -1196,7 +1196,7 @@ type chinaCIDRRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// putChinaCIDR 接收 Fonu 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
|
||||
// putChinaCIDR 接收 Havline 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
|
||||
func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
|
||||
var req chinaCIDRRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&req); err != nil {
|
||||
@@ -1226,7 +1226,7 @@ func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
geo := "# Managed by fonu-agent. Do not edit.\ngeo $fonu_china_client {\n default 0;\n include " + chinaListPath() + ";\n}\n"
|
||||
geo := "# Managed by havline-agent. Do not edit.\ngeo $havline_china_client {\n default 0;\n include " + chinaListPath() + ";\n}\n"
|
||||
if err := atomicWrite(s.chinaGeoFilePath(), []byte(geo), 0o640); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -1256,7 +1256,7 @@ func (s *Server) writeRoute(req routeRequest) error {
|
||||
if certDir == "" {
|
||||
certDir = filepath.Join(s.cfg.CertsDir, req.Domain)
|
||||
}
|
||||
// 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 fonuagent-china.conf 中定义)
|
||||
// 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 havlineagent-china.conf 中定义)
|
||||
if req.ChinaOnly {
|
||||
if _, err := os.Stat(chinaListPath()); err != nil {
|
||||
return fmt.Errorf("启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)")
|
||||
@@ -1265,7 +1265,7 @@ func (s *Server) writeRoute(req routeRequest) error {
|
||||
content := nginxRoute(req, certDir)
|
||||
path := s.routePath(req.Domain)
|
||||
// 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对,
|
||||
// 非当前目录但内容带 Agent 标记的 fonu-<domain>.conf 一律删除(不碰宝塔自己的配置)。
|
||||
// 非当前目录但内容带 Agent 标记的 havline-<domain>.conf 一律删除(不碰宝塔自己的配置)。
|
||||
if nginx := s.nginxPath(); nginx != "" {
|
||||
if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
|
||||
for _, line := range strings.Split(string(tOut), "\n") {
|
||||
@@ -1273,8 +1273,8 @@ func (s *Server) writeRoute(req routeRequest) error {
|
||||
if value, found := strings.CutPrefix(line, "# configuration file "); found {
|
||||
p := strings.TrimSuffix(strings.TrimSpace(value), ":")
|
||||
base := filepath.Base(p)
|
||||
if p != path && base == "fonu-"+req.Domain+".conf" {
|
||||
if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
|
||||
if p != path && base == "havline-"+req.Domain+".conf" {
|
||||
if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by havline-agent.") {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
@@ -1299,17 +1299,17 @@ func (s *Server) routeNames() []string {
|
||||
}
|
||||
var result []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "havline-") || !strings.HasSuffix(entry.Name(), ".conf") {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf"))
|
||||
result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "havline-"), ".conf"))
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) routePath(domain string) string {
|
||||
return filepath.Join(s.cfg.NginxConfDir, "fonu-"+domain+".conf")
|
||||
return filepath.Join(s.cfg.NginxConfDir, "havline-"+domain+".conf")
|
||||
}
|
||||
|
||||
func (s *Server) validateNginx() error {
|
||||
@@ -1380,7 +1380,7 @@ func migrateVersionDirsFromNginxOutput(output string) bool {
|
||||
func (s *Server) reloadNginx() error {
|
||||
nginx := s.nginxPath()
|
||||
if nginx == "" {
|
||||
return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 FONU_AGENT_NGINX_BIN 指定路径")
|
||||
return errors.New("未找到 nginx 可执行文件:请先在「公网反代」页安装 Nginx,或用 HAVLINE_AGENT_NGINX_BIN 指定路径")
|
||||
}
|
||||
return runCommand(nginx, "-s", "reload")
|
||||
}
|
||||
@@ -1530,7 +1530,7 @@ func validDomain(value string) bool {
|
||||
|
||||
func nginxRoute(req routeRequest, certDir string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Managed by fonu-agent. Do not edit.\n")
|
||||
b.WriteString("# Managed by havline-agent. Do not edit.\n")
|
||||
// HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为)
|
||||
tlsEnabled := req.TLS && !req.HTTPSDisabled
|
||||
if req.RedirectTLS && tlsEnabled {
|
||||
@@ -1557,7 +1557,7 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
Enabled: req.SecurityHeaders,
|
||||
HTTPS: tlsEnabled,
|
||||
}))
|
||||
// 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
|
||||
// 限流/连接数的 zone 定义在 havlineagent-limits.conf(http 上下文),此处只声明状态码
|
||||
if req.RateLimitRate > 0 {
|
||||
b.WriteString(" limit_req_status 429;\n")
|
||||
}
|
||||
@@ -1572,7 +1572,7 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
// 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。
|
||||
chinaDeny := ""
|
||||
if req.ChinaOnly {
|
||||
chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n"
|
||||
chinaDeny = " if ($havline_china_client = 0) { return 403; }\n"
|
||||
}
|
||||
aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{
|
||||
Allow: req.AllowIPs,
|
||||
@@ -1585,15 +1585,15 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
|
||||
if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
|
||||
if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
|
||||
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||
authLine = fmt.Sprintf(" auth_basic \"Havline Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||
}
|
||||
} else if _, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
|
||||
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||
authLine = fmt.Sprintf(" auth_basic \"Havline Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
|
||||
}
|
||||
}
|
||||
// 限流 / 连接数(zone 在 http 上下文定义)
|
||||
limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{
|
||||
Zone: nginxtmpl.ZoneName("fonu", req.Domain),
|
||||
Zone: nginxtmpl.ZoneName("havline", req.Domain),
|
||||
Rate: req.RateLimitRate,
|
||||
Burst: req.RateLimitBurst,
|
||||
Conn: req.ConnLimitMax,
|
||||
@@ -1603,21 +1603,21 @@ func nginxRoute(req routeRequest, certDir string) string {
|
||||
}
|
||||
|
||||
// agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。
|
||||
const agentStateDir = "/var/lib/fonu-agent"
|
||||
const agentStateDir = "/var/lib/havline-agent"
|
||||
|
||||
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
|
||||
func zoneKey(domain string) string {
|
||||
return nginxtmpl.ZoneName("fonu", domain)
|
||||
return nginxtmpl.ZoneName("havline", domain)
|
||||
}
|
||||
|
||||
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
|
||||
func (s *Server) limitsFilePath() string {
|
||||
return filepath.Join(s.cfg.NginxConfDir, "fonuagent-limits.conf")
|
||||
return filepath.Join(s.cfg.NginxConfDir, "havlineagent-limits.conf")
|
||||
}
|
||||
|
||||
// chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。
|
||||
func (s *Server) chinaGeoFilePath() string {
|
||||
return filepath.Join(s.cfg.NginxConfDir, "fonuagent-china.conf")
|
||||
return filepath.Join(s.cfg.NginxConfDir, "havlineagent-china.conf")
|
||||
}
|
||||
|
||||
// chinaListPath 返回中国 IP 段文件(geo include 格式:<cidr> 1;)。
|
||||
@@ -1678,7 +1678,7 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error {
|
||||
return nil
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Managed by fonu-agent. Do not edit.\n")
|
||||
b.WriteString("# Managed by havline-agent. Do not edit.\n")
|
||||
domains := make([]string, 0, len(state))
|
||||
for domain := range state {
|
||||
domains = append(domains, domain)
|
||||
@@ -1744,7 +1744,7 @@ func writeBasicAuthSecret(domain, user, password string) error {
|
||||
|
||||
// basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。
|
||||
func basicAuthFilePath(domain string) string {
|
||||
return filepath.Join("/var/lib/fonu-agent", "basicauth", domain+".htpasswd")
|
||||
return filepath.Join("/var/lib/havline-agent", "basicauth", domain+".htpasswd")
|
||||
}
|
||||
|
||||
// ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。
|
||||
|
||||
@@ -17,14 +17,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/nginxtmpl"
|
||||
)
|
||||
|
||||
const (
|
||||
httpsProxyConfigName = "fonuagent-https.conf"
|
||||
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt"
|
||||
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key"
|
||||
httpsProxyConfigName = "havlineagent-https.conf"
|
||||
httpsProxyCertPath = "/etc/havline-agent/tls/agent.crt"
|
||||
httpsProxyKeyPath = "/etc/havline-agent/tls/agent.key"
|
||||
)
|
||||
|
||||
type httpsProxyRequest struct {
|
||||
@@ -119,7 +119,7 @@ func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsDir := "/etc/fonu-agent/tls"
|
||||
tlsDir := "/etc/havline-agent/tls"
|
||||
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func (s *Server) ensureNginxDir() (string, error) {
|
||||
}
|
||||
|
||||
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
||||
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
|
||||
return fmt.Sprintf(`# Managed by havline-agent. Do not edit.
|
||||
server {
|
||||
listen %d ssl default_server;
|
||||
server_name _;
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
)
|
||||
|
||||
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fonuagent-https.conf")
|
||||
path := filepath.Join(dir, "havlineagent-https.conf")
|
||||
legacy := path + ".versions"
|
||||
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -37,7 +37,7 @@ func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧版本目录应被移出 nginx include 范围:%v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".fonuagent-https.conf.versions"))
|
||||
entries, err := os.ReadDir(filepath.Join(dir, ".havlineagent-https.conf.versions"))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("隐藏版本目录应保留 1 个版本:%v %v", entries, err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestMigrateLegacyVersionDirs(t *testing.T) {
|
||||
|
||||
func TestMigrateVersionDirsFromNginxOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "fonuagent-https.conf.versions")
|
||||
legacy := filepath.Join(dir, "havlineagent-https.conf.versions")
|
||||
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestCertificatePin(t *testing.T) {
|
||||
|
||||
func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fonu-a.example.com.conf")
|
||||
path := filepath.Join(dir, "havline-a.example.com.conf")
|
||||
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func TestApplyRouteContentSavesPreviousVersion(t *testing.T) {
|
||||
|
||||
func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fonu-a.example.com.conf")
|
||||
path := filepath.Join(dir, "havline-a.example.com.conf")
|
||||
if err := os.WriteFile(path, []byte("old config"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestApplyRouteContentRestoresOnValidationFailure(t *testing.T) {
|
||||
|
||||
func TestApplyRouteContentRemovesNewFileOnValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fonu-new.example.com.conf")
|
||||
path := filepath.Join(dir, "havline-new.example.com.conf")
|
||||
server := &Server{
|
||||
cfg: Config{NginxConfDir: dir},
|
||||
validateNginxFn: func() error { return errors.New("nginx rejected") },
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
@@ -168,7 +168,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Name: "havline_session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
@@ -197,7 +197,7 @@ func clientIP(r *http.Request) string {
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Name: "havline_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
|
||||
@@ -10,19 +10,19 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
func TestAuthSetupAndLoginFlow(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestAuthSetupAndLoginFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
cookie := loginRec.Result().Cookies()[0]
|
||||
if cookie.Name != "fonu_session" || cookie.HttpOnly == false {
|
||||
if cookie.Name != "havline_session" || cookie.HttpOnly == false {
|
||||
t.Fatal("expected httponly session cookie")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
@@ -52,7 +52,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
defer file.Close()
|
||||
|
||||
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
|
||||
out, err := os.CreateTemp("", "fonu-restore-*.tar.gz")
|
||||
out, err := os.CreateTemp("", "havline-restore-*.tar.gz")
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
|
||||
return
|
||||
@@ -72,7 +72,7 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(tmpPath)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
|
||||
}
|
||||
|
||||
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
|
||||
@@ -150,7 +150,7 @@ func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
|
||||
}
|
||||
|
||||
// DeleteArchive 删除指定备份
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
"github.com/havline/havline/internal/acme"
|
||||
)
|
||||
|
||||
type CertHandler struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/havline/havline/internal/chinacidr"
|
||||
)
|
||||
|
||||
type ChinaCIDRHandler struct {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
)
|
||||
|
||||
type CloudflareHandler struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
)
|
||||
|
||||
type DDNSHandler struct {
|
||||
|
||||
+15
-15
@@ -4,21 +4,21 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
"github.com/havline/havline/internal/acme"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/chinacidr"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/metrics"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
)
|
||||
|
||||
type DiscoveryHandler struct {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
)
|
||||
|
||||
// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/logstore"
|
||||
)
|
||||
|
||||
type LogsHandler struct {
|
||||
|
||||
@@ -9,20 +9,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
func TestLogsAccessRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(dir, "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestLogsAccessRoute(t *testing.T) {
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "fonu_session", Value: sessionID})
|
||||
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/havline/havline/internal/metrics"
|
||||
)
|
||||
|
||||
type MetricsHandler struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -15,7 +15,7 @@ const sessionContextKey contextKey = "session_id"
|
||||
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("fonu_session")
|
||||
cookie, err := r.Cookie("havline_session")
|
||||
if err == nil && cookie.Value != "" {
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/havline/havline/internal/service"
|
||||
)
|
||||
|
||||
type NginxHandler struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
)
|
||||
|
||||
type NotifyHandler struct {
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/logstore"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
)
|
||||
|
||||
type ProxyHandler struct {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
)
|
||||
|
||||
type securityRequest struct {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
)
|
||||
|
||||
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
|
||||
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||
@@ -26,7 +26,7 @@ var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
|
||||
func TestProtectedRoutesRequireAuth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(dir, "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
certstore "github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
"github.com/havline/havline/internal/acme"
|
||||
certstore "github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/logstore"
|
||||
"github.com/havline/havline/internal/publicip"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
type StatusHandler struct {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
|
||||
@@ -99,7 +99,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
payload := statusPagePayload{
|
||||
Title: "Fonu 服务状态",
|
||||
Title: "Havline 服务状态",
|
||||
CodeRequired: codeRequired,
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Routes: make([]statusPageRoute, 0),
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
)
|
||||
|
||||
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
|
||||
|
||||
@@ -3,7 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
type UpdateHandler struct {
|
||||
|
||||
@@ -3,14 +3,14 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/version"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/version"
|
||||
)
|
||||
|
||||
type versionResponse struct {
|
||||
Version string `json:"version"`
|
||||
NginxHTTPPort int `json:"nginx_http_port"`
|
||||
NginxHTTPSPort int `json:"nginx_https_port"`
|
||||
Version string `json:"version"`
|
||||
NginxHTTPPort int `json:"nginx_http_port"`
|
||||
NginxHTTPSPort int `json:"nginx_https_port"`
|
||||
}
|
||||
|
||||
func Version(cfg config.Config) http.HandlerFunc {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/version"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/version"
|
||||
)
|
||||
|
||||
func TestVersionEndpoint(t *testing.T) {
|
||||
|
||||
+24
-24
@@ -11,30 +11,30 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
"github.com/fonu/fonu/internal/api"
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/chinacidr"
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/scheduler"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/fonu/fonu/internal/updater"
|
||||
"github.com/havline/havline/internal/acme"
|
||||
"github.com/havline/havline/internal/api"
|
||||
"github.com/havline/havline/internal/auth"
|
||||
"github.com/havline/havline/internal/backup"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/chinacidr"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/ddns"
|
||||
"github.com/havline/havline/internal/discovery"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/logstore"
|
||||
"github.com/havline/havline/internal/metrics"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
"github.com/havline/havline/internal/nginx"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/scheduler"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/service"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
"github.com/havline/havline/internal/updater"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fonu/fonu/internal/cloudflared"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
"github.com/havline/havline/internal/cloudflared"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
"github.com/havline/havline/internal/traffic"
|
||||
)
|
||||
|
||||
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
|
||||
|
||||
@@ -3,8 +3,8 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/havline/havline/internal/frp"
|
||||
"github.com/havline/havline/internal/monitor"
|
||||
)
|
||||
|
||||
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
// 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
|
||||
// archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。
|
||||
// List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。
|
||||
var archiveNamePattern = regexp.MustCompile(`^fonu-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
|
||||
var archiveNamePattern = regexp.MustCompile(`^havline-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
|
||||
|
||||
// Archive 留在磁盘上的一份备份
|
||||
type Archive struct {
|
||||
@@ -104,7 +104,7 @@ func (s *Service) Remove(name string) error {
|
||||
}
|
||||
|
||||
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
|
||||
// 同时要求包内必须有 fonu.db(否则多半不是 Fonu 的备份)。
|
||||
// 同时要求包内必须有 havline.db(否则多半不是 Havline 的备份)。
|
||||
func (s *Service) Verify(ctx context.Context, path string) (int, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -133,12 +133,12 @@ func (s *Service) Verify(ctx context.Context, path string) (int, error) {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
if header.Name == "fonu.db" {
|
||||
if header.Name == "havline.db" {
|
||||
hasDatabase = true
|
||||
}
|
||||
}
|
||||
if !hasDatabase {
|
||||
return count, fmt.Errorf("备份里没有 fonu.db,可能不是 Fonu 的备份")
|
||||
return count, fmt.Errorf("备份里没有 havline.db,可能不是 Havline 的备份")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
// AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestDueForBackup(t *testing.T) {
|
||||
|
||||
func TestExportVerifyRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "fonu.db"), []byte("db"), 0o600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "havline.db"), []byte("db"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(dir)
|
||||
@@ -62,7 +62,7 @@ func TestExportVerifyRoundTrip(t *testing.T) {
|
||||
|
||||
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||
svc := New(t.TempDir())
|
||||
path := filepath.Join(svc.backupsDir(), "fonu-backup-20260925-000000-1.tar.gz")
|
||||
path := filepath.Join(svc.backupsDir(), "havline-backup-20260925-000000-1.tar.gz")
|
||||
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -80,9 +80,9 @@ func TestListPruneAndPath(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"fonu-backup-20260923-100000-1.tar.gz",
|
||||
"fonu-backup-20260924-100000-2.tar.gz",
|
||||
"fonu-backup-20260925-100000-3.tar.gz",
|
||||
"havline-backup-20260923-100000-1.tar.gz",
|
||||
"havline-backup-20260924-100000-2.tar.gz",
|
||||
"havline-backup-20260925-100000-3.tar.gz",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -100,7 +100,7 @@ func TestListPruneAndPath(t *testing.T) {
|
||||
if len(archives) != 3 {
|
||||
t.Fatalf("应只列出 3 份备份,实际 %d", len(archives))
|
||||
}
|
||||
if archives[0].Name != "fonu-backup-20260925-100000-3.tar.gz" {
|
||||
if archives[0].Name != "havline-backup-20260925-100000-3.tar.gz" {
|
||||
t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name)
|
||||
}
|
||||
|
||||
@@ -116,12 +116,12 @@ func TestListPruneAndPath(t *testing.T) {
|
||||
}
|
||||
|
||||
// 名称校验:不能让备份名把读写带出备份目录
|
||||
for _, name := range []string{"../fonu.db", "notes.txt", "", "fonu-backup-x.tar.gz"} {
|
||||
for _, name := range []string{"../havline.db", "notes.txt", "", "havline-backup-x.tar.gz"} {
|
||||
if _, err := svc.Path(name); err == nil {
|
||||
t.Fatalf("非法备份名应拒绝:%q", name)
|
||||
}
|
||||
}
|
||||
if err := svc.Remove("fonu-backup-20260920-100000-9.tar.gz"); err == nil {
|
||||
if err := svc.Remove("havline-backup-20260920-100000-9.tar.gz"); err == nil {
|
||||
t.Fatal("删除不存在的备份应报错")
|
||||
}
|
||||
if _, err := svc.Prune(0); err != nil {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *Service) Export(ctx context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
|
||||
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("fonu-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
|
||||
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("havline-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
|
||||
|
||||
gw := gzip.NewWriter(file)
|
||||
tw := tar.NewWriter(gw)
|
||||
for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} {
|
||||
for _, entry := range []string{"havline.db", "certs", "nginx", "cloudflared/tunnels"} {
|
||||
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
||||
discard()
|
||||
return err
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/nginx"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -228,7 +228,7 @@ func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, targe
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
|
||||
// DeleteDNSRecord 删除一条明确由 Havline 接管的 DNS 记录。
|
||||
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
|
||||
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
)
|
||||
|
||||
type tokenPayload struct {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDecodeToken(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/havline/havline/internal/config"
|
||||
)
|
||||
|
||||
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
|
||||
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
|
||||
const (
|
||||
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Fonu 生成
|
||||
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Havline 生成
|
||||
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
|
||||
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
|
||||
)
|
||||
|
||||
@@ -259,7 +259,7 @@ func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
|
||||
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
|
||||
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Havline 接管的 DNS 记录"}
|
||||
}
|
||||
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/validate"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/validate"
|
||||
)
|
||||
|
||||
// RouteView 是配置页展示的一条 ingress 路由。
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
)
|
||||
|
||||
const defaultOriginService = "http://127.0.0.1:80"
|
||||
@@ -525,7 +525,7 @@ func (s *Service) restartIfRunning(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Fonu 自己接管的记录。
|
||||
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Havline 自己接管的记录。
|
||||
func (s *Service) syncTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, remoteTunnelID string, hostnames []string) error {
|
||||
desired := make(map[string]struct{}, len(hostnames))
|
||||
for _, raw := range hostnames {
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/db"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
)
|
||||
|
||||
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
|
||||
|
||||
@@ -112,7 +112,7 @@ type settingsRow struct {
|
||||
Network NetworkSettings
|
||||
}
|
||||
|
||||
// DNSRecord 是 Fonu 自动创建或接管的 Cloudflare DNS 记录。
|
||||
// DNSRecord 是 Havline 自动创建或接管的 Cloudflare DNS 记录。
|
||||
type DNSRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
TunnelID int64 `json:"tunnel_id"`
|
||||
|
||||
+13
-13
@@ -25,22 +25,22 @@ type Config struct {
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
dataDir := envOr("FONU_DATA_DIR", defaultDataDir())
|
||||
dataDir := envOr("HAVLINE_DATA_DIR", defaultDataDir())
|
||||
if abs, err := filepath.Abs(dataDir); err == nil {
|
||||
dataDir = abs
|
||||
}
|
||||
return Config{
|
||||
ListenAddr: envOr("FONU_LISTEN", ":6893"),
|
||||
ListenAddr: envOr("HAVLINE_LISTEN", ":6893"),
|
||||
DataDir: dataDir,
|
||||
SessionSecret: envOr("FONU_SESSION_SECRET", ""),
|
||||
NginxBin: envOr("FONU_NGINX_BIN", "nginx"),
|
||||
NginxPIDFile: envOr("FONU_NGINX_PID", dataDir+"/nginx/nginx.pid"),
|
||||
NginxMimeTypes: envOr("FONU_NGINX_MIME_TYPES", defaultMimeTypes()),
|
||||
NginxDefaultHTTPPort: envIntOr("FONU_NGINX_HTTP_PORT", 80),
|
||||
NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"),
|
||||
SessionSecret: envOr("HAVLINE_SESSION_SECRET", ""),
|
||||
NginxBin: envOr("HAVLINE_NGINX_BIN", "nginx"),
|
||||
NginxPIDFile: envOr("HAVLINE_NGINX_PID", dataDir+"/nginx/nginx.pid"),
|
||||
NginxMimeTypes: envOr("HAVLINE_NGINX_MIME_TYPES", defaultMimeTypes()),
|
||||
NginxDefaultHTTPPort: envIntOr("HAVLINE_NGINX_HTTP_PORT", 80),
|
||||
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
||||
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
||||
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (c Config) DefaultListenPort(httpsEnabled bool) int {
|
||||
}
|
||||
|
||||
func (c Config) DBPath() string {
|
||||
return c.DataDir + "/fonu.db"
|
||||
return c.DataDir + "/havline.db"
|
||||
}
|
||||
|
||||
func (c Config) NginxDir() string {
|
||||
@@ -128,7 +128,7 @@ func (c Config) FrpLogPath() string {
|
||||
return filepath.Join(c.LogsDir(), "frpc.log")
|
||||
}
|
||||
|
||||
// LegacySessionSecret 是历史版本在未配置 FONU_SESSION_SECRET 时使用的固定值。
|
||||
// LegacySessionSecret 是历史版本在未配置 HAVLINE_SESSION_SECRET 时使用的固定值。
|
||||
// 它同时是 secret box 的加密密钥,所以只提醒、不自动替换:
|
||||
// 换掉它会让库里已加密的凭据(frp token、通知密码等)再也解不开。
|
||||
const LegacySessionSecret = "change-me-in-production"
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
)
|
||||
|
||||
func TestLoadNginxDefaultPorts(t *testing.T) {
|
||||
t.Setenv("FONU_NGINX_HTTP_PORT", "18080")
|
||||
t.Setenv("FONU_NGINX_HTTPS_PORT", "9443")
|
||||
t.Setenv("HAVLINE_NGINX_HTTP_PORT", "18080")
|
||||
t.Setenv("HAVLINE_NGINX_HTTPS_PORT", "9443")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 18080 {
|
||||
@@ -25,8 +25,8 @@ func TestLoadNginxDefaultPorts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
||||
os.Unsetenv("FONU_NGINX_HTTP_PORT")
|
||||
os.Unsetenv("FONU_NGINX_HTTPS_PORT")
|
||||
os.Unsetenv("HAVLINE_NGINX_HTTP_PORT")
|
||||
os.Unsetenv("HAVLINE_NGINX_HTTPS_PORT")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.NginxDefaultHTTPPort != 80 || cfg.NginxDefaultHTTPSPort != 443 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := Open(filepath.Join(dir, "fonu.db"))
|
||||
conn, err := Open(filepath.Join(dir, "havline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/publicip"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
maskedToken = "********"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
HasAuthToken bool `json:"has_auth_token"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Enabled bool
|
||||
ServerAddr string
|
||||
ServerPort int
|
||||
AuthToken string
|
||||
TLSEnabled bool
|
||||
CustomDomains []string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
settings *settings.Store
|
||||
secretBox *secret.Box
|
||||
}
|
||||
|
||||
func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
|
||||
return &Store{settings: settingsStore, secretBox: secretBox}
|
||||
}
|
||||
|
||||
func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
enabled, _ := s.settings.GetBool(ctx, settings.KeyFRPEnabled)
|
||||
serverAddr, _ := s.settings.Get(ctx, settings.KeyFRPServerAddr)
|
||||
serverPort, _ := s.settings.GetInt(ctx, settings.KeyFRPServerPort)
|
||||
if serverPort <= 0 {
|
||||
serverPort = 7000
|
||||
}
|
||||
tlsEnabled, _ := s.settings.GetBool(ctx, settings.KeyFRPTLSEnabled)
|
||||
domains, err := s.loadDomains(ctx)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
encToken, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
hasToken := strings.TrimSpace(encToken) != ""
|
||||
token := ""
|
||||
if hasToken {
|
||||
token = maskedToken
|
||||
}
|
||||
return Config{
|
||||
Enabled: enabled,
|
||||
ServerAddr: serverAddr,
|
||||
ServerPort: serverPort,
|
||||
AuthToken: token,
|
||||
HasAuthToken: hasToken,
|
||||
TLSEnabled: tlsEnabled,
|
||||
CustomDomains: domains,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadRuntime(ctx context.Context) (Config, string, error) {
|
||||
cfg, err := s.Load(ctx)
|
||||
if err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
if !cfg.HasAuthToken {
|
||||
return cfg, "", nil
|
||||
}
|
||||
enc, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
plain, err := s.secretBox.Decrypt(enc)
|
||||
if err != nil {
|
||||
return cfg, "", secret.DecryptHint(err)
|
||||
}
|
||||
return cfg, plain, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(ctx context.Context, in SaveInput, keepToken bool) error {
|
||||
if err := validateSaveInput(in, keepToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPEnabled, in.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPServerAddr, normalizeServerAddr(in.ServerAddr)); err != nil {
|
||||
return err
|
||||
}
|
||||
port := in.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyFRPServerPort, port); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPTLSEnabled, in.TLSEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
domains := normalizeDomains(in.CustomDomains)
|
||||
raw, err := json.Marshal(domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPCustomDomains, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token != "" && token != maskedToken {
|
||||
enc, err := s.secretBox.Encrypt(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPAuthToken, enc); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if in.Enabled && !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetLastError(ctx context.Context, msg string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPLastError, strings.TrimSpace(msg))
|
||||
}
|
||||
|
||||
func (s *Store) LastError(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPLastError)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) StartedAt(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPStartedAt)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) SetStartedAt(ctx context.Context, value string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPStartedAt, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func (s *Store) loadDomains(ctx context.Context) ([]string, error) {
|
||||
raw, _ := s.settings.Get(ctx, settings.KeyFRPCustomDomains)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err != nil {
|
||||
return nil, fmt.Errorf("FRP 域名配置无效")
|
||||
}
|
||||
return normalizeDomains(domains), nil
|
||||
}
|
||||
|
||||
func normalizeServerAddr(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
addr = strings.TrimPrefix(addr, "https://")
|
||||
addr = strings.TrimPrefix(addr, "http://")
|
||||
if idx := strings.IndexAny(addr, "/?#"); idx >= 0 {
|
||||
addr = addr[:idx]
|
||||
}
|
||||
return strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
func normalizeDomains(domains []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, d := range domains {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" || seen[strings.ToLower(d)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(d)] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput, keepToken bool) error {
|
||||
if !in.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(in.ServerAddr) == "" {
|
||||
return fmt.Errorf("请填写 FRP 服务器地址")
|
||||
}
|
||||
if in.ServerPort < 0 || in.ServerPort > 65535 {
|
||||
return fmt.Errorf("FRP 服务器端口无效")
|
||||
}
|
||||
if len(normalizeDomains(in.CustomDomains)) == 0 {
|
||||
return fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token == "" || token == maskedToken {
|
||||
if !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeServerAddr(t *testing.T) {
|
||||
if got := normalizeServerAddr("http://127.0.0.1"); got != "127.0.0.1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := normalizeServerAddr("https://frp.example.com/path"); got != "frp.example.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func Generate(cfg config.Config, frpCfg Config, authToken string, httpPort, httpsPort int) (string, error) {
|
||||
if strings.TrimSpace(frpCfg.ServerAddr) == "" {
|
||||
return "", fmt.Errorf("FRP 服务器地址未配置")
|
||||
}
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return "", fmt.Errorf("FRP 认证 Token 未配置")
|
||||
}
|
||||
domains := normalizeDomains(frpCfg.CustomDomains)
|
||||
if len(domains) == 0 {
|
||||
return "", fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
if httpPort <= 0 || httpsPort <= 0 {
|
||||
return "", fmt.Errorf("Nginx 端口未配置")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("serverAddr = %q\n", frpCfg.ServerAddr))
|
||||
b.WriteString(fmt.Sprintf("serverPort = %d\n\n", frpCfg.ServerPort))
|
||||
|
||||
b.WriteString(fmt.Sprintf("log.to = %q\n", tomlPath(cfg.FrpLogPath())))
|
||||
b.WriteString("log.level = \"info\"\n\n")
|
||||
|
||||
b.WriteString("auth.method = \"token\"\n")
|
||||
b.WriteString(fmt.Sprintf("auth.token = %q\n\n", authToken))
|
||||
|
||||
if frpCfg.TLSEnabled {
|
||||
b.WriteString("transport.tls.enable = true\n\n")
|
||||
} else {
|
||||
b.WriteString("transport.tls.enable = false\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-http\"\n")
|
||||
b.WriteString("type = \"http\"\n")
|
||||
b.WriteString("localIP = \"127.0.0.1\"\n")
|
||||
b.WriteString(fmt.Sprintf("localPort = %d\n", httpPort))
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n\n", formatTOMLStringArray(domains)))
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-https\"\n")
|
||||
b.WriteString("type = \"https\"\n")
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n", formatTOMLStringArray(domains)))
|
||||
b.WriteString("[proxies.plugin]\n")
|
||||
b.WriteString("type = \"https2https\"\n")
|
||||
b.WriteString(fmt.Sprintf("localAddr = \"127.0.0.1:%d\"\n", httpsPort))
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func tomlPath(path string) string {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
||||
func formatTOMLStringArray(items []string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateNginxGateway(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
DataDir: "/data",
|
||||
NginxDefaultHTTPPort: 18080,
|
||||
NginxDefaultHTTPSPort: 9443,
|
||||
FrpPIDFile: "/data/frp/frpc.pid",
|
||||
}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
TLSEnabled: true,
|
||||
CustomDomains: []string{"nas.example.com", "*.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `serverAddr = "vps.example.com"`) {
|
||||
t.Fatalf("missing server addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localPort = 18080`) {
|
||||
t.Fatalf("missing http local port: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `type = "https2https"`) {
|
||||
t.Fatalf("missing https plugin: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localAddr = "127.0.0.1:9443"`) {
|
||||
t.Fatalf("missing https local addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = true`) {
|
||||
t.Fatalf("missing tls enable: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDisablesTLSByDefault(t *testing.T) {
|
||||
cfg := config.Config{DataDir: "/data", FrpPIDFile: "/data/frp/frpc.pid"}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
CustomDomains: []string{"nas.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = false`) {
|
||||
t.Fatalf("expected tls disabled: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReadLogTail(path string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
|
||||
return []string{}, nil
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
const (
|
||||
frpcCmdTimeout = 8 * time.Second
|
||||
frpcStartWait = 5 * time.Second
|
||||
frpcTerminateWait = 3 * time.Second
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg config.Config
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
|
||||
return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureDirs() error {
|
||||
return os.MkdirAll(m.cfg.FrpDir(), 0o755)
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
|
||||
existing, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
|
||||
if err := m.store.Save(ctx, in, keepToken); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.clearStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已停用"}, nil
|
||||
}
|
||||
|
||||
runtimeCfg, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
tmpPath := m.cfg.FrpConfigPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.verifyConfig(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !m.available() {
|
||||
msg := "frpc 未安装,配置已保存"
|
||||
_ = m.store.SetLastError(ctx, msg)
|
||||
return ApplyResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
running, _ := m.isRunning()
|
||||
if !running {
|
||||
if err := m.start(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已启动"}, nil
|
||||
}
|
||||
|
||||
if err := m.reload(ctx); err != nil {
|
||||
m.logger.Warn("frpc reload failed, trying restart", "error", err)
|
||||
if err := m.forceRestart(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重新启动"}, nil
|
||||
}
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重载"}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Bootstrap(ctx context.Context) error {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !m.available() {
|
||||
m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
|
||||
return nil
|
||||
}
|
||||
_, err = m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: cfg.CustomDomains,
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Warn("initial frpc apply skipped", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context) (Config, error) {
|
||||
return m.store.Load(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Status(ctx context.Context) Status {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{Message: "读取配置失败"}
|
||||
}
|
||||
return BuildStatus(
|
||||
m.cfg,
|
||||
cfg,
|
||||
cfg.Enabled,
|
||||
m.store.LastError(ctx),
|
||||
m.store.StartedAt(ctx),
|
||||
ResolveClientVersion(m.cfg.FrpcBin),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
merged := MergeDomains(cfg.CustomDomains, domains)
|
||||
return m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: merged,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
|
||||
return ReadLogTail(m.cfg.FrpLogPath(), limit)
|
||||
}
|
||||
|
||||
func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := cfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPPort() int {
|
||||
return m.cfg.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPSPort() int {
|
||||
return m.cfg.NginxDefaultHTTPSPort
|
||||
}
|
||||
|
||||
func (m *Manager) verifyConfig(ctx context.Context) error {
|
||||
if !m.available() {
|
||||
return nil
|
||||
}
|
||||
return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
|
||||
}
|
||||
|
||||
func (m *Manager) markStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (m *Manager) clearStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, "")
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if !m.isRunningQuick() {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
_ = terminateProcess(pid, syscall.SIGTERM)
|
||||
waitProcessExit(pid, frpcTerminateWait)
|
||||
if nginx.IsPIDAlive(pid) {
|
||||
_ = terminateProcess(pid, syscall.SIGKILL)
|
||||
waitProcessExit(pid, time.Second)
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
m.clearStarted(ctx)
|
||||
m.logger.Info("frpc stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) available() bool {
|
||||
_, err := exec.LookPath(m.cfg.FrpcBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context) error {
|
||||
if m.isRunningQuick() {
|
||||
return m.reload(ctx)
|
||||
}
|
||||
args := []string{"-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reload(ctx context.Context) error {
|
||||
args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc reloaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) forceRestart(ctx context.Context) error {
|
||||
_ = m.Stop(ctx)
|
||||
return m.start(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
msg = "操作超时"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
|
||||
cmd := exec.Command(m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
|
||||
deadline := time.Now().Add(frpcStartWait)
|
||||
for time.Now().Before(deadline) {
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
m.logger.Warn("frpc exited during start", "error", msg)
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, "操作超时")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), nil)
|
||||
if msg == "" {
|
||||
msg = "未检测到运行中的进程"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
|
||||
func frpcCommandError(stdout, stderr string, err error) string {
|
||||
msg := strings.TrimSpace(stderr)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stdout)
|
||||
}
|
||||
if msg == "" && err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "进程已退出"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writePIDFile(path string, pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("无效的 frpc 进程 ID")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) isRunning() (bool, error) {
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) isRunningQuick() bool {
|
||||
ok, _ := m.isRunning()
|
||||
return ok
|
||||
}
|
||||
|
||||
func readPIDFile(path string) (int, error) {
|
||||
return nginx.ReadPID(path)
|
||||
}
|
||||
|
||||
func removePIDFile(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func waitProcessExit(pid int, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func terminateProcess(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
|
||||
func FrpConfigDir(cfg config.Config) string {
|
||||
return filepath.Clean(cfg.FrpDir())
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Message string `json:"message"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ServerEndpoint string `json:"server_endpoint,omitempty"`
|
||||
ConnectedAt string `json:"connected_at,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
HTTPGatewayEnabled bool `json:"http_gateway_enabled"`
|
||||
HTTPSGatewayEnabled bool `json:"https_gateway_enabled"`
|
||||
SyncedDomainCount int `json:"synced_domain_count"`
|
||||
}
|
||||
|
||||
func BuildStatus(cfg config.Config, frpCfg Config, enabled bool, lastError, startedAt, clientVersion string) Status {
|
||||
st := Status{
|
||||
Enabled: enabled,
|
||||
Connected: false,
|
||||
Message: "未启用",
|
||||
LastError: strings.TrimSpace(lastError),
|
||||
ClientVersion: clientVersion,
|
||||
SyncedDomainCount: len(frpCfg.CustomDomains),
|
||||
}
|
||||
if enabled {
|
||||
addr := strings.TrimSpace(frpCfg.ServerAddr)
|
||||
if addr != "" {
|
||||
port := frpCfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
st.ServerEndpoint = addr + ":" + strconv.Itoa(port)
|
||||
}
|
||||
st.HTTPGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
st.HTTPSGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
}
|
||||
if !enabled {
|
||||
return st
|
||||
}
|
||||
running, err := isProcessRunning(cfg.FrpPIDFile)
|
||||
if err != nil || !running {
|
||||
st.Message = "未连接"
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
} else if hint := readLogHint(cfg.FrpLogPath()); hint != "" {
|
||||
st.Message = hint
|
||||
}
|
||||
return st
|
||||
}
|
||||
ok, logHint := logIndicatesConnected(cfg.FrpLogPath())
|
||||
if ok {
|
||||
st.Connected = true
|
||||
st.Message = "已连接"
|
||||
st.LastError = ""
|
||||
applyConnectedTiming(&st, startedAt)
|
||||
return st
|
||||
}
|
||||
st.Message = "连接中"
|
||||
if logHint != "" {
|
||||
st.Message = logHint
|
||||
}
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func applyConnectedTiming(st *Status, startedAt string) {
|
||||
st.ConnectedAt = strings.TrimSpace(startedAt)
|
||||
if st.ConnectedAt == "" {
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, st.ConnectedAt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
st.UptimeSeconds = int64(time.Since(t).Seconds())
|
||||
if st.UptimeSeconds < 0 {
|
||||
st.UptimeSeconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
func isProcessRunning(pidFile string) (bool, error) {
|
||||
pid, err := nginx.ReadPID(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return nginx.IsPIDAlive(pid), nil
|
||||
}
|
||||
|
||||
func readLogHint(path string) string {
|
||||
lines := tailLines(path, 40)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") ||
|
||||
strings.Contains(line, "token in login doesn't match") {
|
||||
return sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logIndicatesConnected(path string) (bool, string) {
|
||||
lines := tailLines(path, 80)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return true, ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") {
|
||||
return false, sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func tailLines(path string, max int) []string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > max {
|
||||
lines = lines[len(lines)-max:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func sanitizeLogLine(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
|
||||
line = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
if len(line) > 200 {
|
||||
line = line[:200] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func DomainsFromProxyRules(rules []proxy.Rule) []string {
|
||||
seen := make(map[string]bool)
|
||||
var domains []string
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, host := range rule.Hostnames() {
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
domains = append(domains, host)
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func MergeDomains(existing, incoming []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, list := range [][]string{existing, incoming} {
|
||||
for _, d := range list {
|
||||
d = strings.TrimSpace(d)
|
||||
key := strings.ToLower(d)
|
||||
if d == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDomainsFromProxyRules(t *testing.T) {
|
||||
rules := []proxy.Rule{
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}}, Domain: "b.example.com"},
|
||||
{Enabled: false, Hosts: []proxy.Host{{Hostname: "skip.example.com"}}},
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}, {Hostname: "c.example.com"}}},
|
||||
}
|
||||
domains := DomainsFromProxyRules(rules)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %v", domains)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package frp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildFRPSConfig generates frps.toml for the user's VPS, aligned with Fonu Nginx ports and auth.
|
||||
func BuildFRPSConfig(bindPort, httpPort, httpsPort int, token string) string {
|
||||
if bindPort <= 0 {
|
||||
bindPort = 7000
|
||||
}
|
||||
if httpPort <= 0 {
|
||||
httpPort = 80
|
||||
}
|
||||
if httpsPort <= 0 {
|
||||
httpsPort = 443
|
||||
}
|
||||
if token == "" {
|
||||
token = "your-secret-token"
|
||||
}
|
||||
return fmt.Sprintf(`bindAddr = "0.0.0.0"
|
||||
bindPort = %d
|
||||
|
||||
vhostHTTPPort = %d
|
||||
vhostHTTPSPort = %d
|
||||
|
||||
[auth]
|
||||
method = "token"
|
||||
token = "%s"
|
||||
`, bindPort, httpPort, httpsPort, token)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildFRPSConfig(t *testing.T) {
|
||||
out := BuildFRPSConfig(7000, 18080, 9443, "fonu-dev-token")
|
||||
if out == "" {
|
||||
t.Fatal("empty config")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"bindPort = 7000",
|
||||
"vhostHTTPPort = 18080",
|
||||
"vhostHTTPSPort = 9443",
|
||||
`token = "fonu-dev-token"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultClientVersion = "frpc v0.71.0"
|
||||
|
||||
func ResolveClientVersion(bin string) string {
|
||||
bin = strings.TrimSpace(bin)
|
||||
if bin == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
out, err := exec.Command(bin, "--version").Output()
|
||||
if err != nil {
|
||||
return defaultClientVersion
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
if strings.HasPrefix(line, "frpc version ") {
|
||||
return "frpc v" + strings.TrimPrefix(line, "frpc version ")
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentClient 调用公网 VPS 上的 fonu-agent。凭据由调用方解密后只在内存中使用。
|
||||
// AgentClient 调用公网 VPS 上的 havline-agent。凭据由调用方解密后只在内存中使用。
|
||||
type AgentClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin
|
||||
return client.NginxLogs(ctx, kind, lines)
|
||||
}
|
||||
|
||||
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Fonu 证书库交叉核对
|
||||
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Havline 证书库交叉核对
|
||||
func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]any, error) {
|
||||
client, ok := s.agentClient(ctx, serverID)
|
||||
if !ok {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp/agentbin"
|
||||
"github.com/havline/havline/internal/frp/agentbin"
|
||||
)
|
||||
|
||||
// AgentInstaller 负责把内嵌的 fonu-agent 通过 SSH 装到 VPS 上,并收集安装结果。
|
||||
// AgentInstaller 负责把内嵌的 havline-agent 通过 SSH 装到 VPS 上,并收集安装结果。
|
||||
type AgentInstaller struct {
|
||||
SSH SSHInstaller
|
||||
}
|
||||
@@ -28,12 +28,12 @@ func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
|
||||
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_exist": out("test -x /usr/local/bin/havline-agent && echo yes || echo no") == "yes",
|
||||
"agent_running": out("systemctl is-active havline-agent 2>/dev/null || true"),
|
||||
"agent_version": out("/usr/local/bin/havline-agent -version 2>/dev/null || echo 未知"),
|
||||
// Token 只回「是否已配置 + 末 4 位」:明文不经过 HTTP 返回前端(库里同一字段也是掩码)
|
||||
"agent_token_configured": out("test -s /etc/fonu-agent/agent.env && grep -q '^FONU_AGENT_TOKEN=' /etc/fonu-agent/agent.env && echo yes || echo no") == "yes",
|
||||
"agent_token_hint": out("sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env 2>/dev/null | tr -d '\\n' | tail -c 4"),
|
||||
"agent_token_configured": out("test -s /etc/havline-agent/agent.env && grep -q '^HAVLINE_AGENT_TOKEN=' /etc/havline-agent/agent.env && echo yes || echo no") == "yes",
|
||||
"agent_token_hint": out("sed -n 's/^HAVLINE_AGENT_TOKEN=//p' /etc/havline-agent/agent.env 2>/dev/null | tr -d '\\n' | tail -c 4"),
|
||||
"frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
|
||||
"frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
|
||||
"frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
|
||||
@@ -57,7 +57,7 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
||||
}
|
||||
// 开发环境(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)")
|
||||
return "", fmt.Errorf("内嵌 agent 二进制无效(开发构建占位文件)。请用 Docker 构建或先执行构建脚本生成真实 agent:go build -o internal/frp/agentbin/havline-agent-linux-amd64 ./cmd/havline-agent(GOOS=linux GOARCH=amd64)")
|
||||
}
|
||||
script, err := agentInstallScript()
|
||||
if err != nil {
|
||||
@@ -76,7 +76,7 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
||||
|
||||
// Uninstall 停止并移除 agent 服务;--keep-data 保留路由与证书。
|
||||
func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, error) {
|
||||
command := "/usr/local/bin/fonu-agent-uninstall"
|
||||
command := "/usr/local/bin/havline-agent-uninstall"
|
||||
if keepData {
|
||||
command += " --keep-data"
|
||||
}
|
||||
@@ -86,14 +86,14 @@ func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, e
|
||||
|
||||
// RestorePreviousConfig 在安全迁移验证失败时恢复安装前的 agent.env,并重启旧监听配置。
|
||||
func (i AgentInstaller) RestorePreviousConfig(ctx context.Context) error {
|
||||
command := `if [ -f /etc/fonu-agent/agent.env.previous ]; then cp /etc/fonu-agent/agent.env.previous /etc/fonu-agent/agent.env && chmod 600 /etc/fonu-agent/agent.env && systemctl restart fonu-agent; fi`
|
||||
command := `if [ -f /etc/havline-agent/agent.env.previous ]; then cp /etc/havline-agent/agent.env.previous /etc/havline-agent/agent.env && chmod 600 /etc/havline-agent/agent.env && systemctl restart havline-agent; fi`
|
||||
_, err := i.SSH.Run(ctx, command)
|
||||
return err
|
||||
}
|
||||
|
||||
// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
|
||||
func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
|
||||
return i.SSH.Run(ctx, "systemctl status 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'")
|
||||
return i.SSH.Run(ctx, "systemctl status havline-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u havline-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
|
||||
}
|
||||
|
||||
func extractToken(output string) string {
|
||||
@@ -110,25 +110,25 @@ func agentInstallScript() ([]byte, error) {
|
||||
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
|
||||
AGENT_BINARY=${1:-/tmp/havline-agent}
|
||||
PREFIX=/usr/local/bin/havline-agent
|
||||
CONF_DIR=/etc/havline-agent
|
||||
CONF_FILE="$CONF_DIR/agent.env"
|
||||
UNIT=/etc/systemd/system/fonu-agent.service
|
||||
UNIT=/etc/systemd/system/havline-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 stop havline-agent 2>/dev/null || true
|
||||
systemctl disable havline-agent 2>/dev/null || true
|
||||
rm -f "$UNIT" "$PREFIX" /usr/local/bin/havline-agent-uninstall
|
||||
systemctl daemon-reload
|
||||
if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/fonu-agent; fi
|
||||
echo "fonu-agent 已卸载"
|
||||
if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/havline-agent; fi
|
||||
echo "havline-agent 已卸载"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
|
||||
mkdir -p "$CONF_DIR" /var/lib/havline-agent/certs
|
||||
# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
|
||||
systemctl stop fonu-agent 2>/dev/null || true
|
||||
systemctl stop havline-agent 2>/dev/null || true
|
||||
TMP_PREFIX="${PREFIX}.new"
|
||||
# 迁移旧版 Agent 留在 nginx include 范围内的 <配置>.versions 目录(保留历史版本内容)
|
||||
for NGINX_DIR in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
|
||||
@@ -157,23 +157,23 @@ fi
|
||||
printf 'agent version: %s\n' "$AGENT_VERSION"
|
||||
if [ ! -f "$CONF_FILE" ]; then
|
||||
umask 077
|
||||
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent)
|
||||
printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
|
||||
# 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Havline 无法认证 agent)
|
||||
printf 'HAVLINE_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
|
||||
fi
|
||||
cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
|
||||
touch "$CONF_FILE"
|
||||
sed -i '/^FONU_AGENT_LISTEN=/d; /^FONU_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
|
||||
printf 'FONU_AGENT_LISTEN=127.0.0.1:7700\nFONU_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
|
||||
sed -i '/^HAVLINE_AGENT_LISTEN=/d; /^HAVLINE_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
|
||||
printf 'HAVLINE_AGENT_LISTEN=127.0.0.1:7700\nHAVLINE_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
|
||||
cat > "$UNIT" <<'UNIT'
|
||||
[Unit]
|
||||
Description=Fonu FRPS management agent
|
||||
Description=Havline 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
|
||||
EnvironmentFile=/etc/havline-agent/agent.env
|
||||
ExecStart=/usr/local/bin/havline-agent
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
@@ -181,11 +181,11 @@ RestartSec=3
|
||||
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")"
|
||||
systemctl restart havline-agent
|
||||
systemctl enable havline-agent
|
||||
cp "$0" /usr/local/bin/havline-agent-uninstall
|
||||
echo "havline-agent installed"
|
||||
echo "Token: $(sed -n 's/^HAVLINE_AGENT_TOKEN=//p' "$CONF_FILE")"
|
||||
`), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package agentbin 内嵌各架构 fonu-agent 二进制,供 SSH 安装器按 VPS 架构选择。
|
||||
// Package agentbin 内嵌各架构 havline-agent 二进制,供 SSH 安装器按 VPS 架构选择。
|
||||
// 仓库中的占位文件保证开发机构建可通过;Docker 构建阶段会用真实二进制覆盖它们
|
||||
// (见 Dockerfile:先交叉编译 agent,再构建主程序)。
|
||||
package agentbin
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed fonu-agent-linux-amd64
|
||||
//go:embed havline-agent-linux-amd64
|
||||
var LinuxAMD64 []byte
|
||||
|
||||
//go:embed fonu-agent-linux-arm64
|
||||
//go:embed havline-agent-linux-arm64
|
||||
var LinuxARM64 []byte
|
||||
|
||||
// BinaryForArch 返回对应架构的 agent 二进制;arch 取 uname -m 输出(x86_64/aarch64)。
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -42,7 +42,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
|
||||
b.WriteString("# 由 Havline 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
|
||||
b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(INI 自 v0.52.0 起已被官方弃用)\n")
|
||||
b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
|
||||
// TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
|
||||
@@ -118,7 +118,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
b.WriteString("\nauth.method = \"token\"\n")
|
||||
b.WriteString(fmt.Sprintf("auth.token = %q\n", token))
|
||||
} else {
|
||||
b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Fonu 重新生成本文件以保持两端一致\n")
|
||||
b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Havline 重新生成本文件以保持两端一致\n")
|
||||
}
|
||||
case "none":
|
||||
b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n")
|
||||
@@ -143,7 +143,7 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
b.WriteString(fmt.Sprintf("webServer.password = %q\n", password))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Fonu 所在机器访问该端口\n")
|
||||
b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Havline 所在机器访问该端口\n")
|
||||
}
|
||||
|
||||
if hasSubdomain {
|
||||
|
||||
@@ -272,7 +272,7 @@ func (m *Manager) binaryPathLocked() (string, error) {
|
||||
}
|
||||
path, err := exec.LookPath(m.bin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_FRPC_BIN")
|
||||
return "", fmt.Errorf("未找到 frpc 二进制,请设置 HAVLINE_FRPC_BIN")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ type ServerOptions struct {
|
||||
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
|
||||
// frps 管理接口(webServer),仅用于 Havline 拉取穿透流量等只读信息,不写入 frpc.toml
|
||||
DashboardAddr string `json:"dashboard_addr,omitempty"`
|
||||
DashboardPort int `json:"dashboard_port,omitempty"`
|
||||
DashboardUser string `json:"dashboard_user,omitempty"`
|
||||
|
||||
@@ -25,7 +25,7 @@ type RouteDriftItem struct {
|
||||
Actual string `json:"actual,omitempty"`
|
||||
}
|
||||
|
||||
// RouteDriftReport 部署漂移报告:Expected = 库里启用中的规则域名数,Deployed = VPS 上 fonu-*.conf 数,Loaded = 其中被加载的数
|
||||
// RouteDriftReport 部署漂移报告:Expected = 库里启用中的规则域名数,Deployed = VPS 上 havline-*.conf 数,Loaded = 其中被加载的数
|
||||
type RouteDriftReport struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
Expected int `json:"expected"`
|
||||
@@ -35,7 +35,7 @@ type RouteDriftReport struct {
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
// RouteDrift 比对「数据库里启用中的公网反代规则」与「VPS 上 fonu-*.conf 的实际状态」。
|
||||
// RouteDrift 比对「数据库里启用中的公网反代规则」与「VPS 上 havline-*.conf 的实际状态」。
|
||||
// 内容比对只做上流地址(proxy_pass):配置文件由 agent 渲染,应用侧不重复实现一套渲染器,
|
||||
// 因此不做全文 diff,避免两套渲染逻辑漂移带来的误报。
|
||||
func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftReport, error) {
|
||||
@@ -113,7 +113,7 @@ func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftRep
|
||||
if got != "" && got != normalizeUpstream(want) {
|
||||
report.Items = append(report.Items, RouteDriftItem{
|
||||
Domain: domain, Kind: RouteDriftContentMismatch,
|
||||
Detail: "VPS 上的上流地址与 Fonu 记录不一致(可能被手工修改过)",
|
||||
Detail: "VPS 上的上流地址与 Havline 记录不一致(可能被手工修改过)",
|
||||
Expected: want, Actual: got,
|
||||
})
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftRep
|
||||
}
|
||||
report.Items = append(report.Items, RouteDriftItem{
|
||||
Domain: domain, Kind: RouteDriftOrphan,
|
||||
Detail: "VPS 上存在该域名配置,但 Fonu 里没有对应规则(手工添加,或已删除规则的残留)",
|
||||
Detail: "VPS 上存在该域名配置,但 Havline 里没有对应规则(手工添加,或已删除规则的残留)",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ func fetchBytesWithProgress(ctx context.Context, url string, limit int64, progre
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("User-Agent", "Fonu-FRP-Downloader")
|
||||
request.Header.Set("User-Agent", "Havline-FRP-Downloader")
|
||||
if strings.HasPrefix(url, "https://api.github.com/repos/fatedier/frp/releases/assets/") {
|
||||
request.Header.Set("Accept", "application/octet-stream")
|
||||
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
|
||||
+11
-11
@@ -13,10 +13,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/havline/havline/internal/certificate"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
"github.com/havline/havline/internal/secret"
|
||||
)
|
||||
|
||||
// maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值
|
||||
@@ -29,7 +29,7 @@ type Service struct {
|
||||
secretBox *secret.Box
|
||||
manager *Manager
|
||||
logger *slog.Logger
|
||||
certStore *certificate.Store // 用于 HTTPS 规则部署时把 Fonu 本地证书推送到公网 agent
|
||||
certStore *certificate.Store // 用于 HTTPS 规则部署时把 Havline 本地证书推送到公网 agent
|
||||
|
||||
managersMu sync.RWMutex
|
||||
managers map[int64]*Manager
|
||||
@@ -790,7 +790,7 @@ func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, err
|
||||
_ = (AgentInstaller{SSH: installer}).RestorePreviousConfig(ctx)
|
||||
return Server{}, err
|
||||
}
|
||||
// 旧版升级流程可能残留 fonuagent-https.conf,这里通过隧道顺手关闭,避免 7443 继续暴露。
|
||||
// 旧版升级流程可能残留 havlineagent-https.conf,这里通过隧道顺手关闭,避免 7443 继续暴露。
|
||||
_ = client.DisableHTTPS(ctx)
|
||||
s.invalidateAgentClient(serverID)
|
||||
return updated, nil
|
||||
@@ -930,7 +930,7 @@ func (s *Service) StartAgentTunnels(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupLegacyAgentHTTPS 清理隧道模式下可能残留的 fonuagent-https.conf,避免 7443 继续暴露。
|
||||
// cleanupLegacyAgentHTTPS 清理隧道模式下可能残留的 havlineagent-https.conf,避免 7443 继续暴露。
|
||||
func (s *Service) cleanupLegacyAgentHTTPS(ctx context.Context, serverID int64) {
|
||||
client, ok := s.agentClient(ctx, serverID)
|
||||
if !ok {
|
||||
@@ -1655,7 +1655,7 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
||||
} else {
|
||||
item.TunnelDetail = dashboardDiag
|
||||
}
|
||||
// ④ 内网服务探活:Fonu 与服务同在内网,直接 TCP 拨测
|
||||
// ④ 内网服务探活:Havline 与服务同在内网,直接 TCP 拨测
|
||||
conn, serr2 := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", proxy.LocalIP, proxy.LocalPort), 2*time.Second)
|
||||
if serr2 == nil {
|
||||
_ = conn.Close()
|
||||
@@ -1774,7 +1774,7 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushAgentCertificate 从 Fonu 本地证书存储读取域名对应证书并推送到公网 agent;
|
||||
// pushAgentCertificate 从 Havline 本地证书存储读取域名对应证书并推送到公网 agent;
|
||||
// 支持精确域名与通配符(*.example.com)匹配。
|
||||
func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient, domain string) error {
|
||||
if s.certStore == nil {
|
||||
@@ -1807,7 +1807,7 @@ func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient,
|
||||
}
|
||||
return client.PushCertificate(ctx, domain, string(certPEM), string(keyPEM))
|
||||
}
|
||||
return fmt.Errorf("Fonu 本地没有域名 %s 的证书;请先在「证书」页申请,或改用 HTTP 类型规则", domain)
|
||||
return fmt.Errorf("Havline 本地没有域名 %s 的证书;请先在「证书」页申请,或改用 HTTP 类型规则", domain)
|
||||
}
|
||||
|
||||
// RemoveAgentRoute 手动移除单条穿透规则对应的公网 Nginx 反代。
|
||||
@@ -2356,7 +2356,7 @@ func (s *Service) saveOIDCSecret(ctx context.Context, serverID int64, in ServerI
|
||||
return s.store.SetOIDCSecret(ctx, serverID, value)
|
||||
}
|
||||
|
||||
// saveDashboardPassword 保存 frps 管理接口密码(与 frpc Token 无关,仅用于 Fonu 拉取流量信息)。
|
||||
// saveDashboardPassword 保存 frps 管理接口密码(与 frpc Token 无关,仅用于 Havline 拉取流量信息)。
|
||||
func (s *Service) saveDashboardPassword(ctx context.Context, serverID int64, in ServerInput) error {
|
||||
if in.ClearDashboardPassword {
|
||||
return s.store.SetDashboardPassword(ctx, serverID, "")
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
|
||||
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 havline-agent。
|
||||
// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
|
||||
type SSHInstaller struct {
|
||||
Host string
|
||||
@@ -132,13 +132,13 @@ func (i SSHInstaller) Install(ctx context.Context, binary []byte, script []byte)
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
if err := uploadBytes(client, "/tmp/fonu-agent", binary, 0o755); err != nil {
|
||||
if err := uploadBytes(client, "/tmp/havline-agent", binary, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := uploadBytes(client, "/tmp/fonu-agent-install.sh", script, 0o700); err != nil {
|
||||
if err := uploadBytes(client, "/tmp/havline-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")
|
||||
return runSSHSession(client, "sudo /tmp/havline-agent-install.sh /tmp/havline-agent && test -x /usr/local/bin/havline-agent && /usr/local/bin/havline-agent -version && rm -f /tmp/havline-agent /tmp/havline-agent-install.sh")
|
||||
}
|
||||
|
||||
func uploadBytes(client *ssh.Client, path string, data []byte, mode uint32) error {
|
||||
|
||||
@@ -6,7 +6,7 @@ import "testing"
|
||||
// 单引号必须转义,否则路径里的引号能截断命令。
|
||||
func TestShellPathEscapesSingleQuotes(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"/tmp/fonu-agent", "'/tmp/fonu-agent'"},
|
||||
{"/tmp/havline-agent", "'/tmp/havline-agent'"},
|
||||
{"/tmp/it's here", `'/tmp/it'\''s here'`},
|
||||
{"", "''"},
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func TestReadVersionRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVersionsDirIsHidden(t *testing.T) {
|
||||
path := filepath.Join("/etc/nginx/sites-enabled", "fonuagent-https.conf")
|
||||
path := filepath.Join("/etc/nginx/sites-enabled", "havlineagent-https.conf")
|
||||
base := filepath.Base(VersionsDir(path))
|
||||
if !strings.HasPrefix(base, ".") {
|
||||
t.Fatalf("版本目录应以点号开头,避免被 nginx 的 include * 加载:%s", base)
|
||||
@@ -208,7 +208,7 @@ func TestVersionsDirIsHidden(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMigrateLegacyVersions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "fonuagent-https.conf")
|
||||
path := filepath.Join(t.TempDir(), "havlineagent-https.conf")
|
||||
legacy := path + ".versions"
|
||||
if err := os.MkdirAll(legacy, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestDashboardAccessFilterSkipsNoisePaths(t *testing.T) {
|
||||
if isDashboardAccess(AccessEntry{Path: "/favicon.ico"}) {
|
||||
t.Fatal("expected favicon to be excluded")
|
||||
}
|
||||
if isDashboardAccess(AccessEntry{Path: "/fonu-errors/error.png"}) {
|
||||
if isDashboardAccess(AccessEntry{Path: "/havline-errors/error.png"}) {
|
||||
t.Fatal("expected error asset to be excluded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ func isDashboardAccess(entry AccessEntry) bool {
|
||||
case "/favicon.ico", "/robots.txt":
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(entry.Path, "/fonu-errors/") {
|
||||
if strings.HasPrefix(entry.Path, "/havline-errors/") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
+10
-10
@@ -14,10 +14,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
"github.com/havline/havline/internal/metrics"
|
||||
"github.com/havline/havline/internal/notify"
|
||||
"github.com/havline/havline/internal/settings"
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
// RouteStatus 是巡检关心的隧道与健康状态。
|
||||
@@ -99,7 +99,7 @@ type ServerStatus struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// AgentStatus 是某台服务端上 fonu-agent 的可达性;没配 agent 的服务端不会出现在这里。
|
||||
// AgentStatus 是某台服务端上 havline-agent 的可达性;没配 agent 的服务端不会出现在这里。
|
||||
type AgentStatus struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -586,15 +586,15 @@ func (c *Checker) checkAgents(ctx context.Context) {
|
||||
switch event {
|
||||
case failureEventDown:
|
||||
c.notify.Alert(ctx, notify.EventAgentUnreachable, "公网 agent 不可达",
|
||||
fmt.Sprintf("%s 的 fonu-agent 连续 %d 次无响应:%s", label, limit, detailOr(agent.Detail)))
|
||||
fmt.Sprintf("%s 的 havline-agent 连续 %d 次无响应:%s", label, limit, detailOr(agent.Detail)))
|
||||
case failureEventRecovered:
|
||||
c.notify.Alert(ctx, notify.EventAgentUnreachable, "公网 agent 已恢复",
|
||||
fmt.Sprintf("%s 的 fonu-agent 已恢复响应", label))
|
||||
fmt.Sprintf("%s 的 havline-agent 已恢复响应", label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkDrift 配置漂移:VPS 上的 vhost 与 Fonu 记录不一致(被手工改过、或 agent 重装后没重部署)。
|
||||
// checkDrift 配置漂移:VPS 上的 vhost 与 Havline 记录不一致(被手工改过、或 agent 重装后没重部署)。
|
||||
// 它不是「连续 N 次」型故障,因此用冷却时间限流:首次发现即报,之后每 cooldown 提醒一次,漂移清空时补一条恢复。
|
||||
func (c *Checker) checkDrift(ctx context.Context) {
|
||||
if c.source == nil {
|
||||
@@ -629,7 +629,7 @@ func (c *Checker) checkDrift(ctx context.Context) {
|
||||
if report.Count == 0 {
|
||||
if alerted {
|
||||
c.notify.Alert(ctx, notify.EventRouteDrift, "配置漂移已消除",
|
||||
fmt.Sprintf("%s 上的 vhost 已与 Fonu 记录一致", label))
|
||||
fmt.Sprintf("%s 上的 vhost 已与 Havline 记录一致", label))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -641,7 +641,7 @@ func (c *Checker) checkDrift(ctx context.Context) {
|
||||
c.driftNotified[report.ID] = now
|
||||
c.mu.Unlock()
|
||||
c.notify.Alert(ctx, notify.EventRouteDrift, "公网反代配置漂移",
|
||||
fmt.Sprintf("%s 有 %d 项与 Fonu 记录不一致:%s", label, report.Count, strings.Join(report.Items, "、")))
|
||||
fmt.Sprintf("%s 有 %d 项与 Havline 记录不一致:%s", label, report.Count, strings.Join(report.Items, "、")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
)
|
||||
|
||||
func osReadDir(path string) ([]os.DirEntry, error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/havline/havline/internal/proxy"
|
||||
)
|
||||
|
||||
func writeCommentLine(b *strings.Builder, indent int, text string) {
|
||||
@@ -72,7 +72,7 @@ func writeSecurityHeadersComment(b *strings.Builder) {
|
||||
}
|
||||
|
||||
func writeErrorPagesComment(b *strings.Builder) {
|
||||
writeCommentLine(b, 4, "Fonu 自定义错误页(403/404/429/5xx)")
|
||||
writeCommentLine(b, 4, "Havline 自定义错误页(403/404/429/5xx)")
|
||||
}
|
||||
|
||||
func writeLocationHeaderComment(b *strings.Builder, upstream string) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/havline/havline/internal/config"
|
||||
"github.com/havline/havline/internal/fsutil"
|
||||
)
|
||||
|
||||
const maxCustomBackups = 10
|
||||
@@ -220,7 +220,7 @@ client_max_body_size 50m;
|
||||
}
|
||||
|
||||
func GenerateGlobalFramework(cfg config.Config) string {
|
||||
return fmt.Sprintf(`# 以下部分由 Fonu 自动生成,请勿在此编辑
|
||||
return fmt.Sprintf(`# 以下部分由 Havline 自动生成,请勿在此编辑
|
||||
|
||||
# 主进程
|
||||
worker_processes auto;
|
||||
@@ -240,8 +240,8 @@ http {
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 访问日志格式与路径
|
||||
log_format fonu_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
|
||||
access_log %s fonu_access;
|
||||
log_format havline_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
|
||||
access_log %s havline_access;
|
||||
|
||||
# 传输优化
|
||||
sendfile on;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user