diff --git a/CHANGELOG.md b/CHANGELOG.md index cf59aa5..e969334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. +## [v1.5.1] - 2026-09-28 + +### 变更 + +- **P1-1 共享 Nginx 模板包**:新增纯生成包 `internal/nginxtmpl`,统一 TLS 指令、安全响应头、allow / deny、WebSocket 头、限流指令与 zone 命名;本机 `internal/nginx` 与公网 Agent 均改为调用共享函数,重复模板实现已删除 +- **安全响应头对齐**:两端统一输出 HSTS、`X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`,本机此前缺少 `Referrer-Policy` +- **访问控制顺序统一**:统一为黑名单 `deny` → 仅大陆规则 → 白名单 `allow` → `deny all`,黑名单优先匹配 +- **TLS 指令显式化**:两端统一显式输出 `ssl_protocols TLSv1.2 TLSv1.3` 或 `TLSv1.3` +- 版本号提升到 1.5.1;`AgentVersion` 0.14.0 → **0.14.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/nginxtmpl`:新增共享函数的 TLS、安全头、访问控制、限流、zone 与 WebSocket 特征化测试 +- `internal/nginx`:本机生成测试继续通过;`internal/agent`:新增 vhost 顺序与共享语义测试 + ## [v1.5.0] - 2026-09-28 ### 新增 diff --git a/VERSION b/VERSION index bc80560..26ca594 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.0 +1.5.1 diff --git a/docs/README.md b/docs/README.md index af0e252..e09c269 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.5.0) +> 最近更新:2026-09-28(v1.5.1) ## 方案与设计(本目录) diff --git a/docs/improvement-roadmap-2026-09-27.md b/docs/improvement-roadmap-2026-09-27.md index ceb61c8..2144ba9 100644 --- a/docs/improvement-roadmap-2026-09-27.md +++ b/docs/improvement-roadmap-2026-09-27.md @@ -585,6 +585,8 @@ P1 的核心是控制复杂度,不改变可见功能。 ## 4.1 P1-1:抽共享 Nginx 模板包 +> 实施状态:2026-09-28 已完成(v1.5.1)。共享包 `internal/nginxtmpl` 已落地,本机与 Agent 的 TLS、安全响应头、allow/deny、WebSocket、限流指令与 zone 命名均已迁出;仅保留中国 IP geo 变量名与 Agent 限流文件路径的适配层。 + ### 4.1.1 现状 本机与公网 Agent 各有一套 Nginx 生成逻辑: diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 61867d5..721eebf 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.5.0 的验证流程: +示例页面用于验证升级结果。v1.5.1 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.0`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.1`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.5.0`; - - 页面显示验证标识 `v1.5.0-example-page`; + - 页面显示的版本为 `1.5.1`; + - 页面显示验证标识 `v1.5.1-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/agent/route_test.go b/internal/agent/route_test.go index 33d30fb..11c970d 100644 --- a/internal/agent/route_test.go +++ b/internal/agent/route_test.go @@ -48,6 +48,46 @@ func TestNginxRouteRendersUpstream(t *testing.T) { } } +// TestNginxRouteSharedTemplateSemantics 锁定共享模板迁出后的关键语义与顺序。 +func TestNginxRouteSharedTemplateSemantics(t *testing.T) { + out := nginxRoute(routeRequest{ + Domain: "a.example.com", + Upstream: "127.0.0.1:8080", + TLS: true, + RedirectTLS: true, + SecurityHeaders: true, + WebSocket: true, + AllowIPs: []string{"192.168.1.0/24"}, + DenyIPs: []string{"8.8.8.8"}, + RateLimitRate: 10, + RateLimitBurst: 20, + ConnLimitMax: 5, + ChinaOnly: true, + }, "/etc/fonu-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; }", + "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;", + "proxy_set_header Upgrade $http_upgrade;", + `proxy_set_header Connection "upgrade";`, + } { + if !strings.Contains(out, want) { + t.Fatalf("缺少 %q:\n%s", want, out) + } + } + denyIdx := strings.Index(out, "deny 8.8.8.8;") + allowIdx := strings.Index(out, "allow 192.168.1.0/24;") + denyAllIdx := strings.Index(out, "deny all;") + if denyIdx < 0 || allowIdx < 0 || denyAllIdx < 0 || !(denyIdx < allowIdx && allowIdx < denyAllIdx) { + t.Fatalf("allow / deny 顺序不正确:\n%s", out) + } +} + // TestFileSHA256 覆盖 frps 安装包的校验路径。 func TestFileSHA256(t *testing.T) { path := filepath.Join(t.TempDir(), "payload.txt") diff --git a/internal/agent/server.go b/internal/agent/server.go index 98fce8d..c99c8c9 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -27,6 +27,7 @@ import ( "time" "github.com/fonu/fonu/internal/fsutil" + "github.com/fonu/fonu/internal/nginxtmpl" "github.com/fonu/fonu/internal/sysinfo" ) @@ -59,7 +60,8 @@ import ( // 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检, // 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。 // 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。 -const AgentVersion = "0.14.0" +// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。 +const AgentVersion = "0.14.1" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -1519,14 +1521,10 @@ func nginxRoute(req routeRequest, certDir string) string { fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout) } // 安全响应头:HSTS 仅在 HTTPS 实际启用时输出 - if req.SecurityHeaders { - b.WriteString(" add_header X-Content-Type-Options \"nosniff\" always;\n") - b.WriteString(" add_header X-Frame-Options \"SAMEORIGIN\" always;\n") - b.WriteString(" add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n") - if tlsEnabled { - b.WriteString(" add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n") - } - } + b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{ + Enabled: req.SecurityHeaders, + HTTPS: tlsEnabled, + })) // 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码 if req.RateLimitRate > 0 { b.WriteString(" limit_req_status 429;\n") @@ -1536,26 +1534,19 @@ func nginxRoute(req routeRequest, certDir string) string { } if req.WebSocket { b.WriteString(" proxy_http_version 1.1;\n") - b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") - b.WriteString(" proxy_set_header Connection \"upgrade\";\n") + b.WriteString(nginxtmpl.UpgradeHeaders(" ", "\"upgrade\"")) } // IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效; - // Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。 - var acl strings.Builder - for _, ip := range req.AllowIPs { - ip = strings.TrimSpace(ip) - if ip != "" { - fmt.Fprintf(&acl, " allow %s;\n", ip) - } - } - for _, ip := range req.DenyIPs { - if ip = strings.TrimSpace(ip); ip != "" { - fmt.Fprintf(&acl, " deny %s;\n", ip) - } - } - if len(req.AllowIPs) > 0 { - acl.WriteString(" deny all;\n") + // 顺序由共享模板统一:deny → 仅大陆规则 → allow → deny all。 + chinaDeny := "" + if req.ChinaOnly { + chinaDeny = " if ($fonu_china_client = 0) { return 403; }\n" } + aclText := nginxtmpl.AccessDirectives(nginxtmpl.AccessControl{ + Allow: req.AllowIPs, + Deny: req.DenyIPs, + ChinaDeny: chinaDeny, + }, " ") // Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式); // 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释 authLine := "" @@ -1568,24 +1559,14 @@ func nginxRoute(req routeRequest, certDir string) string { authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain)) } } - // 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义) - var limitText strings.Builder - if req.RateLimitRate > 0 { - zone := zoneKey(req.Domain) - if req.RateLimitBurst > 0 { - fmt.Fprintf(&limitText, " limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst) - } else { - fmt.Fprintf(&limitText, " limit_req zone=%s_req nodelay;\n", zone) - } - } - if req.ConnLimitMax > 0 { - fmt.Fprintf(&limitText, " limit_conn %s_conn %d;\n", zoneKey(req.Domain), req.ConnLimitMax) - } - if req.ChinaOnly { - limitText.WriteString(" if ($fonu_china_client = 0) { return 403; }\n") - } - aclText := acl.String() - fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText.String(), req.Upstream) + // 限流 / 连接数(zone 在 http 上下文定义) + limitText := nginxtmpl.LimitDirectives(nginxtmpl.LimitConfig{ + Zone: nginxtmpl.ZoneName("fonu", req.Domain), + Rate: req.RateLimitRate, + Burst: req.RateLimitBurst, + Conn: req.ConnLimitMax, + }, " ") + fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText, req.Upstream) return b.String() } @@ -1594,16 +1575,7 @@ const agentStateDir = "/var/lib/fonu-agent" // zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。 func zoneKey(domain string) string { - var b strings.Builder - b.WriteString("fonu_") - for _, r := range strings.ToLower(domain) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - return b.String() + return nginxtmpl.ZoneName("fonu", domain) } // limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。 @@ -1684,10 +1656,10 @@ func (s *Server) writeLimitState(state map[string]routeLimit) error { item := state[domain] key := zoneKey(domain) if item.Rate > 0 { - fmt.Fprintf(&b, "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n", key, item.Rate) + b.WriteString(nginxtmpl.RateLimitZone(key+"_req", item.Rate)) } if item.Conn > 0 { - fmt.Fprintf(&b, "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n", key) + b.WriteString(nginxtmpl.ConnLimitZone(key + "_conn")) } } return atomicWrite(path, []byte(b.String()), 0o640) @@ -1722,12 +1694,11 @@ func (s *Server) clearLimitState(domain string) error { // sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。 func sslDirectives(certDir string, tls13Only bool) string { - var b strings.Builder - fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem")) - if tls13Only { - b.WriteString(" ssl_protocols TLSv1.3;\n") - } - return b.String() + return nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{ + CertPath: filepath.Join(certDir, "fullchain.pem"), + KeyPath: filepath.Join(certDir, "privkey.pem"), + TLS13Only: tls13Only, + }) } // writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。 diff --git a/internal/agent/transport.go b/internal/agent/transport.go index 1ffc470..2fd8006 100644 --- a/internal/agent/transport.go +++ b/internal/agent/transport.go @@ -18,6 +18,7 @@ import ( "time" "github.com/fonu/fonu/internal/fsutil" + "github.com/fonu/fonu/internal/nginxtmpl" ) const ( @@ -209,9 +210,7 @@ func renderHTTPSProxy(port int, certPath, keyPath string) string { server { listen %d ssl default_server; server_name _; - ssl_certificate %s; - ssl_certificate_key %s; - ssl_protocols TLSv1.2 TLSv1.3; +%s location /api/v1/ { proxy_pass http://127.0.0.1:7700; @@ -222,7 +221,7 @@ server { proxy_set_header Authorization $http_authorization; } } -`, port, certPath, keyPath) +`, port, nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{CertPath: certPath, KeyPath: keyPath})) } func certificatePin(certPEM string) (string, error) { diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 26a32d9..6cf1507 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 968a033..24478ab 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/nginx/generate.go b/internal/nginx/generate.go index 48e1018..d6d0b3d 100644 --- a/internal/nginx/generate.go +++ b/internal/nginx/generate.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/nginxtmpl" "github.com/fonu/fonu/internal/proxy" ) @@ -171,9 +172,13 @@ func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serve writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames)) writeCommentLine(&block, 4, "SSL 证书") - block.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", absNginxPath(cert.CertPath))) - block.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", absNginxPath(cert.KeyPath))) - writeTLSProtocols(&block, rule.Security) + writeTLSProtocolComment(&block, rule.Security) + block.WriteString(nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{ + CertPath: absNginxPath(cert.CertPath), + KeyPath: absNginxPath(cert.KeyPath), + TLS13Only: rule.Security.TLSMin13Only, + })) + block.WriteString("\n") writeServerSecurityHeaders(&block, rule.Security, true) writeErrorPages(&block, cfg) writeLocationWithSecurity(&block, cfg, rule, opts) @@ -217,11 +222,10 @@ func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.Secur proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Real-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; +%s proxy_buffering off; proxy_read_timeout 3600s; -`, sslBlock, upstream, hostHeader)) +`, sslBlock, upstream, hostHeader, nginxtmpl.UpgradeHeaders(" ", "$connection_upgrade"))) } func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) { diff --git a/internal/nginx/security.go b/internal/nginx/security.go index b42847d..8e9b600 100644 --- a/internal/nginx/security.go +++ b/internal/nginx/security.go @@ -6,18 +6,19 @@ import ( "strings" "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/nginxtmpl" "github.com/fonu/fonu/internal/proxy" ) type GenerateOptions struct { - TrustedProxy TrustedProxyConfig - GlobalIPBlacklist []string - GlobalIPWhitelist []string - ChinaCIDRAvailable bool - ChinaCIDRPathOverride string - GlobalCustomOverride string - GlobalCustomPathOverride string - RuleCustomOverrides map[int64]string + TrustedProxy TrustedProxyConfig + GlobalIPBlacklist []string + GlobalIPWhitelist []string + ChinaCIDRAvailable bool + ChinaCIDRPathOverride string + GlobalCustomOverride string + GlobalCustomPathOverride string + RuleCustomOverrides map[int64]string } func writeGlobalAllowList(b *strings.Builder, cidrs []string) { @@ -52,11 +53,11 @@ func writeLimitZones(b *strings.Builder, rules []proxy.Rule) { writeLimitZoneComments(b, rule) } if rule.Security.RateLimitEnabled() { - rl := rule.Security.RateLimit - b.WriteString(fmt.Sprintf(" limit_req_zone $binary_remote_addr zone=fonu_rule_%d_req:10m rate=%dr/s;\n", rule.ID, rl.Rate)) + zone := fmt.Sprintf("fonu_rule_%d_req", rule.ID) + writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate)) } if rule.Security.ConnLimitEnabled() { - b.WriteString(fmt.Sprintf(" limit_conn_zone $binary_remote_addr zone=fonu_rule_%d_conn:10m;\n", rule.ID)) + writeIndentedZone(b, nginxtmpl.ConnLimitZone(fmt.Sprintf("fonu_rule_%d_conn", rule.ID))) } } if hasLimitZones(rules) { @@ -64,6 +65,13 @@ func writeLimitZones(b *strings.Builder, rules []proxy.Rule) { } } +func writeIndentedZone(b *strings.Builder, line string) { + if strings.TrimSpace(line) == "" { + return + } + b.WriteString(" " + line) +} + func hasLimitZones(rules []proxy.Rule) bool { for _, rule := range rules { if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) { @@ -135,18 +143,18 @@ func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts Genera } } -func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) { +func chinaOnlyCheckDirectives(rule proxy.Rule, opts GenerateOptions) string { sec := rule.Security.Normalize() if !sec.ChinaOnly { - return + return "" } if !opts.ChinaCIDRAvailable { - b.WriteString(` if ($fonu_is_private = 0) { + return ` if ($fonu_is_private = 0) { return 403; } -`) - return +` } + var b strings.Builder b.WriteString(` set $fonu_china_block 0; if ($fonu_is_china = 0) { set $fonu_china_block 1; @@ -163,6 +171,7 @@ func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptio return 403; } `) + return b.String() } func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) { @@ -170,20 +179,8 @@ func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, ht return } writeSecurityHeadersComment(b) - b.WriteString(` add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - -`) -} - -func writeTLSProtocols(b *strings.Builder, sec proxy.SecurityConfig) { - writeTLSProtocolComment(b, sec) - if sec.TLSMin13Only { - b.WriteString(" ssl_protocols TLSv1.3;\n\n") - } else { - b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n\n") - } + b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true})) + b.WriteString("\n") } func writeErrorPages(b *strings.Builder, cfg config.Config) { @@ -216,25 +213,24 @@ func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rul sec := rule.Security.Normalize() writeLocationSecurityComments(b, rule, opts) - for _, cidr := range sec.IPBlacklist { - b.WriteString(fmt.Sprintf(" deny %s;\n", cidr)) + acl := nginxtmpl.AccessControl{ + Deny: sec.IPBlacklist, + ChinaDeny: chinaOnlyCheckDirectives(rule, opts), } - - writeChinaOnlyCheck(b, rule, opts) - if sec.WhitelistEnabled() { - for _, cidr := range sec.IPWhitelist { - b.WriteString(fmt.Sprintf(" allow %s;\n", cidr)) - } - b.WriteString(" deny all;\n") + acl.Allow = sec.IPWhitelist } + b.WriteString(nginxtmpl.AccessDirectives(acl, " ")) + limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("fonu_rule_%d", rule.ID)} if sec.ConnLimitEnabled() { - b.WriteString(fmt.Sprintf(" limit_conn fonu_rule_%d_conn %d;\n", rule.ID, sec.ConnLimit.Max)) + limits.Conn = sec.ConnLimit.Max } if sec.RateLimitEnabled() { - b.WriteString(fmt.Sprintf(" limit_req zone=fonu_rule_%d_req burst=%d nodelay;\n", rule.ID, sec.RateLimit.Burst)) + limits.Rate = sec.RateLimit.Rate + limits.Burst = sec.RateLimit.Burst } + b.WriteString(nginxtmpl.LimitDirectives(limits, " ")) if sec.BasicAuthEnabled() { path := absNginxPath(HtpasswdPath(cfg, rule.ID)) diff --git a/internal/nginxtmpl/nginxtmpl.go b/internal/nginxtmpl/nginxtmpl.go new file mode 100644 index 0000000..03bcfe2 --- /dev/null +++ b/internal/nginxtmpl/nginxtmpl.go @@ -0,0 +1,176 @@ +// Package nginxtmpl 提供本机 Nginx 与公网 Agent 共用的纯文本模板函数。 +// +// 这里只做字符串渲染,不读文件、不执行进程、不依赖业务模型, +// 因此 internal/nginx 与 internal/agent 都可以安全导入而不产生循环依赖。 +package nginxtmpl + +import ( + "fmt" + "strings" +) + +// DefaultIndent 是 Nginx server 块内指令的默认缩进。 +const DefaultIndent = " " + +// TLSConfig 描述证书与 TLS 协议版本偏好。 +type TLSConfig struct { + CertPath string + KeyPath string + TLS13Only bool +} + +// SecurityHeaders 描述安全响应头;HTTPS 为 false 时不输出 HSTS。 +type SecurityHeaders struct { + Enabled bool + HTTPS bool + HSTSMaxAgeSeconds int +} + +// AccessControl 描述 allow / deny 顺序;Allow 非空时末尾自动追加 deny all。 +// ChinaDeny 是可选的完整指令片段,插在 deny 黑名单之后、allow 白名单之前, +// 让两端共享顺序结构,同时保留各自的 geo 变量名。 +type AccessControl struct { + Allow []string + Deny []string + ChinaDeny string +} + +// LimitConfig 描述 location 级限流指令;Zone 是已合法化的 zone 前缀。 +type LimitConfig struct { + Zone string + Rate int + Burst int + Conn int +} + +// SSLDirectives 生成证书与 TLS 协议指令,缩进固定为 server 块内层级。 +func SSLDirectives(cfg TLSConfig) string { + var b strings.Builder + fmt.Fprintf(&b, "%sssl_certificate %s;\n", DefaultIndent, cfg.CertPath) + fmt.Fprintf(&b, "%sssl_certificate_key %s;\n", DefaultIndent, cfg.KeyPath) + if cfg.TLS13Only { + fmt.Fprintf(&b, "%sssl_protocols TLSv1.3;\n", DefaultIndent) + return b.String() + } + fmt.Fprintf(&b, "%sssl_protocols TLSv1.2 TLSv1.3;\n", DefaultIndent) + return b.String() +} + +// ServerSecurityHeaders 生成统一的安全响应头;HSTS 只在 HTTPS 且 Enabled 时输出。 +func ServerSecurityHeaders(cfg SecurityHeaders) string { + if !cfg.Enabled || !cfg.HTTPS { + return "" + } + maxAge := cfg.HSTSMaxAgeSeconds + if maxAge <= 0 { + maxAge = 31536000 + } + var b strings.Builder + fmt.Fprintf(&b, "%sadd_header Strict-Transport-Security \"max-age=%d; includeSubDomains\" always;\n", DefaultIndent, maxAge) + fmt.Fprintf(&b, "%sadd_header X-Content-Type-Options \"nosniff\" always;\n", DefaultIndent) + fmt.Fprintf(&b, "%sadd_header X-Frame-Options \"SAMEORIGIN\" always;\n", DefaultIndent) + fmt.Fprintf(&b, "%sadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n", DefaultIndent) + return b.String() +} + +// AccessDirectives 生成 allow / deny:先 deny 黑名单,再 allow 白名单,最后 deny all。 +func AccessDirectives(cfg AccessControl, indent string) string { + var b strings.Builder + for _, ip := range cfg.Deny { + if ip = strings.TrimSpace(ip); ip != "" { + fmt.Fprintf(&b, "%sdeny %s;\n", indent, ip) + } + } + b.WriteString(strings.TrimRight(cfg.ChinaDeny, "\n")) + if strings.TrimSpace(cfg.ChinaDeny) != "" { + b.WriteString("\n") + } + allowCount := 0 + for _, ip := range cfg.Allow { + if ip = strings.TrimSpace(ip); ip != "" { + fmt.Fprintf(&b, "%sallow %s;\n", indent, ip) + allowCount++ + } + } + if allowCount > 0 { + fmt.Fprintf(&b, "%sdeny all;\n", indent) + } + return b.String() +} + +// LimitDirectives 生成 location 级限流指令:连接数在前,请求速率在后。 +func LimitDirectives(cfg LimitConfig, indent string) string { + zone := strings.TrimSpace(cfg.Zone) + if zone == "" { + return "" + } + var b strings.Builder + if cfg.Conn > 0 { + fmt.Fprintf(&b, "%slimit_conn %s_conn %d;\n", indent, zone, cfg.Conn) + } + if cfg.Rate > 0 { + if cfg.Burst > 0 { + fmt.Fprintf(&b, "%slimit_req zone=%s_req burst=%d nodelay;\n", indent, zone, cfg.Burst) + } else { + fmt.Fprintf(&b, "%slimit_req zone=%s_req nodelay;\n", indent, zone) + } + } + return b.String() +} + +// UpgradeHeaders 生成 WebSocket 代理头;connectionExpr 由调用方提供(map 变量或字面量)。 +// proxy_http_version 由调用方保留在原有位置,避免改变既有输出顺序。 +func UpgradeHeaders(indent, connectionExpr string) string { + connectionExpr = strings.TrimSpace(connectionExpr) + if connectionExpr == "" { + connectionExpr = "$connection_upgrade" + } + var b strings.Builder + fmt.Fprintf(&b, "%sproxy_set_header Upgrade $http_upgrade;\n", indent) + fmt.Fprintf(&b, "%sproxy_set_header Connection %s;\n", indent, connectionExpr) + return b.String() +} + +// RateLimitZone 生成 http 上下文的请求速率 zone 定义;zone 需包含完整后缀。 +func RateLimitZone(zone string, rate int) string { + zone = strings.TrimSpace(zone) + if zone == "" || rate <= 0 { + return "" + } + return fmt.Sprintf("limit_req_zone $binary_remote_addr zone=%s:10m rate=%dr/s;\n", zone, rate) +} + +// ConnLimitZone 生成 http 上下文的连接数 zone 定义;zone 需包含完整后缀。 +func ConnLimitZone(zone string) string { + zone = strings.TrimSpace(zone) + if zone == "" { + return "" + } + return fmt.Sprintf("limit_conn_zone $binary_remote_addr zone=%s:10m;\n", zone) +} + +// ZoneName 把前缀与片段合法化为 zone 名:只保留小写字母与数字,其余换成下划线。 +func ZoneName(prefix string, parts ...string) string { + var b strings.Builder + writeZonePart(&b, prefix) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('_') + } + writeZonePart(&b, part) + } + return b.String() +} + +func writeZonePart(b *strings.Builder, part string) { + for _, r := range strings.ToLower(part) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + continue + } + b.WriteByte('_') + } +} diff --git a/internal/nginxtmpl/nginxtmpl_test.go b/internal/nginxtmpl/nginxtmpl_test.go new file mode 100644 index 0000000..0a04738 --- /dev/null +++ b/internal/nginxtmpl/nginxtmpl_test.go @@ -0,0 +1,104 @@ +package nginxtmpl + +import ( + "strings" + "testing" +) + +func TestSSLDirectives(t *testing.T) { + base := SSLDirectives(TLSConfig{CertPath: "/certs/fullchain.pem", KeyPath: "/certs/privkey.pem"}) + for _, want := range []string{ + "ssl_certificate /certs/fullchain.pem;", + "ssl_certificate_key /certs/privkey.pem;", + "ssl_protocols TLSv1.2 TLSv1.3;", + } { + if !strings.Contains(base, want) { + t.Fatalf("默认 TLS 缺少 %q:%s", want, base) + } + } + only13 := SSLDirectives(TLSConfig{CertPath: "/c", KeyPath: "/k", TLS13Only: true}) + if !strings.Contains(only13, "ssl_protocols TLSv1.3;") || strings.Contains(only13, "TLSv1.2") { + t.Fatalf("TLS 1.3 only 输出不正确:%s", only13) + } +} + +func TestServerSecurityHeaders(t *testing.T) { + if got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: false}); got != "" { + t.Fatalf("HTTP 不应输出 HSTS:%s", got) + } + if got := ServerSecurityHeaders(SecurityHeaders{Enabled: false, HTTPS: true}); got != "" { + t.Fatalf("关闭安全头时不应输出:%s", got) + } + got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: true}) + for _, want := range []string{ + `Strict-Transport-Security "max-age=31536000; includeSubDomains"`, + `X-Content-Type-Options "nosniff"`, + `X-Frame-Options "SAMEORIGIN"`, + `Referrer-Policy "strict-origin-when-cross-origin"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("安全头缺少 %q:%s", want, got) + } + } +} + +func TestAccessDirectives(t *testing.T) { + got := AccessDirectives(AccessControl{ + Deny: []string{"8.8.8.8", " "}, + ChinaDeny: " if ($is_china = 0) { return 403; }\n", + Allow: []string{"192.168.1.0/24"}, + }, " ") + want := " deny 8.8.8.8;\n if ($is_china = 0) { return 403; }\n allow 192.168.1.0/24;\n deny all;\n" + if got != want { + t.Fatalf("AccessDirectives() = %q, 期望 %q", got, want) + } + if got := AccessDirectives(AccessControl{}, " "); got != "" { + t.Fatalf("空参数不应输出指令:%q", got) + } +} + +func TestLimitDirectives(t *testing.T) { + got := LimitDirectives(LimitConfig{Zone: "fonu_rule_7", Rate: 10, Burst: 20, Conn: 30}, " ") + for _, want := range []string{ + "limit_conn fonu_rule_7_conn 30;", + "limit_req zone=fonu_rule_7_req burst=20 nodelay;", + } { + if !strings.Contains(got, want) { + t.Fatalf("限流指令缺少 %q:%s", want, got) + } + } + noBurst := LimitDirectives(LimitConfig{Zone: "z", Rate: 5}, " ") + if !strings.Contains(noBurst, "limit_req zone=z_req nodelay;") { + t.Fatalf("无 burst 时输出不正确:%s", noBurst) + } +} + +func TestZoneDirectives(t *testing.T) { + if got := RateLimitZone("fonu_app_req", 10); got != "limit_req_zone $binary_remote_addr zone=fonu_app_req:10m rate=10r/s;\n" { + t.Fatalf("RateLimitZone() = %q", got) + } + if got := ConnLimitZone("fonu_app_conn"); got != "limit_conn_zone $binary_remote_addr zone=fonu_app_conn:10m;\n" { + t.Fatalf("ConnLimitZone() = %q", got) + } + if got := RateLimitZone("", 10); got != "" { + t.Fatalf("空 zone 不应输出:%q", got) + } +} + +func TestZoneName(t *testing.T) { + if got := ZoneName("fonu", "App.Example.com"); got != "fonu_app_example_com" { + t.Fatalf("ZoneName() = %q, 期望 fonu_app_example_com", got) + } +} + +func TestUpgradeHeaders(t *testing.T) { + got := UpgradeHeaders(" ", "$connection_upgrade") + for _, want := range []string{ + "proxy_set_header Upgrade $http_upgrade;", + "proxy_set_header Connection $connection_upgrade;", + } { + if !strings.Contains(got, want) { + t.Fatalf("WebSocket 头缺少 %q:%s", want, got) + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index c47789e..2ee6ab0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 11be064..c1636f1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.5.0", + "version": "1.5.1", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 6ac6ad9..abe9cdd 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.0。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.1。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.5.0。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.5.0。
  • -
  • 验证标识更新为 v1.5.0-example-page。
  • +
  • 页面版本提示更新为 v1.5.1。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.5.1。
  • +
  • 验证标识更新为 v1.5.1-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.5.0' -const verificationMarker = 'v1.5.0-example-page' +const verificationRound = 'v1.5.1' +const verificationMarker = 'v1.5.1-example-page' async function refreshVersion() { refreshing.value = true