chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+164
View File
@@ -0,0 +1,164 @@
package nginx
import (
"fmt"
"os"
"strings"
"github.com/fonu/fonu/internal/proxy"
)
func osReadDir(path string) ([]os.DirEntry, error) {
return os.ReadDir(path)
}
type CertSource struct {
Domains []string
CertPath string
KeyPath string
}
func FindCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
return findCertificateForHosts(certsDir, hostnames, certs)
}
func HasCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) bool {
return certUsable(findCertificateForHosts(certsDir, hostnames, certs))
}
func findCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
for _, hostname := range hostnames {
for _, source := range certs {
if !hostCoveredByCert(hostname, source.Domains) {
continue
}
if cert := certFromSource(source); cert != nil {
return cert
}
for _, domain := range source.Domains {
if cert := certAt(certsDir, storageDirName(domain)); cert != nil && certUsable(cert) {
return cert
}
}
}
if cert := findCertificate(certsDir, hostname); cert != nil && certUsable(cert) {
return cert
}
}
return scanCertDirectories(certsDir, hostnames)
}
func storageDirName(domain string) string {
domain = strings.TrimSpace(domain)
if strings.HasPrefix(domain, "*.") {
return "wildcard." + strings.TrimPrefix(domain, "*.")
}
return domain
}
func certFromSource(source CertSource) *certFiles {
cert := &certFiles{
CertPath: absNginxPath(source.CertPath),
KeyPath: absNginxPath(source.KeyPath),
}
if !certUsable(cert) {
return nil
}
return cert
}
func hostCoveredByCert(hostname string, domains []string) bool {
hostname = strings.ToLower(strings.TrimSpace(hostname))
for _, domain := range domains {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
continue
}
if domain == hostname {
return true
}
if strings.HasPrefix(domain, "*.") {
suffix := strings.TrimPrefix(domain, "*.")
if hostname == suffix || strings.HasSuffix(hostname, "."+suffix) {
return true
}
}
}
return false
}
func scanCertDirectories(certsDir string, hostnames []string) *certFiles {
entries, err := osReadDir(certsDir)
if err != nil {
return nil
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pattern := dirNameToPattern(entry.Name())
for _, hostname := range hostnames {
if !hostCoveredByCert(hostname, []string{pattern, entry.Name()}) {
continue
}
if cert := certAt(certsDir, pattern); cert != nil && certUsable(cert) {
return cert
}
if cert := certAt(certsDir, entry.Name()); cert != nil && certUsable(cert) {
return cert
}
}
}
return nil
}
func dirNameToPattern(name string) string {
if strings.HasPrefix(name, "wildcard.") {
return "*." + strings.TrimPrefix(name, "wildcard.")
}
return name
}
func certSummary(certs []CertSource) string {
seen := map[string]struct{}{}
var domains []string
for _, cert := range certs {
for _, domain := range cert.Domains {
if domain == "" {
continue
}
if _, ok := seen[domain]; ok {
continue
}
seen[domain] = struct{}{}
domains = append(domains, domain)
}
}
if len(domains) == 0 {
return "无"
}
return strings.Join(domains, ", ")
}
func HTTPSCoverageError(hostname string, certs []CertSource) error {
return fmt.Errorf("域名 %s 未匹配到可用证书(当前证书覆盖:%s),请检查拼写或先在证书页导入/申请", hostname, certSummary(certs))
}
func ValidateHTTPSRules(rules []proxy.Rule, certs []CertSource, certsDir string) error {
for _, rule := range rules {
if !rule.Enabled || !rule.HTTPSEnabled {
continue
}
for _, group := range rule.PortGroups() {
cert := findCertificateForHosts(certsDir, group.Hostnames, certs)
if certUsable(cert) {
continue
}
if len(group.Hostnames) == 0 {
return fmt.Errorf("启用 HTTPS 的规则缺少前端域名")
}
return HTTPSCoverageError(group.Hostnames[0], certs)
}
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package nginx
import (
"os"
"path/filepath"
"testing"
)
func TestFindCertificateForHostsFallsBackToDiskWhenDBPathsMissing(t *testing.T) {
dir := t.TempDir()
certDir := filepath.Join(dir, "certs", "wildcard.example.com")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatal(err)
}
certPath := filepath.Join(certDir, "fullchain.pem")
keyPath := filepath.Join(certDir, "privatekey.pem")
if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte("key"), 0o644); err != nil {
t.Fatal(err)
}
cert := findCertificateForHosts(filepath.Join(dir, "certs"), []string{"app.example.com"}, []CertSource{{
Domains: []string{"example.com", "*.example.com"},
CertPath: "/data/certs/wildcard.example.com/missing.pem",
KeyPath: "/data/certs/wildcard.example.com/missing-key.pem",
}})
if cert == nil || !certUsable(cert) {
t.Fatal("expected disk certificate fallback")
}
if filepath.Clean(cert.CertPath) != filepath.Clean(certPath) {
t.Fatalf("unexpected cert path: %s", cert.CertPath)
}
}
+13
View File
@@ -0,0 +1,13 @@
package nginx
import "testing"
func TestHostCoveredByCert(t *testing.T) {
domains := []string{"example.com", "*.example.com"}
if !hostCoveredByCert("app.example.com", domains) {
t.Fatal("expected wildcard to cover subdomain")
}
if hostCoveredByCert("s.muksi.com", domains) {
t.Fatal("expected typo domain to be rejected")
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !windows
package nginx
import (
"context"
"os/exec"
"syscall"
"time"
)
func prepareStartPlatform(m *Manager, ctx context.Context) {
stopOrphanMasters(ctx, m)
}
func stopOrphanMasters(ctx context.Context, m *Manager) {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err == nil && isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGTERM)
waitProcessExit(pid, nginxTerminateWait)
if isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGKILL)
waitProcessExit(pid, time.Second)
}
}
_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
removePIDFile(m.cfg.NginxPIDFile)
if _, err := exec.LookPath("pkill"); err == nil {
_ = exec.Command("pkill", "-x", "nginx").Run()
time.Sleep(200 * time.Millisecond)
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package nginx
import "context"
func prepareStartPlatform(m *Manager, ctx context.Context) {
stopOrphanMasters(m.cfg.NginxDir())
_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
removePIDFile(m.cfg.NginxPIDFile)
}
+155
View File
@@ -0,0 +1,155 @@
package nginx
import (
"fmt"
"strings"
"github.com/fonu/fonu/internal/proxy"
)
func writeCommentLine(b *strings.Builder, indent int, text string) {
b.WriteString(strings.Repeat(" ", indent) + "# " + text + "\n")
}
func ruleDisplayName(rule proxy.Rule) string {
name := strings.TrimSpace(rule.Name)
if name != "" {
return name
}
if host := rule.PrimaryHost(); host != "" {
return host
}
return fmt.Sprintf("规则 #%d", rule.ID)
}
func writeRulePreambleComments(b *strings.Builder, rule proxy.Rule) {
sec := rule.Security.Normalize()
writeCommentLine(b, 0, fmt.Sprintf("规则:%s(ID %d)", ruleDisplayName(rule), rule.ID))
writeCommentLine(b, 0, fmt.Sprintf("上游:%s", rule.Upstream))
if sec.RateLimitEnabled() || sec.ConnLimitEnabled() {
writeCommentLine(b, 0, "限流 zone 在 http 块中自动生成,本段 location 内引用")
}
b.WriteString("\n")
}
func writeServerBlockHeaderComment(b *strings.Builder, rule proxy.Rule, serverNames string, port int, kind string) {
writeCommentLine(b, 0, fmt.Sprintf("规则:%s", ruleDisplayName(rule)))
writeCommentLine(b, 0, fmt.Sprintf("域名:%s(端口 %d)", serverNames, port))
switch kind {
case "https":
writeCommentLine(b, 0, "HTTPS 反代")
case "redirect":
writeCommentLine(b, 0, "HTTP 跳转 HTTPS")
default:
writeCommentLine(b, 0, "HTTP 反代")
}
b.WriteString("\n")
}
func writeListenComments(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
proto := "HTTP"
if ssl {
proto = "HTTPS"
}
if ipv4 {
writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv4 :%d", proto, port))
}
if ipv6 {
writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv6 :%d", proto, port))
}
}
func writeTLSProtocolComment(b *strings.Builder, sec proxy.SecurityConfig) {
if sec.TLSMin13Only {
writeCommentLine(b, 4, "TLS:仅 TLS 1.3")
} else {
writeCommentLine(b, 4, "TLS:TLS 1.2 / TLS 1.3")
}
}
func writeSecurityHeadersComment(b *strings.Builder) {
writeCommentLine(b, 4, "安全响应头:HSTS / X-Content-Type-Options / X-Frame-Options")
}
func writeErrorPagesComment(b *strings.Builder) {
writeCommentLine(b, 4, "Fonu 自定义错误页(403/404/429/5xx)")
}
func writeLocationHeaderComment(b *strings.Builder, upstream string) {
writeCommentLine(b, 4, fmt.Sprintf("反代 location / → %s", upstream))
}
func writeLocationSecurityComments(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
sec := rule.Security.Normalize()
if len(sec.IPBlacklist) > 0 {
writeCommentLine(b, 8, "IP 黑名单")
}
if sec.ChinaOnly {
if opts.ChinaCIDRAvailable {
writeCommentLine(b, 8, "仅中国大陆:内网 IP 与白名单 IP 段自动豁免")
} else {
writeCommentLine(b, 8, "仅中国大陆:中国 IP 段未就绪,当前仅允许内网 IP")
}
}
if sec.WhitelistEnabled() {
writeCommentLine(b, 8, "IP 白名单模式:仅允许以下 IP,其余拒绝")
}
if sec.ConnLimitEnabled() {
writeCommentLine(b, 8, fmt.Sprintf("连接数限制:每 IP 最多 %d 并发", sec.ConnLimit.Max))
}
if sec.RateLimitEnabled() {
writeCommentLine(b, 8, fmt.Sprintf("请求限流:%d 次/秒,突发 %d(超出返回 429)", sec.RateLimit.Rate, sec.RateLimit.Burst))
}
if sec.BasicAuthEnabled() {
writeCommentLine(b, 8, "Basic Auth:浏览器弹窗认证")
}
}
func writeProxyBodyComments(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
writeCommentLine(b, 8, "反向代理与 WebSocket 头")
if sec.ProxyHostUpstream {
writeCommentLine(b, 8, "Host 头:使用上游地址($proxy_host)")
}
if strings.HasPrefix(upstream, "https://") {
if sec.ProxySSLVerifyOff {
writeCommentLine(b, 8, "上游 HTTPS:跳过证书校验")
} else {
writeCommentLine(b, 8, "上游 HTTPS:启用 SNI")
}
}
}
func writeLimitZoneComments(b *strings.Builder, rule proxy.Rule) {
sec := rule.Security.Normalize()
if sec.RateLimitEnabled() {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:请求限流 zone(%d 次/秒)", ruleDisplayName(rule), sec.RateLimit.Rate))
}
if sec.ConnLimitEnabled() {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:连接数 zone(每 IP %d 并发)", ruleDisplayName(rule), sec.ConnLimit.Max))
}
}
func writeGlobalAllowListComment(b *strings.Builder) {
writeCommentLine(b, 4, "全局 IP 白名单(含内网段默认放行,优先于黑名单,可豁免仅中国大陆限制)")
}
func writeGlobalDenyListComment(b *strings.Builder) {
writeCommentLine(b, 4, "全局 IP 黑名单(系统设置,优先于所有规则)")
}
func writeRealIPComment(b *strings.Builder, cfg TrustedProxyConfig) {
if len(cfg.effectiveCIDRs()) == 0 {
writeCommentLine(b, 4, "未启用信任代理:客户端 IP 取 $remote_addr")
return
}
writeCommentLine(b, 4, fmt.Sprintf("信任代理:从 %s 解析真实客户端 IP", cfg.effectiveHeader()))
}
func writeChinaGeoComment(b *strings.Builder) {
writeCommentLine(b, 4, "仅中国大陆:内网 IP 判定与中国 IP 段库")
}
func writeChinaBypassComment(b *strings.Builder, rule proxy.Rule) {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:仅中国大陆 — 白名单 IP 豁免段", ruleDisplayName(rule)))
}
+75
View File
@@ -0,0 +1,75 @@
package nginx
import (
"fmt"
"strings"
)
func certUsable(cert *certFiles) bool {
if cert == nil {
return false
}
if strings.TrimSpace(cert.CertPath) == "" || strings.TrimSpace(cert.KeyPath) == "" {
return false
}
return fileExists(cert.CertPath) && fileExists(cert.KeyPath)
}
func assertValidSSLBlocks(content string) error {
inServer := false
depth := 0
serverSSL := false
serverCert := false
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !inServer {
if strings.HasPrefix(line, "server") && strings.Contains(line, "{") {
inServer = true
depth = braceDelta(line)
serverSSL = false
serverCert = false
}
continue
}
depth += braceDelta(line)
if isSSLListenLine(line) {
serverSSL = true
}
if isSSLCertificateLine(line) {
serverCert = true
}
if depth <= 0 {
if serverSSL && !serverCert {
return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
}
inServer = false
}
}
if inServer && serverSSL && !serverCert {
return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
}
return nil
}
func braceDelta(line string) int {
return strings.Count(line, "{") - strings.Count(line, "}")
}
func isSSLListenLine(line string) bool {
if !strings.HasPrefix(line, "listen ") {
return false
}
return strings.Contains(line, " ssl;") || strings.HasSuffix(line, " ssl")
}
func isSSLCertificateLine(line string) bool {
return strings.HasPrefix(line, "ssl_certificate ") && !strings.HasPrefix(line, "ssl_certificate_key ")
}
+38
View File
@@ -0,0 +1,38 @@
package nginx
import "testing"
func TestAssertValidSSLBlocksRejectsListenSSLWithoutCertificate(t *testing.T) {
content := `http {
server {
listen 8017 ssl;
listen [::]:8017 ssl;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:1;
}
}
}
`
if err := assertValidSSLBlocks(content); err == nil {
t.Fatal("expected invalid ssl block to fail validation")
}
}
func TestAssertValidSSLBlocksAcceptsCertificateAfterLocation(t *testing.T) {
content := `http {
server {
listen 8017 ssl;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:1;
}
ssl_certificate /data/certs/wildcard.example.com/fullchain.pem;
ssl_certificate_key /data/certs/wildcard.example.com/privatekey.pem;
}
}
`
if err := assertValidSSLBlocks(content); err != nil {
t.Fatalf("expected config with certificate directives to pass: %v", err)
}
}
+259
View File
@@ -0,0 +1,259 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/fonu/fonu/internal/config"
)
const maxCustomBackups = 10
type BackupEntry struct {
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
func EnsureCustomDirs(cfg config.Config) error {
for _, dir := range []string{
cfg.NginxCustomDir(),
cfg.NginxRulesDir(),
cfg.NginxGlobalBackupsDir(),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return nil
}
func ReadGlobalCustom(cfg config.Config) (string, error) {
data, err := os.ReadFile(cfg.NginxGlobalCustomPath())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
func ReadRuleCustom(cfg config.Config, ruleID int64) (string, error) {
data, err := os.ReadFile(cfg.NginxRuleCustomPath(ruleID))
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
func ruleCustomContent(cfg config.Config, ruleID int64, opts GenerateOptions) string {
if opts.RuleCustomOverrides != nil {
if override, ok := opts.RuleCustomOverrides[ruleID]; ok {
return override
}
}
content, _ := ReadRuleCustom(cfg, ruleID)
return content
}
func globalCustomPath(cfg config.Config, opts GenerateOptions) string {
if opts.GlobalCustomPathOverride != "" {
return opts.GlobalCustomPathOverride
}
return cfg.NginxGlobalCustomPath()
}
func globalCustomShouldInclude(cfg config.Config, opts GenerateOptions) bool {
if strings.TrimSpace(opts.GlobalCustomOverride) != "" {
return true
}
if opts.GlobalCustomPathOverride != "" {
data, err := os.ReadFile(opts.GlobalCustomPathOverride)
return err == nil && strings.TrimSpace(string(data)) != ""
}
content, _ := ReadGlobalCustom(cfg)
return strings.TrimSpace(content) != ""
}
func BackupFile(srcPath, backupDir string) (string, error) {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", err
}
data, err := os.ReadFile(srcPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
name := time.Now().UTC().Format("20060102T150405.000Z") + ".conf"
dst := filepath.Join(backupDir, name)
if err := os.WriteFile(dst, data, 0o644); err != nil {
return "", err
}
if err := pruneBackups(backupDir, maxCustomBackups); err != nil {
return "", err
}
return name, nil
}
func ListBackups(backupDir string) ([]BackupEntry, error) {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return nil, err
}
entries, err := os.ReadDir(backupDir)
if err != nil {
return nil, err
}
var out []BackupEntry
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
out = append(out, BackupEntry{Name: entry.Name(), CreatedAt: info.ModTime().UTC()})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name > out[j].Name
})
return out, nil
}
func RestoreBackup(backupDir, backupName, targetPath string) error {
src := filepath.Join(backupDir, backupName)
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("备份不存在")
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(targetPath, data, 0o644)
}
func WriteGlobalCustom(cfg config.Config, content string) error {
if err := EnsureCustomDirs(cfg); err != nil {
return err
}
return os.WriteFile(cfg.NginxGlobalCustomPath(), []byte(content), 0o644)
}
func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error {
if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil {
return err
}
return os.WriteFile(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644)
}
func RemoveRuleCustom(cfg config.Config, ruleID int64) error {
path := cfg.NginxRuleCustomPath(ruleID)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
backupDir := cfg.NginxRuleBackupsDir(ruleID)
_ = os.RemoveAll(backupDir)
return nil
}
func SaveGlobalCustomWithBackup(cfg config.Config, content string) (string, error) {
path := cfg.NginxGlobalCustomPath()
var backupName string
if _, err := os.Stat(path); err == nil {
name, err := BackupFile(path, cfg.NginxGlobalBackupsDir())
if err != nil {
return "", err
}
backupName = name
}
if err := WriteGlobalCustom(cfg, content); err != nil {
return "", err
}
return backupName, nil
}
func SaveRuleCustomWithBackup(cfg config.Config, ruleID int64, content string) (string, error) {
path := cfg.NginxRuleCustomPath(ruleID)
var backupName string
if _, err := os.Stat(path); err == nil {
name, err := BackupFile(path, cfg.NginxRuleBackupsDir(ruleID))
if err != nil {
return "", err
}
backupName = name
}
if err := WriteRuleCustom(cfg, ruleID, content); err != nil {
return "", err
}
return backupName, nil
}
func pruneBackups(dir string, keep int) error {
entries, err := ListBackups(dir)
if err != nil {
return err
}
for i := keep; i < len(entries); i++ {
_ = os.Remove(filepath.Join(dir, entries[i].Name))
}
return nil
}
func DefaultGlobalHTTPSnippet() string {
return `# 全局 http 块片段(自动生成)
# 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖)
client_max_body_size 50m;
`
}
func GenerateGlobalFramework(cfg config.Config) string {
return fmt.Sprintf(`# 以下部分由 Fonu 自动生成,请勿在此编辑
# 主进程
worker_processes auto;
# 错误日志
error_log %s warn;
# PID 文件
pid %s;
# 事件模型
events {
worker_connections 1024;
}
http {
# MIME 类型
include %s;
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;
# 传输优化
sendfile on;
keepalive_timeout 65;
# WebSocket 升级映射
# map $http_upgrade $connection_upgrade { ... }
# limit_req_zone / geo / real_ip 等由规则与系统设置动态生成
# 全局自定义片段插入在上述动态块之后、各 server {} 之前
}
`, absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")),
absNginxPath(cfg.NginxPIDFile),
absNginxPath(cfg.NginxMimeTypes),
absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")))
}
+136
View File
@@ -0,0 +1,136 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateRuleBlocks(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
rule := proxy.Rule{
ID: 1,
Upstream: "http://127.0.0.1:8080",
ListenPort: 8011,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "example.com"}},
HTTPSEnabled: false,
Enabled: true,
}
out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "server_name example.com") {
t.Fatalf("missing server_name: %s", out)
}
if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080") {
t.Fatalf("missing proxy_pass: %s", out)
}
}
func TestGenerateWithCustomRuleAndGlobal(t *testing.T) {
dir := t.TempDir()
cfg := config.Config{DataDir: dir}
if err := EnsureCustomDirs(cfg); err != nil {
t.Fatal(err)
}
customGlobal := "gzip on;\n"
if err := WriteGlobalCustom(cfg, customGlobal); err != nil {
t.Fatal(err)
}
customRule := "server {\n listen 8011;\n server_name custom.test;\n return 444;\n}\n"
if err := WriteRuleCustom(cfg, 7, customRule); err != nil {
t.Fatal(err)
}
rules := []proxy.Rule{{
ID: 7,
Upstream: "http://127.0.0.1:1",
ListenPort: 8011,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "ignored.test"}},
Enabled: true,
NginxMode: "custom",
}}
out, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "include "+strings.ReplaceAll(absNginxPath(cfg.NginxGlobalCustomPath()), "\\", "/")) &&
!strings.Contains(out, absNginxPath(cfg.NginxGlobalCustomPath())) {
t.Fatalf("missing global include: %s", out)
}
if !strings.Contains(out, "server_name custom.test") {
t.Fatalf("missing custom rule block: %s", out)
}
if strings.Contains(out, "ignored.test") {
t.Fatalf("auto-generated host leaked into custom mode: %s", out)
}
}
func TestBackupFileKeepsRecent(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.conf")
if err := os.WriteFile(src, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
backupDir := filepath.Join(dir, "backups")
if _, err := BackupFile(src, backupDir); err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Millisecond)
if err := os.WriteFile(src, []byte("v2"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := BackupFile(src, backupDir); err != nil {
t.Fatal(err)
}
entries, err := ListBackups(backupDir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("backups=%d want 2", len(entries))
}
if err := RestoreBackup(backupDir, entries[1].Name, src); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if string(data) != "v1" {
t.Fatalf("restored=%q want v1", string(data))
}
}
func TestSaveRuleCustomWithBackup(t *testing.T) {
dir := t.TempDir()
cfg := config.Config{DataDir: dir}
if err := WriteRuleCustom(cfg, 3, "first"); err != nil {
t.Fatal(err)
}
if _, err := SaveRuleCustomWithBackup(cfg, 3, "second"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(cfg.NginxRuleCustomPath(3))
if err != nil {
t.Fatal(err)
}
if string(data) != "second" {
t.Fatalf("content=%q", string(data))
}
backups, err := ListBackups(cfg.NginxRuleBackupsDir(3))
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Fatalf("backups=%d want 1", len(backups))
}
}
+8
View File
@@ -0,0 +1,8 @@
package nginx
import "embed"
// Bundled for Docker/runtime when web/assets is not on disk.
//
//go:embed assets/error.png assets/429.png
var errorAssetFS embed.FS
+146
View File
@@ -0,0 +1,146 @@
package nginx
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/fonu/fonu/internal/config"
)
func EnsureErrorPages(cfg config.Config) error {
dir := cfg.ErrorsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
ensureNginxReadable(dir, true)
pages := []struct {
file, sprite, bgPos, title, msg string
code int
}{
{"403.html", "s403", "0% 0%", "访问被拒绝", "你没有权限访问此资源。", 403},
{"404.html", "s404", "100% 0%", "页面不存在", "请求的地址不存在或已被移除。", 404},
{"500.html", "s500", "0% 100%", "服务异常", "服务器遇到错误,请稍后再试。", 500},
{"503.html", "s503", "100% 100%", "服务维护中", "服务暂时不可用,请稍后再试。", 503},
{"429.html", "429", "", "请求过于频繁", "你的请求次数过多,请稍后再试。", 429},
}
for _, p := range pages {
html := errorPageHTML(p.code, p.title, p.msg, p.sprite, p.bgPos)
target := filepath.Join(dir, p.file)
if err := os.WriteFile(target, []byte(html), 0o644); err != nil {
return err
}
ensureNginxReadable(target, false)
}
for _, name := range []string{"error.png", "429.png"} {
if err := ensureErrorAsset(dir, name); err != nil {
return err
}
}
return nil
}
func ensureErrorAsset(dir, name string) error {
target := filepath.Join(dir, name)
if src := findErrorAsset(name); src != "" {
if err := copyFileIfChanged(src, target); err != nil {
return err
}
} else if err := writeEmbeddedErrorAsset(name, target); err != nil {
return err
}
if err := verifyErrorAsset(target); err != nil {
return err
}
ensureNginxReadable(target, false)
return nil
}
func verifyErrorAsset(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("error asset missing after write: %s: %w", path, err)
}
if info.Size() == 0 {
return fmt.Errorf("error asset is empty: %s", path)
}
return nil
}
func writeEmbeddedErrorAsset(name, target string) error {
data, err := errorAssetFS.ReadFile("assets/" + name)
if err != nil {
return fmt.Errorf("embedded error asset %s: %w", name, err)
}
return os.WriteFile(target, data, 0o644)
}
func findErrorAsset(name string) string {
candidates := []string{
filepath.Join("web", "assets", "image", name),
filepath.Join("/app", "web", "assets", "image", name),
filepath.Join("internal", "nginx", "assets", name),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
func copyFileIfChanged(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func errorPageHTML(code int, title, message, sprite, bgPos string) string {
var imgBlock string
if sprite == "429" {
imgBlock = `<img class="illus" src="/fonu-errors/429.png" alt="">`
} else {
imgBlock = fmt.Sprintf(`<div class="sprite" role="img" aria-label="%d"></div>`, code)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%d - %s</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e2e8f0;padding:24px}
.card{text-align:center;max-width:420px;width:100%%}
.sprite{width:280px;height:186px;margin:0 auto 24px;background:url(/fonu-errors/error.png) no-repeat;background-size:200%% 200%%;background-position:%s}
.illus{width:280px;height:auto;margin:0 auto 24px;display:block}
.code{font-size:14px;font-weight:600;color:#22c55e;letter-spacing:.08em;margin-bottom:8px}
h1{font-size:22px;font-weight:600;margin-bottom:8px}
p{font-size:14px;color:#94a3b8;line-height:1.6}
@media (prefers-color-scheme:light){body{background:#f8fafc;color:#1e293b}p{color:#64748b}}
</style>
</head>
<body>
<div class="card">
%s
<div class="code">%d</div>
<h1>%s</h1>
<p>%s</p>
</div>
</body>
</html>`, code, title, bgPos, imgBlock, code, title, message)
}
+45
View File
@@ -0,0 +1,45 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
)
func TestEnsureErrorPagesWritesAssets(t *testing.T) {
dir := t.TempDir()
cfg := config.Config{DataDir: dir}
if err := EnsureErrorPages(cfg); err != nil {
t.Fatal(err)
}
errorsDir := cfg.ErrorsDir()
for _, name := range []string{"403.html", "404.html", "error.png", "429.png"} {
path := filepath.Join(errorsDir, name)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("missing %s: %v", name, err)
}
if info.Size() == 0 {
t.Fatalf("%s is empty", name)
}
}
html, err := os.ReadFile(filepath.Join(errorsDir, "403.html"))
if err != nil {
t.Fatal(err)
}
body := string(html)
for _, want := range []string{
"/fonu-errors/error.png",
"background:url(/fonu-errors/error.png)",
} {
if !strings.Contains(body, want) {
t.Fatalf("403.html missing %q:\n%s", want, body)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package nginx
import (
"os"
"runtime"
)
// Debian/Ubuntu nginx worker user.
const nginxWorkerUID = 33
const nginxWorkerGID = 33
func ensureNginxReadable(path string, dir bool) {
mode := os.FileMode(0o644)
if dir {
mode = 0o755
}
_ = os.Chmod(path, mode)
if runtime.GOOS == "linux" {
_ = os.Chown(path, nginxWorkerUID, nginxWorkerGID)
}
}
+273
View File
@@ -0,0 +1,273 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func Generate(cfg config.Config, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) {
var b strings.Builder
b.WriteString(`worker_processes auto;
error_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")) + ` warn;
pid ` + absNginxPath(cfg.NginxPIDFile) + `;
events {
worker_connections 1024;
}
http {
include ` + absNginxPath(cfg.NginxMimeTypes) + `;
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 ` + absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")) + ` fonu_access;
sendfile on;
keepalive_timeout 65;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_status 429;
limit_conn_status 503;
`)
writeRealIPDirectives(&b, opts.TrustedProxy)
writeGlobalAllowList(&b, opts.GlobalIPWhitelist)
writeGlobalDenyList(&b, opts.GlobalIPBlacklist)
writeLimitZones(&b, rules)
writeChinaGeoBlocks(&b, cfg, opts, rules)
writeChinaOnlyBypassGeo(&b, rules, opts)
writeGlobalCustomInclude(&b, cfg, opts)
for _, rule := range rules {
if !rule.Enabled {
continue
}
if rule.NginxMode == "custom" {
if err := writeCustomRuleBlocks(&b, cfg, rule.ID, opts); err != nil {
return "", err
}
continue
}
if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil {
return "", err
}
}
b.WriteString("}\n")
content := b.String()
if err := assertValidSSLBlocks(content); err != nil {
return "", err
}
return content, nil
}
func GenerateRuleBlocks(cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) {
var b strings.Builder
writeRulePreambleComments(&b, rule)
if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil {
return "", err
}
content := b.String()
if err := assertValidSSLBlocks(content); err != nil {
return "", err
}
return content, nil
}
func writeGlobalCustomInclude(b *strings.Builder, cfg config.Config, opts GenerateOptions) {
if strings.TrimSpace(opts.GlobalCustomOverride) != "" {
writeIndentedHTTPSnippet(b, opts.GlobalCustomOverride)
return
}
if globalCustomShouldInclude(cfg, opts) {
path := globalCustomPath(cfg, opts)
b.WriteString(fmt.Sprintf(" include %s;\n\n", absNginxPath(path)))
return
}
writeIndentedHTTPSnippet(b, DefaultGlobalHTTPSnippet())
}
func writeIndentedHTTPSnippet(b *strings.Builder, content string) {
for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
b.WriteString(" " + line + "\n")
}
b.WriteString("\n")
}
func writeCustomRuleBlocks(b *strings.Builder, cfg config.Config, ruleID int64, opts GenerateOptions) error {
content := strings.TrimSpace(ruleCustomContent(cfg, ruleID, opts))
if content == "" {
return fmt.Errorf("规则 %d 的自定义 Nginx 配置为空", ruleID)
}
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
b.WriteString(content)
return nil
}
func writeRuleBlocks(b *strings.Builder, cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) error {
for _, group := range rule.PortGroups() {
if len(group.Hostnames) == 0 {
continue
}
serverNames := strings.Join(group.Hostnames, " ")
cert := findCertificateForHosts(cfg.CertsDir(), group.Hostnames, certs)
useHTTPS := rule.HTTPSEnabled && certUsable(cert)
wroteBlock := false
if useHTTPS {
if !appendSSLServerBlock(b, cfg, group.Port, serverNames, cert, rule, opts) {
return fmt.Errorf("HTTPS 证书文件不可用,请重新导入证书或关闭 HTTPS")
}
wroteBlock = true
}
if rule.HTTPRedirect && useHTTPS {
writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "redirect")
b.WriteString("server {\n")
writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames))
writeErrorPages(b, cfg)
writeCommentLine(b, 4, "301 跳转至 HTTPS")
b.WriteString(" return 301 https://$host:$server_port$request_uri;\n")
b.WriteString("}\n")
wroteBlock = true
}
if !wroteBlock {
writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "http")
b.WriteString("server {\n")
writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames))
writeErrorPages(b, cfg)
writeLocationWithSecurity(b, cfg, rule, opts)
b.WriteString("}\n")
}
}
return nil
}
func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serverNames string, cert *certFiles, rule proxy.Rule, opts GenerateOptions) bool {
if !certUsable(cert) {
return false
}
var block strings.Builder
writeServerBlockHeaderComment(&block, rule, serverNames, port, "https")
block.WriteString("server {\n")
writeListenComments(&block, port, true, rule.ListenIPv4, rule.ListenIPv6)
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)
writeServerSecurityHeaders(&block, rule.Security, true)
writeErrorPages(&block, cfg)
writeLocationWithSecurity(&block, cfg, rule, opts)
block.WriteString("}\n")
b.WriteString(block.String())
return true
}
func writeLocationWithSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) {
writeLocationHeaderComment(b, rule.Upstream)
b.WriteString(" location / {\n")
writeLocationSecurity(b, cfg, rule, opts)
writeProxyLocationBody(b, rule.Upstream, rule.Security)
b.WriteString(" }\n")
}
func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
sec = sec.Normalize()
writeProxyBodyComments(b, upstream, sec)
hostHeader := "$host"
if sec.ProxyHostUpstream {
hostHeader = "$proxy_host"
}
sslBlock := ""
if strings.HasPrefix(upstream, "https://") {
if sec.ProxySSLVerifyOff {
sslBlock = ` proxy_ssl_verify off;
proxy_ssl_server_name on;
`
} else {
sslBlock = ` proxy_ssl_server_name on;
`
}
}
b.WriteString(fmt.Sprintf(`%s proxy_pass %s;
proxy_http_version 1.1;
proxy_set_header Host %s;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
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;
proxy_buffering off;
proxy_read_timeout 3600s;
`, sslBlock, upstream, hostHeader))
}
func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
sslSuffix := ""
if ssl {
sslSuffix = " ssl"
}
if ipv4 {
b.WriteString(fmt.Sprintf(" listen %d%s;\n", port, sslSuffix))
}
if ipv6 {
b.WriteString(fmt.Sprintf(" listen [::]:%d%s;\n", port, sslSuffix))
}
}
type certFiles struct {
CertPath string
KeyPath string
}
func findCertificate(certsDir, domain string) *certFiles {
parts := strings.Split(domain, ".")
for i := 0; i < len(parts)-1; i++ {
wildcard := "*." + strings.Join(parts[i:], ".")
if c := certAt(certsDir, wildcard); c != nil {
return c
}
}
if c := certAt(certsDir, domain); c != nil {
return c
}
return nil
}
func certAt(certsDir, name string) *certFiles {
safeName := strings.ReplaceAll(name, "*", "wildcard")
dir := filepath.Join(certsDir, safeName)
certPath := filepath.Join(dir, "fullchain.pem")
keyPath := filepath.Join(dir, "privatekey.pem")
if fileExists(certPath) && fileExists(keyPath) {
return &certFiles{CertPath: absNginxPath(certPath), KeyPath: absNginxPath(keyPath)}
}
return nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -0,0 +1,56 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestChinaOnlyBypassGeoWithoutUserWhitelist(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf")
if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
rules := []proxy.Rule{{
ID: 3,
ListenPort: 8080,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://127.0.0.1:3000",
Enabled: true,
HTTPSEnabled: false,
Security: proxy.SecurityConfig{ChinaOnly: true},
}}
content, err := Generate(cfg, rules, nil, GenerateOptions{ChinaCIDRAvailable: true})
if err != nil {
t.Fatalf("generate: %v", err)
}
for _, want := range []string{
"geo $fonu_client_ip $fonu_rule_3_china_bypass",
"192.168.0.0/16 1;",
"10.0.0.0/8 1;",
"if ($fonu_rule_3_china_bypass = 1)",
} {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in:\n%s", want, content)
}
}
}
@@ -0,0 +1,59 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateChinaCIDRUsesFinalPathWhenOverrideCleared(t *testing.T) {
dir := t.TempDir()
cidrDir := filepath.Join(dir, "nginx")
if err := os.MkdirAll(cidrDir, 0o755); err != nil {
t.Fatal(err)
}
finalPath := filepath.Join(cidrDir, "china_cidr.conf")
if err := os.WriteFile(finalPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
if err := os.WriteFile(cfg.NginxMimeTypes, []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
rules := []proxy.Rule{{
ID: 1,
ListenPort: 8080,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://127.0.0.1:3000",
Enabled: true,
Security: proxy.SecurityConfig{ChinaOnly: true},
}}
opts := GenerateOptions{
ChinaCIDRAvailable: true,
ChinaCIDRPathOverride: filepath.Join(cidrDir, "china_cidr.conf.tmp"),
}
opts.ChinaCIDRPathOverride = ""
content, err := Generate(cfg, rules, nil, opts)
if err != nil {
t.Fatal(err)
}
if strings.Contains(content, "china_cidr.conf.tmp") {
t.Fatalf("expected final china cidr path, got tmp reference:\n%s", content)
}
if !strings.Contains(content, "china_cidr.conf") {
t.Fatalf("missing china_cidr.conf include:\n%s", content)
}
}
+49
View File
@@ -0,0 +1,49 @@
package nginx
import (
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateRuleBlocksIncludesComments(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
rule := proxy.Rule{
ID: 3,
Name: "fonu",
Upstream: "http://127.0.0.1:8080",
ListenPort: 8011,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "s.example.com"}},
HTTPSEnabled: false,
Enabled: true,
Security: proxy.SecurityConfig{
IPBlacklist: []string{"192.168.8.2"},
IPWhitelistMode: true,
IPWhitelist: []string{"192.168.8.1"},
RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20},
ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20},
},
}
out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"# 规则:fonu",
"# 上游:http://127.0.0.1:8080",
"# 限流 zone 在 http 块中自动生成",
"# IP 黑名单",
"# IP 白名单模式",
"# 请求限流:10 次/秒,突发 20",
"# 连接数限制:每 IP 最多 20 并发",
"# 反代 location /",
"# 反向代理与 WebSocket 头",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing comment %q in:\n%s", want, out)
}
}
}
@@ -0,0 +1,38 @@
package nginx
import (
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateHTTPSWithoutCertUsesHTTPOnly(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(),
NginxPIDFile: t.TempDir() + "/nginx.pid",
NginxMimeTypes: t.TempDir() + "/mime.types",
}
rules := []proxy.Rule{{
ListenPort: 8011,
ListenIPv4: true,
ListenIPv6: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://192.168.8.3:6893",
HTTPSEnabled: true,
HTTPRedirect: true,
Enabled: true,
}}
content, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
if strings.Contains(content, " ssl") {
t.Fatalf("expected no ssl listen without certificate:\n%s", content)
}
if !strings.Contains(content, "proxy_pass http://192.168.8.3:6893") {
t.Fatalf("expected http proxy fallback:\n%s", content)
}
}
@@ -0,0 +1,56 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateHTTPSWithCertIncludesCertificateDirectives(t *testing.T) {
dir := t.TempDir()
certDir := filepath.Join(dir, "certs", "wildcard.example.com")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
rules := []proxy.Rule{{
ListenPort: 8011,
ListenIPv4: true,
ListenIPv6: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://192.168.8.3:6893",
HTTPSEnabled: true,
HTTPRedirect: true,
Enabled: true,
}}
content, err := Generate(cfg, rules, []CertSource{{
Domains: []string{"*.example.com", "example.com"},
CertPath: filepath.Join(certDir, "fullchain.pem"),
KeyPath: filepath.Join(certDir, "privatekey.pem"),
}}, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
if !strings.Contains(content, "listen 8011 ssl;") {
t.Fatalf("expected ssl listen:\n%s", content)
}
if !strings.Contains(content, "ssl_certificate") || !strings.Contains(content, "ssl_certificate_key") {
t.Fatalf("expected certificate directives:\n%s", content)
}
}
+51
View File
@@ -0,0 +1,51 @@
package nginx
import (
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateMultiHostCustomPort(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(),
NginxPIDFile: t.TempDir() + "/nginx.pid",
NginxMimeTypes: t.TempDir() + "/mime.types",
}
port6893 := 6893
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://192.168.8.3:6893",
ListenPort: 8011,
ListenIPv4: true,
ListenIPv6: true,
Hosts: []proxy.Host{
{Hostname: "1.example.com"},
{Hostname: "2.example.com"},
{Hostname: "example.org"},
{Hostname: "example.com", ListenPort: &port6893},
},
HTTPSEnabled: false,
HTTPRedirect: false,
Enabled: true,
}}
content, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
for _, want := range []string{
"listen 8011;",
"listen [::]:8011;",
"server_name 1.example.com 2.example.com example.org",
"listen 6893;",
"listen [::]:6893;",
"server_name example.com",
} {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in generated config:\n%s", want, content)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateHTTPSBlockBeforeHTTPRedirect(t *testing.T) {
dir := t.TempDir()
certDir := filepath.Join(dir, "certs", "wildcard.example.com")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
rules := []proxy.Rule{{
ListenPort: 8017,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://127.0.0.1:1",
HTTPSEnabled: true,
HTTPRedirect: true,
Enabled: true,
}}
certs := []CertSource{{
Domains: []string{"*.example.com"},
CertPath: filepath.Join(certDir, "fullchain.pem"),
KeyPath: filepath.Join(certDir, "privatekey.pem"),
}}
content, err := Generate(cfg, rules, certs, GenerateOptions{})
if err != nil {
t.Fatalf("generate: %v", err)
}
sslIdx := strings.Index(content, "listen 8017 ssl;")
redirectIdx := strings.Index(content, "return 301 https://")
if sslIdx < 0 || redirectIdx < 0 {
t.Fatalf("missing expected directives:\n%s", content)
}
if sslIdx > redirectIdx {
t.Fatalf("expected ssl server block before http redirect:\n%s", content)
}
}
+95
View File
@@ -0,0 +1,95 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateSecurityDirectives(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf")
if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
rules := []proxy.Rule{{
ID: 7,
ListenPort: 8080,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
Upstream: "http://127.0.0.1:3000",
Enabled: true,
HTTPSEnabled: false,
Security: proxy.SecurityConfig{
IPBlacklist: []string{"1.2.3.4", "10.0.0.0/8"},
IPWhitelistMode: true,
IPWhitelist: []string{"192.168.1.0/24"},
ChinaOnly: true,
BasicAuth: &proxy.BasicAuthConfig{
Enabled: true,
Username: "user",
PasswordHash: "$2a$10$abcdefghijklmnopqrstuv",
},
RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20},
ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20},
},
}}
opts := GenerateOptions{
GlobalIPWhitelist: []string{"203.0.113.10"},
GlobalIPBlacklist: []string{"8.8.8.8"},
ChinaCIDRAvailable: true,
}
content, err := Generate(cfg, rules, nil, opts)
if err != nil {
t.Fatalf("generate: %v", err)
}
checks := []string{
"allow 10.0.0.0/8;",
"allow 203.0.113.10;",
"deny 8.8.8.8;",
"limit_req_zone $binary_remote_addr zone=fonu_rule_7_req",
"limit_conn_zone $binary_remote_addr zone=fonu_rule_7_conn",
"deny 1.2.3.4;",
"geo $fonu_client_ip $fonu_is_private",
"geo $fonu_client_ip $fonu_rule_7_china_bypass",
"192.168.0.0/16 1;",
"192.168.1.0/24 1;",
"203.0.113.10 1;",
"set $fonu_china_block 0;",
"if ($fonu_is_private = 1)",
"if ($fonu_rule_7_china_bypass = 1)",
"allow 192.168.1.0/24;",
"deny all;",
"auth_basic",
"limit_req zone=fonu_rule_7_req burst=20",
"limit_conn fonu_rule_7_conn 20",
"error_page 403 /fonu-errors/403.html",
"location = /fonu-errors/error.png",
"location = /fonu-errors/429.png",
"X-Forwarded-Host $host",
"X-Forwarded-Port $server_port",
"X-Real-Proto $scheme",
}
for _, want := range checks {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in:\n%s", want, content)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package nginx
import (
"os"
"path/filepath"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateMixedRulesWithoutCerts(t *testing.T) {
dir := t.TempDir()
mimePath := filepath.Join(dir, "mime.types")
if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: mimePath,
}
rules := []proxy.Rule{
{
ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
{
ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
}
content, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
if err := assertValidSSLBlocks(content); err != nil {
t.Fatalf("invalid ssl blocks: %v\n%s", err, content)
}
}
+44
View File
@@ -0,0 +1,44 @@
package nginx
import (
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestGenerateIncludesWebSocketAndProxy(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(),
NginxPIDFile: t.TempDir() + "/nginx.pid",
NginxMimeTypes: t.TempDir() + "/mime.types",
}
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://192.168.1.10:5666",
ListenPort: 80,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: false,
HTTPRedirect: false,
Enabled: true,
}}
content, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
for _, want := range []string{
"client_max_body_size 50m;",
"listen 80;",
"server_name nas.example.com",
"proxy_pass http://192.168.1.10:5666",
"proxy_set_header Upgrade $http_upgrade",
"proxy_buffering off",
} {
if !strings.Contains(content, want) {
t.Fatalf("missing %q in generated config", want)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package nginx
import (
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
)
func TestGenerateGlobalFrameworkIncludesComments(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
out := GenerateGlobalFramework(cfg)
for _, want := range []string{
"# 以下部分由 Fonu 自动生成",
"# 主进程",
"# MIME 类型",
"# 访问日志格式与路径",
"# 传输优化",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing comment %q in:\n%s", want, out)
}
}
if strings.Contains(out, "client_max_body_size") {
t.Fatalf("framework should not include client_max_body_size:\n%s", out)
}
}
func TestDefaultGlobalHTTPSnippet(t *testing.T) {
out := DefaultGlobalHTTPSnippet()
if !strings.Contains(out, "client_max_body_size 50m;") {
t.Fatalf("missing upload limit in snippet:\n%s", out)
}
if !strings.Contains(out, "# 上传大小限制") {
t.Fatalf("missing comment in snippet:\n%s", out)
}
}
+70
View File
@@ -0,0 +1,70 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
// nginxHtpasswdHash normalizes bcrypt hashes for nginx/apr auth_basic.
// Go's bcrypt uses the $2a$ prefix; some nginx builds only accept $2y$.
func nginxHtpasswdHash(hash string) string {
if strings.HasPrefix(hash, "$2a$") {
return "$2y$" + hash[4:]
}
return hash
}
func HtpasswdDir(cfg config.Config) string {
return filepath.Join(cfg.NginxDir(), "htpasswd")
}
func HtpasswdPath(cfg config.Config, ruleID int64) string {
return filepath.Join(HtpasswdDir(cfg), fmt.Sprintf("rule_%d.conf", ruleID))
}
func SyncHtpasswdFiles(cfg config.Config, rules []proxy.Rule) error {
dir := HtpasswdDir(cfg)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
ensureNginxReadable(dir, true)
active := map[int64]bool{}
for _, rule := range rules {
path := HtpasswdPath(cfg, rule.ID)
if rule.Security.BasicAuthEnabled() {
active[rule.ID] = true
line := fmt.Sprintf("%s:%s\n", rule.Security.BasicAuth.Username, nginxHtpasswdHash(rule.Security.BasicAuth.PasswordHash))
if err := os.WriteFile(path, []byte(line), 0o644); err != nil {
return err
}
ensureNginxReadable(path, false)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
var id int64
if _, err := fmt.Sscanf(entry.Name(), "rule_%d.conf", &id); err != nil {
continue
}
if !active[id] {
_ = os.Remove(filepath.Join(dir, entry.Name()))
}
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package nginx
import "testing"
func TestNginxHtpasswdHashNormalizesBcryptPrefix(t *testing.T) {
in := "$2a$10$abcdefghijklmnopqrstuv"
got := nginxHtpasswdHash(in)
want := "$2y$10$abcdefghijklmnopqrstuv"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestNginxHtpasswdHashLeavesOtherFormats(t *testing.T) {
in := "$apr1$abc123"
if got := nginxHtpasswdHash(in); got != in {
t.Fatalf("got %q want %q", got, in)
}
}
+60
View File
@@ -0,0 +1,60 @@
package nginx
import (
"os"
"runtime"
"strings"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestSyncHtpasswdFilesWritesBasicAuth(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
rules := []proxy.Rule{{
ID: 3,
Enabled: true,
Security: proxy.SecurityConfig{
BasicAuth: &proxy.BasicAuthConfig{
Enabled: true,
Username: "admin",
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwx.yz012345678901234567890",
},
},
}}
if err := SyncHtpasswdFiles(cfg, rules); err != nil {
t.Fatalf("sync htpasswd: %v", err)
}
path := HtpasswdPath(cfg, 3)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read htpasswd: %v", err)
}
if !strings.HasPrefix(string(data), "admin:$2y$10$") {
t.Fatalf("unexpected htpasswd content: %q", data)
}
if runtime.GOOS == "linux" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat htpasswd: %v", err)
}
if info.Mode().Perm() != 0o644 {
t.Fatalf("expected htpasswd mode 0644, got %o", info.Mode().Perm())
}
}
stripped := rules[0].Security.ForAPI()
rules[0].Security = stripped
if rules[0].Security.BasicAuthEnabled() {
t.Fatal("ForAPI should strip password hash for API responses")
}
if err := SyncHtpasswdFiles(cfg, rules); err != nil {
t.Fatalf("sync stripped htpasswd: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected htpasswd removed when hash stripped, stat err=%v", err)
}
}
+417
View File
@@ -0,0 +1,417 @@
package nginx
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/proxy"
)
const (
nginxCmdTimeout = 8 * time.Second
nginxStartWait = 5 * time.Second
nginxTerminateWait = 3 * time.Second
)
type Manager struct {
cfg config.Config
logger *slog.Logger
opts GenerateOptions
}
type ApplyResult struct {
Reloaded bool
Message string
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger}
}
func (m *Manager) SetGenerateOptions(opts GenerateOptions) {
m.opts = opts
}
func (m *Manager) CurrentOptions() GenerateOptions {
return m.opts
}
func (m *Manager) EnsureDirs() error {
for _, dir := range []string{m.cfg.NginxDir(), m.cfg.LogsDir(), m.cfg.CertsDir()} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
workDirs := []string{
"logs",
"client_body_temp",
"proxy_temp",
"fastcgi_temp",
"uwsgi_temp",
"scgi_temp",
filepath.Join("temp", "client_body_temp"),
filepath.Join("temp", "proxy_temp"),
filepath.Join("temp", "fastcgi_temp"),
filepath.Join("temp", "uwsgi_temp"),
filepath.Join("temp", "scgi_temp"),
}
for _, name := range workDirs {
if err := os.MkdirAll(filepath.Join(m.cfg.NginxDir(), name), 0o755); err != nil {
return err
}
}
return nil
}
func (m *Manager) nginxGlobalArgs() []string {
prefix := absNginxPath(m.cfg.NginxDir())
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
errorLog := absNginxPath(filepath.Join(m.cfg.LogsDir(), "error.log"))
return []string{"-p", prefix, "-e", errorLog}
}
func (m *Manager) available() bool {
_, err := exec.LookPath(m.cfg.NginxBin)
return err == nil
}
func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSource) (ApplyResult, error) {
if err := m.EnsureDirs(); err != nil {
return ApplyResult{}, err
}
if err := EnsureErrorPages(m.cfg); err != nil {
return ApplyResult{}, err
}
if err := EnsureCustomDirs(m.cfg); err != nil {
return ApplyResult{}, err
}
if err := SyncHtpasswdFiles(m.cfg, rules); err != nil {
return ApplyResult{}, err
}
opts := m.opts
// Path override is only for pre-rename validation during China CIDR updates.
opts.ChinaCIDRPathOverride = ""
opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
content, err := Generate(m.cfg, rules, certs, opts)
if err != nil {
return ApplyResult{}, err
}
tmpPath := m.cfg.NginxConfigPath() + ".tmp"
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
return ApplyResult{}, err
}
if err := ValidateConfigSyntax(content); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if m.available() {
if err := m.validate(ctx, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
} else {
m.logger.Warn("nginx binary not found, applied syntax-only validation", "bin", m.cfg.NginxBin)
}
currentPath := m.cfg.NginxConfigPath()
backupPath := currentPath + ".bak"
if _, err := os.Stat(currentPath); err == nil {
if err := copyFile(currentPath, backupPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if !m.available() {
return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
}
running, err := m.isRunning()
if err != nil {
return ApplyResult{}, err
}
if !running {
if err := m.start(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{Reloaded: true, Message: "Nginx 已启动"}, nil
}
if err := m.reload(ctx); err != nil {
m.logger.Warn("nginx reload failed, trying restart", "error", err)
if err := m.forceRestart(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{Reloaded: true, Message: "Nginx 已重新启动"}, nil
}
return ApplyResult{Reloaded: true, Message: "Nginx 已重载"}, nil
}
func (m *Manager) ValidateOnly(ctx context.Context, rules []proxy.Rule, certs []CertSource) error {
return m.ValidateOnlyWithOptions(ctx, rules, certs, m.opts)
}
func (m *Manager) ValidateOnlyWithOptions(ctx context.Context, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) error {
if opts.ChinaCIDRPathOverride != "" {
opts.ChinaCIDRAvailable = true
} else {
opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
}
content, err := Generate(m.cfg, rules, certs, opts)
if err != nil {
return err
}
if err := ValidateConfigSyntax(content); err != nil {
return err
}
if !m.available() {
m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
return nil
}
tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
return err
}
defer os.Remove(tmpPath)
return m.validate(ctx, tmpPath)
}
func (m *Manager) validate(ctx context.Context, configPath string) error {
tmpErr := filepath.Join(m.cfg.NginxDir(), "validate.error.log")
args := []string{"-e", absNginxPath(tmpErr), "-t", "-c", configPath}
err := m.runNginx(ctx, args, "Nginx 配置校验失败")
_ = os.Remove(tmpErr)
return err
}
func (m *Manager) start(ctx context.Context) error {
running, err := m.isRunning()
if err != nil {
return err
}
if running {
return m.reload(ctx)
}
if m.trySilentReload(ctx) {
m.logger.Info("nginx reloaded existing master")
return nil
}
prepareStartPlatform(m, ctx)
if err := m.runNginxStart(ctx, []string{"-c", m.cfg.NginxConfigPath()}, "Nginx 启动失败"); err != nil {
if strings.Contains(err.Error(), "conflicting server name") && m.trySilentReload(ctx) {
m.logger.Warn("nginx start skipped duplicate master, reloaded instead")
return nil
}
return err
}
m.logger.Info("nginx started")
return nil
}
func (m *Manager) trySilentReload(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel()
fullArgs := append(m.nginxGlobalArgs(), "-c", m.cfg.NginxConfigPath(), "-s", "reload")
cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
return cmd.Run() == nil
}
func (m *Manager) reload(ctx context.Context) error {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err == nil && isPIDAlive(pid) {
if err := reloadProcess(pid); err == nil {
m.logger.Info("nginx reloaded")
return nil
}
m.logger.Warn("nginx signal reload failed, falling back to cli", "pid", pid, "error", err)
} else if err != nil && !os.IsNotExist(err) {
removePIDFile(m.cfg.NginxPIDFile)
}
if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "reload"}, "Nginx 重载失败"); err != nil {
return err
}
m.logger.Info("nginx reloaded")
return nil
}
func (m *Manager) forceRestart(ctx context.Context) error {
m.terminateMaster()
removePIDFile(m.cfg.NginxPIDFile)
prepareStartPlatform(m, ctx)
return m.start(ctx)
}
func (m *Manager) terminateMaster() {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err != nil || !isPIDAlive(pid) {
return
}
_ = terminateProcess(pid, syscall.SIGTERM)
waitProcessExit(pid, nginxTerminateWait)
if isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGKILL)
waitProcessExit(pid, time.Second)
}
}
func (m *Manager) Stop(ctx context.Context) error {
if !m.isRunningQuick() {
return nil
}
if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "Nginx 停止失败"); err != nil {
return err
}
m.logger.Info("nginx stopped")
return nil
}
func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error {
ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
defer cancel()
fullArgs := append(m.nginxGlobalArgs(), args...)
cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
if ctx.Err() == context.DeadlineExceeded {
msg = "操作超时"
}
m.logger.Error("nginx command failed", "args", strings.Join(fullArgs, " "), "error", msg)
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
}
return nil
}
// runNginxStart launches nginx without waiting for the master process to exit.
// On Windows the master stays in the foreground and would block cmd.Run().
func (m *Manager) runNginxStart(ctx context.Context, args []string, prefix string) error {
fullArgs := append(m.nginxGlobalArgs(), args...)
cmd := exec.Command(m.cfg.NginxBin, fullArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s:%s", prefix, err.Error())
}
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
deadline := time.Now().Add(nginxStartWait)
for time.Now().Before(deadline) {
if running, err := m.isRunning(); err == nil && running {
return nil
}
select {
case err := <-waitDone:
if running, checkErr := m.isRunning(); checkErr == nil && running {
return nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" && err != nil {
msg = err.Error()
}
if msg == "" {
msg = "进程已退出"
}
m.logger.Error("nginx start failed", "args", strings.Join(fullArgs, " "), "error", msg)
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
default:
}
if ctx.Err() != nil {
return fmt.Errorf("%s:%s", prefix, "操作超时")
}
time.Sleep(100 * time.Millisecond)
}
if running, err := m.isRunning(); err == nil && running {
return nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = "未检测到运行中的进程"
}
return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
}
func (m *Manager) isRunning() (bool, error) {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
removePIDFile(m.cfg.NginxPIDFile)
return false, nil
}
if !isPIDAlive(pid) {
removePIDFile(m.cfg.NginxPIDFile)
return false, nil
}
return true, nil
}
func (m *Manager) isRunningQuick() bool {
ok, _ := m.isRunning()
return ok
}
func (m *Manager) ValidateContent(ctx context.Context, content string) error {
if err := ValidateConfigSyntax(content); err != nil {
return err
}
if !m.available() {
m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
return nil
}
tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
return err
}
defer os.Remove(tmpPath)
return m.validate(ctx, tmpPath)
}
func ChinaCIDRExists(cfg config.Config) bool {
return chinaCIDRExists(cfg)
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func sanitizeNginxError(msg string) string {
msg = strings.ReplaceAll(msg, "\r\n", " ")
msg = strings.ReplaceAll(msg, "\n", " ")
return strings.TrimSpace(msg)
}
+58
View File
@@ -0,0 +1,58 @@
package nginx
import (
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestApplyWithNginxIfAvailable(t *testing.T) {
if _, err := exec.LookPath("nginx"); err != nil {
t.Skip("nginx not installed")
}
dir := t.TempDir()
mimePath := filepath.Join(dir, "mime.types")
if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatalf("write mime.types: %v", err)
}
cfg := config.Config{
DataDir: dir,
NginxBin: "nginx",
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: mimePath,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
mgr := NewManager(cfg, logger)
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://127.0.0.1:65535",
ListenPort: 18080,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: false,
HTTPRedirect: false,
Enabled: true,
}}
if _, err := mgr.Apply(context.Background(), rules, nil); err != nil {
t.Fatalf("apply failed: %v", err)
}
defer mgr.Stop(context.Background())
tmp := filepath.Join(dir, "nginx", "bad.conf")
if err := os.WriteFile(tmp, []byte("this is not valid nginx config"), 0o644); err != nil {
t.Fatalf("write bad config: %v", err)
}
if err := mgr.validate(context.Background(), tmp); err == nil {
t.Fatal("expected invalid config to fail validation")
}
}
+50
View File
@@ -0,0 +1,50 @@
package nginx
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/proxy"
)
func TestApplyWithoutNginxBinary(t *testing.T) {
dir := t.TempDir()
mimePath := filepath.Join(dir, "mime.types")
if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatalf("write mime.types: %v", err)
}
cfg := config.Config{
DataDir: dir,
NginxBin: "fonu-missing-nginx-binary",
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: mimePath,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
mgr := NewManager(cfg, logger)
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://127.0.0.1:65535",
ListenPort: 8011,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: false,
Enabled: true,
}}
result, err := mgr.Apply(context.Background(), rules, nil)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if result.Message == "" {
t.Fatal("expected skip message")
}
if _, err := os.Stat(cfg.NginxConfigPath()); err != nil {
t.Fatalf("config not written: %v", err)
}
}
+14
View File
@@ -0,0 +1,14 @@
package nginx
import "path/filepath"
func absNginxPath(path string) string {
if path == "" {
return ""
}
abs, err := filepath.Abs(path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(abs)
}
+39
View File
@@ -0,0 +1,39 @@
package nginx
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
func readPIDFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pidStr := strings.TrimSpace(string(data))
if pidStr == "" {
return 0, fmt.Errorf("empty pid file")
}
pid, err := strconv.Atoi(pidStr)
if err != nil || pid <= 0 {
return 0, fmt.Errorf("invalid pid: %q", pidStr)
}
return pid, nil
}
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 !isPIDAlive(pid) {
return
}
time.Sleep(100 * time.Millisecond)
}
}
+13
View File
@@ -0,0 +1,13 @@
package nginx
import (
"os"
"testing"
)
func TestIsPIDAliveCurrentProcess(t *testing.T) {
pid := os.Getpid()
if !isPIDAlive(pid) {
t.Fatalf("expected current pid %d to be alive", pid)
}
}
+11
View File
@@ -0,0 +1,11 @@
package nginx
// ReadPID reads a PID from the given pid file.
func ReadPID(path string) (int, error) {
return readPIDFile(path)
}
// IsPIDAlive reports whether the process with the given PID is running.
func IsPIDAlive(pid int) bool {
return isPIDAlive(pid)
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !linux && !darwin && !freebsd && !windows
package nginx
import (
"fmt"
"os"
"syscall"
)
func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported")
}
func terminateProcess(pid int, sig syscall.Signal) error {
return fmt.Errorf("signal terminate unsupported")
}
func isPIDAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
+53
View File
@@ -0,0 +1,53 @@
package nginx
import (
"os"
"path/filepath"
"testing"
"github.com/fonu/fonu/internal/config"
)
func TestReadPIDFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.pid")
if _, err := readPIDFile(path); !os.IsNotExist(err) {
t.Fatalf("expected not exist, got %v", err)
}
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readPIDFile(path); err == nil {
t.Fatal("expected error for empty pid file")
}
if err := os.WriteFile(path, []byte("12345\n"), 0o644); err != nil {
t.Fatal(err)
}
pid, err := readPIDFile(path)
if err != nil || pid != 12345 {
t.Fatalf("expected pid 12345, got %d err=%v", pid, err)
}
}
func TestIsRunningStalePIDFile(t *testing.T) {
dir := t.TempDir()
pidPath := filepath.Join(dir, "nginx.pid")
if err := os.WriteFile(pidPath, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
mgr := &Manager{cfg: config.Config{NginxPIDFile: pidPath}}
running, err := mgr.isRunning()
if err != nil {
t.Fatal(err)
}
if running {
t.Fatal("expected stale empty pid file to be treated as not running")
}
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
t.Fatal("expected stale pid file to be removed")
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build linux || darwin || freebsd
package nginx
import (
"os"
"syscall"
)
func reloadProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGHUP)
}
func terminateProcess(pid int, sig syscall.Signal) error {
return syscall.Kill(pid, sig)
}
func isPIDAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
+59
View File
@@ -0,0 +1,59 @@
//go:build windows
package nginx
import (
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
const stillActive = 259
func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported on windows")
}
func terminateProcess(pid int, sig syscall.Signal) error {
args := []string{"/PID", strconv.Itoa(pid)}
if sig == syscall.SIGKILL {
args = append([]string{"/F"}, args...)
}
cmd := exec.Command("taskkill", args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("taskkill: %w", err)
}
return nil
}
func isPIDAlive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(handle)
var exitCode uint32
if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil {
return false
}
return exitCode == stillActive
}
func stopOrphanMasters(nginxDir string) {
marker := strings.ToLower(filepath.ToSlash(absNginxPath(nginxDir)))
marker = strings.ReplaceAll(marker, "'", "''")
script := fmt.Sprintf(
`Get-CimInstance Win32_Process -Filter "Name='nginx.exe'" | Where-Object { $_.CommandLine -like '*%s*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
marker,
)
_ = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run()
}
+99
View File
@@ -0,0 +1,99 @@
package nginx
import (
"encoding/json"
"strings"
)
type TrustedProxyConfig struct {
Enabled bool `json:"enabled"`
Preset string `json:"preset"` // "", "cloudflare", "custom"
CIDRs []string `json:"cidrs,omitempty"`
Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive,omitempty"`
}
var cloudflareIPv4 = []string{
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
}
var cloudflareIPv6 = []string{
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
}
func ParseTrustedProxyJSON(raw string) TrustedProxyConfig {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" {
return TrustedProxyConfig{}
}
var cfg TrustedProxyConfig
_ = json.Unmarshal([]byte(raw), &cfg)
return cfg
}
func (c TrustedProxyConfig) effectiveCIDRs() []string {
if !c.Enabled {
return nil
}
switch c.Preset {
case "cloudflare":
out := make([]string, 0, len(cloudflareIPv4)+len(cloudflareIPv6))
out = append(out, cloudflareIPv4...)
out = append(out, cloudflareIPv6...)
return out
default:
return c.CIDRs
}
}
func (c TrustedProxyConfig) effectiveHeader() string {
if c.Header != "" {
return c.Header
}
if c.Preset == "cloudflare" {
return "CF-Connecting-IP"
}
return "X-Forwarded-For"
}
func writeRealIPDirectives(b *strings.Builder, cfg TrustedProxyConfig) {
writeRealIPComment(b, cfg)
cidrs := cfg.effectiveCIDRs()
if len(cidrs) == 0 {
b.WriteString(" map $remote_addr $fonu_client_ip {\n")
b.WriteString(" default $remote_addr;\n")
b.WriteString(" }\n\n")
return
}
for _, cidr := range cidrs {
b.WriteString(" set_real_ip_from " + cidr + ";\n")
}
header := cfg.effectiveHeader()
b.WriteString(" real_ip_header " + header + ";\n")
if cfg.Recursive || header == "X-Forwarded-For" {
b.WriteString(" real_ip_recursive on;\n")
}
b.WriteString("\n map $remote_addr $fonu_client_ip {\n")
b.WriteString(" default $remote_addr;\n")
b.WriteString(" }\n\n")
}
+257
View File
@@ -0,0 +1,257 @@
package nginx
import (
"fmt"
"os"
"strings"
"github.com/fonu/fonu/internal/config"
"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
}
func writeGlobalAllowList(b *strings.Builder, cidrs []string) {
merged := proxy.ChinaBypassCIDRs(cidrs)
if len(merged) == 0 {
return
}
writeGlobalAllowListComment(b)
for _, cidr := range merged {
b.WriteString(fmt.Sprintf(" allow %s;\n", cidr))
}
b.WriteString("\n")
}
func writeGlobalDenyList(b *strings.Builder, cidrs []string) {
if len(cidrs) == 0 {
return
}
writeGlobalDenyListComment(b)
for _, cidr := range cidrs {
b.WriteString(fmt.Sprintf(" deny %s;\n", cidr))
}
b.WriteString("\n")
}
func writeLimitZones(b *strings.Builder, rules []proxy.Rule) {
for _, rule := range rules {
if !rule.Enabled {
continue
}
if rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled() {
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))
}
if rule.Security.ConnLimitEnabled() {
b.WriteString(fmt.Sprintf(" limit_conn_zone $binary_remote_addr zone=fonu_rule_%d_conn:10m;\n", rule.ID))
}
}
if hasLimitZones(rules) {
b.WriteString("\n")
}
}
func hasLimitZones(rules []proxy.Rule) bool {
for _, rule := range rules {
if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) {
return true
}
}
return false
}
func writePrivateIPGeo(b *strings.Builder) {
b.WriteString(` geo $fonu_client_ip $fonu_is_private {
default 0;
10.0.0.0/8 1;
127.0.0.0/8 1;
172.16.0.0/12 1;
192.168.0.0/16 1;
::1/128 1;
fc00::/7 1;
}
`)
}
func writeChinaGeoBlocks(b *strings.Builder, cfg config.Config, opts GenerateOptions, rules []proxy.Rule) {
needsChina := false
for _, rule := range rules {
if rule.Enabled && rule.Security.ChinaOnly {
needsChina = true
break
}
}
if !needsChina {
return
}
writeChinaGeoComment(b)
writePrivateIPGeo(b)
if !opts.ChinaCIDRAvailable {
return
}
cidrPath := absNginxPath(cfg.ChinaCIDRPath())
if opts.ChinaCIDRPathOverride != "" {
cidrPath = absNginxPath(opts.ChinaCIDRPathOverride)
}
b.WriteString(fmt.Sprintf(` geo $fonu_client_ip $fonu_is_china {
default 0;
include %s;
}
`, cidrPath))
}
func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts GenerateOptions) {
if !opts.ChinaCIDRAvailable {
return
}
for _, rule := range rules {
if !rule.Enabled || !rule.Security.ChinaOnly {
continue
}
sec := rule.Security.Normalize()
cidrs := chinaBypassCIDRs(sec, opts.GlobalIPWhitelist)
writeChinaBypassComment(b, rule)
b.WriteString(fmt.Sprintf(" geo $fonu_client_ip $fonu_rule_%d_china_bypass {\n", rule.ID))
b.WriteString(" default 0;\n")
for _, cidr := range cidrs {
b.WriteString(fmt.Sprintf(" %s 1;\n", cidr))
}
b.WriteString(" }\n\n")
}
}
func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
sec := rule.Security.Normalize()
if !sec.ChinaOnly {
return
}
if !opts.ChinaCIDRAvailable {
b.WriteString(` if ($fonu_is_private = 0) {
return 403;
}
`)
return
}
b.WriteString(` set $fonu_china_block 0;
if ($fonu_is_china = 0) {
set $fonu_china_block 1;
}
if ($fonu_is_private = 1) {
set $fonu_china_block 0;
}
`)
b.WriteString(fmt.Sprintf(` if ($fonu_rule_%d_china_bypass = 1) {
set $fonu_china_block 0;
}
`, rule.ID))
b.WriteString(` if ($fonu_china_block = 1) {
return 403;
}
`)
}
func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
if !https || !sec.SecurityHeaders {
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")
}
}
func writeErrorPages(b *strings.Builder, cfg config.Config) {
errorsDir := absNginxPath(cfg.ErrorsDir())
writeErrorPagesComment(b)
// HTML is served via internal error_page redirect; images are fetched by the browser
// as separate requests and must not use the internal-only location.
b.WriteString(` error_page 403 /fonu-errors/403.html;
error_page 404 /fonu-errors/404.html;
error_page 429 /fonu-errors/429.html;
error_page 500 /fonu-errors/500.html;
error_page 502 503 /fonu-errors/503.html;
location = /fonu-errors/error.png {
alias ` + errorsDir + `/error.png;
}
location = /fonu-errors/429.png {
alias ` + errorsDir + `/429.png;
}
location ^~ /fonu-errors/ {
internal;
alias ` + errorsDir + `/;
}
`)
}
func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) {
sec := rule.Security.Normalize()
writeLocationSecurityComments(b, rule, opts)
for _, cidr := range sec.IPBlacklist {
b.WriteString(fmt.Sprintf(" deny %s;\n", cidr))
}
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")
}
if sec.ConnLimitEnabled() {
b.WriteString(fmt.Sprintf(" limit_conn fonu_rule_%d_conn %d;\n", rule.ID, 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))
}
if sec.BasicAuthEnabled() {
path := absNginxPath(HtpasswdPath(cfg, rule.ID))
b.WriteString(fmt.Sprintf(` auth_basic "Restricted";
auth_basic_user_file %s;
`, path))
}
}
func chinaBypassCIDRs(sec proxy.SecurityConfig, globalWhitelist []string) []string {
extra := make([]string, 0, len(globalWhitelist)+len(sec.IPWhitelist))
extra = append(extra, globalWhitelist...)
extra = append(extra, sec.IPWhitelist...)
return proxy.ChinaBypassCIDRs(extra)
}
func chinaCIDRExists(cfg config.Config) bool {
info, err := os.Stat(cfg.ChinaCIDRPath())
return err == nil && !info.IsDir() && info.Size() > 0
}
+68
View File
@@ -0,0 +1,68 @@
package nginx
import (
"fmt"
"strings"
)
// ValidateConfigSyntax performs a lightweight syntax check when nginx -t is unavailable.
func ValidateConfigSyntax(content string) error {
depth := 0
for i, raw := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
line := stripInlineComment(trimmed)
if line == "" {
continue
}
depth += strings.Count(line, "{") - strings.Count(line, "}")
if depth < 0 {
return fmt.Errorf("第 %d 行:多余的 `}`", i+1)
}
if err := validateConfigLine(line, i+1); err != nil {
return err
}
}
if depth != 0 {
return fmt.Errorf("配置块大括号未配对")
}
return nil
}
func stripInlineComment(line string) string {
inSingle := false
inDouble := false
for i := 0; i < len(line); i++ {
ch := line[i]
switch ch {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
return strings.TrimSpace(line[:i])
}
}
}
return strings.TrimSpace(line)
}
func validateConfigLine(line string, lineNo int) error {
if line == "}" || line == "{" {
return nil
}
if strings.HasSuffix(line, "{") {
return nil
}
if strings.HasSuffix(line, ";") {
return nil
}
return fmt.Errorf("第 %d 行:无效的 Nginx 语法(应以 ; 或 { 结尾)", lineNo)
}
+44
View File
@@ -0,0 +1,44 @@
package nginx
import "testing"
func TestValidateConfigSyntax(t *testing.T) {
valid := `server {
listen 80;
location / {
proxy_pass http://127.0.0.1:8080;
}
}`
if err := ValidateConfigSyntax(valid); err != nil {
t.Fatalf("expected valid config: %v", err)
}
cases := []struct {
name string
content string
}{
{"garbage line", "server {\n 1 1\n}\n"},
{"missing semicolon", "server {\n listen 80\n}\n"},
{"unbalanced brace", "server {\n listen 80;\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateConfigSyntax(tc.content); err == nil {
t.Fatalf("expected error for %q", tc.name)
}
})
}
t.Run("allows comment lines", func(t *testing.T) {
content := `# 规则:测试
server {
# 监听 80
listen 80;
}
# HTTP 跳转
`
if err := ValidateConfigSyntax(content); err != nil {
t.Fatalf("expected comments to pass: %v", err)
}
})
}