This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user