This commit is contained in:
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Fonu 文档索引
|
||||
|
||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||
> 最近更新:2026-09-28(v1.5.0)
|
||||
> 最近更新:2026-09-28(v1.5.1)
|
||||
|
||||
## 方案与设计(本目录)
|
||||
|
||||
|
||||
@@ -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 生成逻辑:
|
||||
|
||||
@@ -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`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+33
-62
@@ -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(自定义密码场景)。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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) {
|
||||
|
||||
+29
-33
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginxtmpl"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('_')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.0 示例页面验证标识已生效">
|
||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.1 示例页面验证标识已生效">
|
||||
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
||||
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
||||
</n-alert>
|
||||
@@ -19,7 +19,7 @@
|
||||
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
||||
</div>
|
||||
<div class="example-proof__hint">
|
||||
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.0。
|
||||
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.1。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +33,16 @@
|
||||
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
||||
<template #icon><n-icon :component="FlashOutline" /></template>
|
||||
</StatCard>
|
||||
<StatCard label="页面状态" value="已更新" sub="v1.5.0 前端资源已加载" tone="teal">
|
||||
<StatCard label="页面状态" value="已更新" sub="v1.5.1 前端资源已加载" tone="teal">
|
||||
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
||||
<ul class="example-changes">
|
||||
<li>页面版本提示更新为 v1.5.0。</li>
|
||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.0。</li>
|
||||
<li>验证标识更新为 <span class="mono">v1.5.0-example-page</span>。</li>
|
||||
<li>页面版本提示更新为 v1.5.1。</li>
|
||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.1。</li>
|
||||
<li>验证标识更新为 <span class="mono">v1.5.1-example-page</span>。</li>
|
||||
</ul>
|
||||
</FonuCard>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user