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:
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 375 KiB |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user