feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+164
View File
@@ -0,0 +1,164 @@
package nginx
import (
"fmt"
"os"
"strings"
"github.com/havline/havline/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)
}
}
+13
View File
@@ -0,0 +1,13 @@
package nginx
import "testing"
func TestHostCoveredByCert(t *testing.T) {
domains := []string{"example.com", "*.example.com"}
if !hostCoveredByCert("app.example.com", domains) {
t.Fatal("expected wildcard to cover subdomain")
}
if hostCoveredByCert("s.muksi.com", domains) {
t.Fatal("expected typo domain to be rejected")
}
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !windows
package nginx
import (
"context"
"os/exec"
"syscall"
"time"
)
func prepareStartPlatform(m *Manager, ctx context.Context) {
stopOrphanMasters(ctx, m)
}
func stopOrphanMasters(ctx context.Context, m *Manager) {
pid, err := readPIDFile(m.cfg.NginxPIDFile)
if err == nil && isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGTERM)
waitProcessExit(pid, nginxTerminateWait)
if isPIDAlive(pid) {
_ = terminateProcess(pid, syscall.SIGKILL)
waitProcessExit(pid, time.Second)
}
}
_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
removePIDFile(m.cfg.NginxPIDFile)
if _, err := exec.LookPath("pkill"); err == nil {
_ = exec.Command("pkill", "-x", "nginx").Run()
time.Sleep(200 * time.Millisecond)
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package nginx
import "context"
func prepareStartPlatform(m *Manager, ctx context.Context) {
stopOrphanMasters(m.cfg.NginxDir())
_ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
removePIDFile(m.cfg.NginxPIDFile)
}
+155
View File
@@ -0,0 +1,155 @@
package nginx
import (
"fmt"
"strings"
"github.com/havline/havline/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, "Havline 自定义错误页(403/404/429/5xx)")
}
func writeLocationHeaderComment(b *strings.Builder, upstream string) {
writeCommentLine(b, 4, fmt.Sprintf("反代 location / → %s", upstream))
}
func writeLocationSecurityComments(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
sec := rule.Security.Normalize()
if len(sec.IPBlacklist) > 0 {
writeCommentLine(b, 8, "IP 黑名单")
}
if sec.ChinaOnly {
if opts.ChinaCIDRAvailable {
writeCommentLine(b, 8, "仅中国大陆:内网 IP 与白名单 IP 段自动豁免")
} else {
writeCommentLine(b, 8, "仅中国大陆:中国 IP 段未就绪,当前仅允许内网 IP")
}
}
if sec.WhitelistEnabled() {
writeCommentLine(b, 8, "IP 白名单模式:仅允许以下 IP,其余拒绝")
}
if sec.ConnLimitEnabled() {
writeCommentLine(b, 8, fmt.Sprintf("连接数限制:每 IP 最多 %d 并发", sec.ConnLimit.Max))
}
if sec.RateLimitEnabled() {
writeCommentLine(b, 8, fmt.Sprintf("请求限流:%d 次/秒,突发 %d(超出返回 429)", sec.RateLimit.Rate, sec.RateLimit.Burst))
}
if sec.BasicAuthEnabled() {
writeCommentLine(b, 8, "Basic Auth:浏览器弹窗认证")
}
}
func writeProxyBodyComments(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
writeCommentLine(b, 8, "反向代理与 WebSocket 头")
if sec.ProxyHostUpstream {
writeCommentLine(b, 8, "Host 头:使用上游地址($proxy_host)")
}
if strings.HasPrefix(upstream, "https://") {
if sec.ProxySSLVerifyOff {
writeCommentLine(b, 8, "上游 HTTPS:跳过证书校验")
} else {
writeCommentLine(b, 8, "上游 HTTPS:启用 SNI")
}
}
}
func writeLimitZoneComments(b *strings.Builder, rule proxy.Rule) {
sec := rule.Security.Normalize()
if sec.RateLimitEnabled() {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:请求限流 zone(%d 次/秒)", ruleDisplayName(rule), sec.RateLimit.Rate))
}
if sec.ConnLimitEnabled() {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:连接数 zone(每 IP %d 并发)", ruleDisplayName(rule), sec.ConnLimit.Max))
}
}
func writeGlobalAllowListComment(b *strings.Builder) {
writeCommentLine(b, 4, "全局 IP 白名单(含内网段默认放行,优先于黑名单,可豁免仅中国大陆限制)")
}
func writeGlobalDenyListComment(b *strings.Builder) {
writeCommentLine(b, 4, "全局 IP 黑名单(系统设置,优先于所有规则)")
}
func writeRealIPComment(b *strings.Builder, cfg TrustedProxyConfig) {
if len(cfg.effectiveCIDRs()) == 0 {
writeCommentLine(b, 4, "未启用信任代理:客户端 IP 取 $remote_addr")
return
}
writeCommentLine(b, 4, fmt.Sprintf("信任代理:从 %s 解析真实客户端 IP", cfg.effectiveHeader()))
}
func writeChinaGeoComment(b *strings.Builder) {
writeCommentLine(b, 4, "仅中国大陆:内网 IP 判定与中国 IP 段库")
}
func writeChinaBypassComment(b *strings.Builder, rule proxy.Rule) {
writeCommentLine(b, 4, fmt.Sprintf("规则 %s:仅中国大陆 — 白名单 IP 豁免段", ruleDisplayName(rule)))
}
+75
View File
@@ -0,0 +1,75 @@
package nginx
import (
"fmt"
"strings"
)
func certUsable(cert *certFiles) bool {
if cert == nil {
return false
}
if strings.TrimSpace(cert.CertPath) == "" || strings.TrimSpace(cert.KeyPath) == "" {
return false
}
return fileExists(cert.CertPath) && fileExists(cert.KeyPath)
}
func assertValidSSLBlocks(content string) error {
inServer := false
depth := 0
serverSSL := false
serverCert := false
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !inServer {
if strings.HasPrefix(line, "server") && strings.Contains(line, "{") {
inServer = true
depth = braceDelta(line)
serverSSL = false
serverCert = false
}
continue
}
depth += braceDelta(line)
if isSSLListenLine(line) {
serverSSL = true
}
if isSSLCertificateLine(line) {
serverCert = true
}
if depth <= 0 {
if serverSSL && !serverCert {
return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
}
inServer = false
}
}
if inServer && serverSSL && !serverCert {
return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
}
return nil
}
func braceDelta(line string) int {
return strings.Count(line, "{") - strings.Count(line, "}")
}
func isSSLListenLine(line string) bool {
if !strings.HasPrefix(line, "listen ") {
return false
}
return strings.Contains(line, " ssl;") || strings.HasSuffix(line, " ssl")
}
func isSSLCertificateLine(line string) bool {
return strings.HasPrefix(line, "ssl_certificate ") && !strings.HasPrefix(line, "ssl_certificate_key ")
}
@@ -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)
}
}
+260
View File
@@ -0,0 +1,260 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
)
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 fsutil.WriteFileAtomic(targetPath, data, 0o644)
}
func WriteGlobalCustom(cfg config.Config, content string) error {
if err := EnsureCustomDirs(cfg); err != nil {
return err
}
return fsutil.WriteFileAtomic(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 fsutil.WriteFileAtomic(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(`# 以下部分由 Havline 自动生成,请勿在此编辑
# 主进程
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 havline_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
access_log %s havline_access;
# 传输优化
sendfile on;
keepalive_timeout 65;
# WebSocket 升级映射
# map $http_upgrade $connection_upgrade { ... }
# limit_req_zone / geo / real_ip 等由规则与系统设置动态生成
# 全局自定义片段插入在上述动态块之后、各 server {} 之前
}
`, absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")),
absNginxPath(cfg.NginxPIDFile),
absNginxPath(cfg.NginxMimeTypes),
absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")))
}
+136
View File
@@ -0,0 +1,136 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/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))
}
}
+113
View File
@@ -0,0 +1,113 @@
package nginx
import (
"strconv"
"strings"
"github.com/havline/havline/internal/proxy"
)
// ParseServerEndpoints 从 Nginx 配置文本里抽取各 server 块的 (server_name, listen 端口) 组合,
// 返回 proxy.Endpoint(与表单规则的端点同一类型),用于手动(custom)规则的监听占用校验。
//
// 采取保守策略:解析不出的写法(include 片段、正则 server_name、listen unix:、变量)一律忽略。
// 该校验只用于保存时提示,宁可漏报也不能因为解析失败而拦住保存。
func ParseServerEndpoints(content string) []proxy.Endpoint {
var out []proxy.Endpoint
seen := map[string]bool{}
var (
inServer bool
depth int
ports []int
names []string
)
flush := func() {
// 未写 listen 的 server 块按 Nginx 默认监听端口 80 处理
if len(ports) == 0 && len(names) > 0 {
ports = []int{80}
}
for _, port := range ports {
for _, name := range names {
key := name + ":" + strconv.Itoa(port)
if seen[key] {
continue
}
seen[key] = true
out = append(out, proxy.Endpoint{Hostname: name, Port: port})
}
}
inServer, depth, ports, names = false, 0, nil, nil
}
for _, raw := range strings.Split(content, "\n") {
line := stripInlineComment(raw)
if line == "" {
continue
}
opens := strings.Count(line, "{")
closes := strings.Count(line, "}")
if !inServer {
if opens > 0 && serverBlockOpens(line) {
inServer, depth = true, opens-closes
}
continue
}
switch {
case strings.HasPrefix(line, "listen"):
if port, ok := listenPort(line); ok {
ports = append(ports, port)
}
case strings.HasPrefix(line, "server_name"):
names = append(names, serverNames(line)...)
}
depth += opens - closes
if depth <= 0 {
flush()
}
}
return out
}
func serverBlockOpens(line string) bool {
fields := strings.Fields(line)
return len(fields) > 0 && fields[0] == "server" && strings.Contains(line, "{")
}
// listenPort 从 listen 指令取出端口号;unix socket 等无法判定的写法返回 false
func listenPort(line string) (int, bool) {
fields := strings.Fields(line)
if len(fields) < 2 {
return 0, false
}
addr := strings.TrimSuffix(fields[1], ";")
if strings.Contains(addr, "unix:") {
return 0, false
}
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
addr = addr[idx+1:]
}
port, err := strconv.Atoi(addr)
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// serverNames 从 server_name 指令取出字面量主机名;正则(~ 开头)与占位符(含 $)跳过
func serverNames(line string) []string {
fields := strings.Fields(line)
if len(fields) < 2 {
return nil
}
var names []string
for _, field := range fields[1:] {
name := strings.ToLower(strings.TrimSuffix(field, ";"))
if name == "" || name == "_" || strings.HasPrefix(name, "~") || strings.Contains(name, "$") {
continue
}
names = append(names, name)
}
return names
}
+57
View File
@@ -0,0 +1,57 @@
package nginx
import "testing"
func TestParseServerEndpoints(t *testing.T) {
content := `server {
listen 8011;
listen [::]:8011;
server_name a.com b.com;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
# 注释行不参与解析
server {
listen 127.0.0.1:8017 ssl;
server_name *.c.com ~^regex\.com$ _;
}
`
endpoints := ParseServerEndpoints(content)
want := []struct {
host string
port int
}{
{"a.com", 8011},
{"b.com", 8011},
{"*.c.com", 8017},
}
if len(endpoints) != len(want) {
t.Fatalf("expected %d endpoints, got %d (%+v)", len(want), len(endpoints), endpoints)
}
for i, expect := range want {
if endpoints[i].Hostname != expect.host || endpoints[i].Port != expect.port {
t.Fatalf("endpoint %d = %s:%d, want %s:%d", i, endpoints[i].Hostname, endpoints[i].Port, expect.host, expect.port)
}
}
}
func TestParseServerEndpointsDefaultsToPort80(t *testing.T) {
endpoints := ParseServerEndpoints("server {\n server_name d.com;\n return 444;\n}\n")
if len(endpoints) != 1 || endpoints[0].Hostname != "d.com" || endpoints[0].Port != 80 {
t.Fatalf("expected d.com:80, got %+v", endpoints)
}
}
func TestParseServerEndpointsIgnoresUnparseable(t *testing.T) {
content := `include /etc/nginx/snippets/extra.conf;
server {
listen unix:/run/havline.sock;
server_name $host;
}
`
if endpoints := ParseServerEndpoints(content); len(endpoints) != 0 {
t.Fatalf("expected no endpoints, got %+v", endpoints)
}
}
@@ -0,0 +1,8 @@
package nginx
import "embed"
// Bundled for Docker/runtime when web/assets is not on disk.
//
//go:embed assets/error.png assets/429.png
var errorAssetFS embed.FS
+146
View File
@@ -0,0 +1,146 @@
package nginx
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/havline/havline/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="/havline-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(/havline-errors/error.png) no-repeat;background-size:200%% 200%%;background-position:%s}
.illus{width:280px;height:auto;margin:0 auto 24px;display:block}
.code{font-size:14px;font-weight:600;color:#22c55e;letter-spacing:.08em;margin-bottom:8px}
h1{font-size:22px;font-weight:600;margin-bottom:8px}
p{font-size:14px;color:#94a3b8;line-height:1.6}
@media (prefers-color-scheme:light){body{background:#f8fafc;color:#1e293b}p{color:#64748b}}
</style>
</head>
<body>
<div class="card">
%s
<div class="code">%d</div>
<h1>%s</h1>
<p>%s</p>
</div>
</body>
</html>`, code, title, bgPos, imgBlock, code, title, message)
}
+45
View File
@@ -0,0 +1,45 @@
package nginx
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/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{
"/havline-errors/error.png",
"background:url(/havline-errors/error.png)",
} {
if !strings.Contains(body, want) {
t.Fatalf("403.html missing %q:\n%s", want, body)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package nginx
import (
"os"
"runtime"
)
// Debian/Ubuntu nginx worker user.
const nginxWorkerUID = 33
const nginxWorkerGID = 33
func ensureNginxReadable(path string, dir bool) {
mode := os.FileMode(0o644)
if dir {
mode = 0o755
}
_ = os.Chmod(path, mode)
if runtime.GOOS == "linux" {
_ = os.Chown(path, nginxWorkerUID, nginxWorkerGID)
}
}
+277
View File
@@ -0,0 +1,277 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/nginxtmpl"
"github.com/havline/havline/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 havline_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")) + ` havline_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 证书")
writeTLSProtocolComment(&block, rule.Security)
block.WriteString(nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{
CertPath: absNginxPath(cert.CertPath),
KeyPath: absNginxPath(cert.KeyPath),
TLS13Only: rule.Security.TLSMin13Only,
}))
block.WriteString("\n")
writeServerSecurityHeaders(&block, rule.Security, true)
writeErrorPages(&block, cfg)
writeLocationWithSecurity(&block, cfg, rule, opts)
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;
%s
proxy_buffering off;
proxy_read_timeout 3600s;
`, sslBlock, upstream, hostHeader, nginxtmpl.UpgradeHeaders(" ", "$connection_upgrade")))
}
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/havline/havline/internal/config"
"github.com/havline/havline/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 $havline_client_ip $havline_rule_3_china_bypass",
"192.168.0.0/16 1;",
"10.0.0.0/8 1;",
"if ($havline_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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/internal/proxy"
)
func TestGenerateRuleBlocksIncludesComments(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
rule := proxy.Rule{
ID: 3,
Name: "havline",
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{
"# 规则:havline",
"# 上游: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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/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=havline_rule_7_req",
"limit_conn_zone $binary_remote_addr zone=havline_rule_7_conn",
"deny 1.2.3.4;",
"geo $havline_client_ip $havline_is_private",
"geo $havline_client_ip $havline_rule_7_china_bypass",
"192.168.0.0/16 1;",
"192.168.1.0/24 1;",
"203.0.113.10 1;",
"set $havline_china_block 0;",
"if ($havline_is_private = 1)",
"if ($havline_rule_7_china_bypass = 1)",
"allow 192.168.1.0/24;",
"deny all;",
"auth_basic",
"limit_req zone=havline_rule_7_req burst=20",
"limit_conn havline_rule_7_conn 20",
"error_page 403 /havline-errors/403.html",
"location = /havline-errors/error.png",
"location = /havline-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/havline/havline/internal/config"
"github.com/havline/havline/internal/proxy"
)
func TestGenerateMixedRulesWithoutCerts(t *testing.T) {
dir := t.TempDir()
mimePath := filepath.Join(dir, "mime.types")
if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := config.Config{
DataDir: dir,
NginxPIDFile: filepath.Join(dir, "nginx.pid"),
NginxMimeTypes: mimePath,
}
rules := []proxy.Rule{
{
ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
{
ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true,
Hosts: []proxy.Host{{Hostname: "app.example.com"}},
HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
},
}
content, err := Generate(cfg, rules, nil, GenerateOptions{})
if err != nil {
t.Fatalf("generate failed: %v", err)
}
if err := assertValidSSLBlocks(content); err != nil {
t.Fatalf("invalid ssl blocks: %v\n%s", err, content)
}
}
+44
View File
@@ -0,0 +1,44 @@
package nginx
import (
"strings"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
)
func TestGenerateGlobalFrameworkIncludesComments(t *testing.T) {
cfg := config.Config{DataDir: t.TempDir()}
out := GenerateGlobalFramework(cfg)
for _, want := range []string{
"# 以下部分由 Havline 自动生成",
"# 主进程",
"# MIME 类型",
"# 访问日志格式与路径",
"# 传输优化",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing comment %q in:\n%s", want, out)
}
}
if strings.Contains(out, "client_max_body_size") {
t.Fatalf("framework should not include client_max_body_size:\n%s", out)
}
}
func TestDefaultGlobalHTTPSnippet(t *testing.T) {
out := DefaultGlobalHTTPSnippet()
if !strings.Contains(out, "client_max_body_size 50m;") {
t.Fatalf("missing upload limit in snippet:\n%s", out)
}
if !strings.Contains(out, "# 上传大小限制") {
t.Fatalf("missing comment in snippet:\n%s", out)
}
}
+70
View File
@@ -0,0 +1,70 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/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)
}
}
+60
View File
@@ -0,0 +1,60 @@
package nginx
import (
"os"
"runtime"
"strings"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/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)
}
}
+426
View File
@@ -0,0 +1,426 @@
package nginx
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
"github.com/havline/havline/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 := fsutil.WriteFileAtomic(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()
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),隐藏的 .versions 目录保留最近 10 份(供版本对比与回滚)
if err := fsutil.Backup(currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
if err := os.Rename(tmpPath, currentPath); err != nil {
_ = os.Remove(tmpPath)
return ApplyResult{}, err
}
return m.activate(ctx)
}
// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。
// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。
func (m *Manager) activate(ctx context.Context) (ApplyResult, error) {
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/havline/havline/internal/config"
"github.com/havline/havline/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/havline/havline/internal/config"
"github.com/havline/havline/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: "havline-missing-nginx-binary",
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: mimePath,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
mgr := NewManager(cfg, logger)
rules := []proxy.Rule{{
ID: 1,
Upstream: "http://127.0.0.1:65535",
ListenPort: 8011,
ListenIPv4: true,
Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
HTTPSEnabled: false,
Enabled: true,
}}
result, err := mgr.Apply(context.Background(), rules, nil)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if result.Message == "" {
t.Fatal("expected skip message")
}
if _, err := os.Stat(cfg.NginxConfigPath()); err != nil {
t.Fatalf("config not written: %v", err)
}
}
+14
View File
@@ -0,0 +1,14 @@
package nginx
import "path/filepath"
func absNginxPath(path string) string {
if path == "" {
return ""
}
abs, err := filepath.Abs(path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(abs)
}
+39
View File
@@ -0,0 +1,39 @@
package nginx
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
func readPIDFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pidStr := strings.TrimSpace(string(data))
if pidStr == "" {
return 0, fmt.Errorf("empty pid file")
}
pid, err := strconv.Atoi(pidStr)
if err != nil || pid <= 0 {
return 0, fmt.Errorf("invalid pid: %q", pidStr)
}
return pid, nil
}
func removePIDFile(path string) {
_ = os.Remove(path)
}
func waitProcessExit(pid int, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !isPIDAlive(pid) {
return
}
time.Sleep(100 * time.Millisecond)
}
}
+13
View File
@@ -0,0 +1,13 @@
package nginx
import (
"os"
"testing"
)
func TestIsPIDAliveCurrentProcess(t *testing.T) {
pid := os.Getpid()
if !isPIDAlive(pid) {
t.Fatalf("expected current pid %d to be alive", pid)
}
}
+11
View File
@@ -0,0 +1,11 @@
package nginx
// ReadPID reads a PID from the given pid file.
func ReadPID(path string) (int, error) {
return readPIDFile(path)
}
// IsPIDAlive reports whether the process with the given PID is running.
func IsPIDAlive(pid int) bool {
return isPIDAlive(pid)
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !linux && !darwin && !freebsd && !windows
package nginx
import (
"fmt"
"os"
"syscall"
)
func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported")
}
func terminateProcess(pid int, sig syscall.Signal) error {
return fmt.Errorf("signal terminate unsupported")
}
func isPIDAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
+53
View File
@@ -0,0 +1,53 @@
package nginx
import (
"os"
"path/filepath"
"testing"
"github.com/havline/havline/internal/config"
)
func TestReadPIDFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.pid")
if _, err := readPIDFile(path); !os.IsNotExist(err) {
t.Fatalf("expected not exist, got %v", err)
}
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readPIDFile(path); err == nil {
t.Fatal("expected error for empty pid file")
}
if err := os.WriteFile(path, []byte("12345\n"), 0o644); err != nil {
t.Fatal(err)
}
pid, err := readPIDFile(path)
if err != nil || pid != 12345 {
t.Fatalf("expected pid 12345, got %d err=%v", pid, err)
}
}
func TestIsRunningStalePIDFile(t *testing.T) {
dir := t.TempDir()
pidPath := filepath.Join(dir, "nginx.pid")
if err := os.WriteFile(pidPath, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
mgr := &Manager{cfg: config.Config{NginxPIDFile: pidPath}}
running, err := mgr.isRunning()
if err != nil {
t.Fatal(err)
}
if running {
t.Fatal("expected stale empty pid file to be treated as not running")
}
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
t.Fatal("expected stale pid file to be removed")
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build linux || darwin || freebsd
package nginx
import (
"os"
"syscall"
)
func reloadProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGHUP)
}
func terminateProcess(pid int, sig syscall.Signal) error {
return syscall.Kill(pid, sig)
}
func isPIDAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
+59
View File
@@ -0,0 +1,59 @@
//go:build windows
package nginx
import (
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
const stillActive = 259
func reloadProcess(pid int) error {
return fmt.Errorf("signal reload unsupported on windows")
}
func terminateProcess(pid int, sig syscall.Signal) error {
args := []string{"/PID", strconv.Itoa(pid)}
if sig == syscall.SIGKILL {
args = append([]string{"/F"}, args...)
}
cmd := exec.Command("taskkill", args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("taskkill: %w", err)
}
return nil
}
func isPIDAlive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(handle)
var exitCode uint32
if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil {
return false
}
return exitCode == stillActive
}
func stopOrphanMasters(nginxDir string) {
marker := strings.ToLower(filepath.ToSlash(absNginxPath(nginxDir)))
marker = strings.ReplaceAll(marker, "'", "''")
script := fmt.Sprintf(
`Get-CimInstance Win32_Process -Filter "Name='nginx.exe'" | Where-Object { $_.CommandLine -like '*%s*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
marker,
)
_ = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run()
}
+99
View File
@@ -0,0 +1,99 @@
package nginx
import (
"encoding/json"
"strings"
)
type TrustedProxyConfig struct {
Enabled bool `json:"enabled"`
Preset string `json:"preset"` // "", "cloudflare", "custom"
CIDRs []string `json:"cidrs,omitempty"`
Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
Recursive bool `json:"recursive,omitempty"`
}
var cloudflareIPv4 = []string{
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
}
var cloudflareIPv6 = []string{
"2400:cb00::/32",
"2606:4700::/32",
"2803:f800::/32",
"2405:b500::/32",
"2405:8100::/32",
"2a06:98c0::/29",
"2c0f:f248::/32",
}
func ParseTrustedProxyJSON(raw string) TrustedProxyConfig {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" {
return TrustedProxyConfig{}
}
var cfg TrustedProxyConfig
_ = json.Unmarshal([]byte(raw), &cfg)
return cfg
}
func (c TrustedProxyConfig) effectiveCIDRs() []string {
if !c.Enabled {
return nil
}
switch c.Preset {
case "cloudflare":
out := make([]string, 0, len(cloudflareIPv4)+len(cloudflareIPv6))
out = append(out, cloudflareIPv4...)
out = append(out, cloudflareIPv6...)
return out
default:
return c.CIDRs
}
}
func (c TrustedProxyConfig) effectiveHeader() string {
if c.Header != "" {
return c.Header
}
if c.Preset == "cloudflare" {
return "CF-Connecting-IP"
}
return "X-Forwarded-For"
}
func writeRealIPDirectives(b *strings.Builder, cfg TrustedProxyConfig) {
writeRealIPComment(b, cfg)
cidrs := cfg.effectiveCIDRs()
if len(cidrs) == 0 {
b.WriteString(" map $remote_addr $havline_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 $havline_client_ip {\n")
b.WriteString(" default $remote_addr;\n")
b.WriteString(" }\n\n")
}
+253
View File
@@ -0,0 +1,253 @@
package nginx
import (
"fmt"
"os"
"strings"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/nginxtmpl"
"github.com/havline/havline/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() {
zone := fmt.Sprintf("havline_rule_%d_req", rule.ID)
writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate))
}
if rule.Security.ConnLimitEnabled() {
writeIndentedZone(b, nginxtmpl.ConnLimitZone(fmt.Sprintf("havline_rule_%d_conn", rule.ID)))
}
}
if hasLimitZones(rules) {
b.WriteString("\n")
}
}
func writeIndentedZone(b *strings.Builder, line string) {
if strings.TrimSpace(line) == "" {
return
}
b.WriteString(" " + line)
}
func hasLimitZones(rules []proxy.Rule) bool {
for _, rule := range rules {
if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) {
return true
}
}
return false
}
func writePrivateIPGeo(b *strings.Builder) {
b.WriteString(` geo $havline_client_ip $havline_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 $havline_client_ip $havline_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 $havline_client_ip $havline_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 chinaOnlyCheckDirectives(rule proxy.Rule, opts GenerateOptions) string {
sec := rule.Security.Normalize()
if !sec.ChinaOnly {
return ""
}
if !opts.ChinaCIDRAvailable {
return ` if ($havline_is_private = 0) {
return 403;
}
`
}
var b strings.Builder
b.WriteString(` set $havline_china_block 0;
if ($havline_is_china = 0) {
set $havline_china_block 1;
}
if ($havline_is_private = 1) {
set $havline_china_block 0;
}
`)
b.WriteString(fmt.Sprintf(` if ($havline_rule_%d_china_bypass = 1) {
set $havline_china_block 0;
}
`, rule.ID))
b.WriteString(` if ($havline_china_block = 1) {
return 403;
}
`)
return b.String()
}
func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
if !https || !sec.SecurityHeaders {
return
}
writeSecurityHeadersComment(b)
b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true}))
b.WriteString("\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 /havline-errors/403.html;
error_page 404 /havline-errors/404.html;
error_page 429 /havline-errors/429.html;
error_page 500 /havline-errors/500.html;
error_page 502 503 /havline-errors/503.html;
location = /havline-errors/error.png {
alias ` + errorsDir + `/error.png;
}
location = /havline-errors/429.png {
alias ` + errorsDir + `/429.png;
}
location ^~ /havline-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)
acl := nginxtmpl.AccessControl{
Deny: sec.IPBlacklist,
ChinaDeny: chinaOnlyCheckDirectives(rule, opts),
}
if sec.WhitelistEnabled() {
acl.Allow = sec.IPWhitelist
}
b.WriteString(nginxtmpl.AccessDirectives(acl, " "))
limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("havline_rule_%d", rule.ID)}
if sec.ConnLimitEnabled() {
limits.Conn = sec.ConnLimit.Max
}
if sec.RateLimitEnabled() {
limits.Rate = sec.RateLimit.Rate
limits.Burst = sec.RateLimit.Burst
}
b.WriteString(nginxtmpl.LimitDirectives(limits, " "))
if sec.BasicAuthEnabled() {
path := absNginxPath(HtpasswdPath(cfg, rule.ID))
b.WriteString(fmt.Sprintf(` auth_basic "Restricted";
auth_basic_user_file %s;
`, path))
}
}
func chinaBypassCIDRs(sec proxy.SecurityConfig, globalWhitelist []string) []string {
extra := make([]string, 0, len(globalWhitelist)+len(sec.IPWhitelist))
extra = append(extra, globalWhitelist...)
extra = append(extra, sec.IPWhitelist...)
return proxy.ChinaBypassCIDRs(extra)
}
func chinaCIDRExists(cfg config.Config) bool {
info, err := os.Stat(cfg.ChinaCIDRPath())
return err == nil && !info.IsDir() && info.Size() > 0
}
+68
View File
@@ -0,0 +1,68 @@
package nginx
import (
"fmt"
"strings"
)
// ValidateConfigSyntax performs a lightweight syntax check when nginx -t is unavailable.
func ValidateConfigSyntax(content string) error {
depth := 0
for i, raw := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
line := stripInlineComment(trimmed)
if line == "" {
continue
}
depth += strings.Count(line, "{") - strings.Count(line, "}")
if depth < 0 {
return fmt.Errorf("第 %d 行:多余的 `}`", i+1)
}
if err := validateConfigLine(line, i+1); err != nil {
return err
}
}
if depth != 0 {
return fmt.Errorf("配置块大括号未配对")
}
return nil
}
func stripInlineComment(line string) string {
inSingle := false
inDouble := false
for i := 0; i < len(line); i++ {
ch := line[i]
switch ch {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
return strings.TrimSpace(line[:i])
}
}
}
return strings.TrimSpace(line)
}
func validateConfigLine(line string, lineNo int) error {
if line == "}" || line == "{" {
return nil
}
if strings.HasSuffix(line, "{") {
return nil
}
if strings.HasSuffix(line, ";") {
return nil
}
return fmt.Errorf("第 %d 行:无效的 Nginx 语法(应以 ; 或 { 结尾)", lineNo)
}
+44
View File
@@ -0,0 +1,44 @@
package nginx
import "testing"
func TestValidateConfigSyntax(t *testing.T) {
valid := `server {
listen 80;
location / {
proxy_pass http://127.0.0.1:8080;
}
}`
if err := ValidateConfigSyntax(valid); err != nil {
t.Fatalf("expected valid config: %v", err)
}
cases := []struct {
name string
content string
}{
{"garbage line", "server {\n 1 1\n}\n"},
{"missing semicolon", "server {\n listen 80\n}\n"},
{"unbalanced brace", "server {\n listen 80;\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateConfigSyntax(tc.content); err == nil {
t.Fatalf("expected error for %q", tc.name)
}
})
}
t.Run("allows comment lines", func(t *testing.T) {
content := `# 规则:测试
server {
# 监听 80
listen 80;
}
# HTTP 跳转
`
if err := ValidateConfigSyntax(content); err != nil {
t.Fatalf("expected comments to pass: %v", err)
}
})
}
+104
View File
@@ -0,0 +1,104 @@
package nginx
import (
"context"
"fmt"
"os"
"time"
"github.com/havline/havline/internal/fsutil"
)
// maxConfigVersions 主配置保留的版本份数。Apply 每次保存都会留一份,fsutil 会跳过内容相同的版本,
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
const maxConfigVersions = 10
// ConfigVersion 是主配置(nginx.conf)的一个历史版本。
type ConfigVersion struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// ReadConfig 读取当前主配置内容,供前端与历史版本做差异对比。
func (m *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(m.cfg.NginxConfigPath())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// ListConfigVersions 列出主配置的历史版本(新的在前);从未写过配置时返回空列表。
func (m *Manager) ListConfigVersions() ([]ConfigVersion, error) {
versions, err := fsutil.ListVersions(m.cfg.NginxConfigPath())
if err != nil {
return nil, err
}
out := make([]ConfigVersion, 0, len(versions))
for _, version := range versions {
out = append(out, ConfigVersion{Name: version.Name, Size: version.Size, CreatedAt: version.CreatedAt})
}
return out, nil
}
// ReadConfigVersion 读取某个历史版本的内容。
func (m *Manager) ReadConfigVersion(name string) (string, error) {
data, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return "", err
}
return string(data), nil
}
// RollbackConfigVersion 把主配置回退到指定版本。
//
// 与 Apply 同构:先语法校验、再 nginx -t 校验,两关都过才写回并重载;校验失败保持当前配置不动。
// 写回前把当前内容也留成一个版本,所以回退本身同样可以再回退。
func (m *Manager) RollbackConfigVersion(ctx context.Context, name string) (ApplyResult, error) {
if name == "" {
return ApplyResult{}, fmt.Errorf("请选择要回滚的版本")
}
content, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
if err != nil {
return ApplyResult{}, err
}
if err := m.EnsureDirs(); err != nil {
return ApplyResult{}, err
}
if err := ValidateConfigSyntax(string(content)); err != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过语法校验,已保持当前配置:%s", err)
}
if m.available() {
tmpPath := m.cfg.NginxConfigPath() + ".rollback.tmp"
if err := fsutil.WriteFileAtomic(tmpPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
validateErr := m.validate(ctx, tmpPath)
_ = os.Remove(tmpPath)
if validateErr != nil {
return ApplyResult{}, fmt.Errorf("该版本无法通过 nginx 校验,已保持当前配置:%s", validateErr)
}
}
currentPath := m.cfg.NginxConfigPath()
if err := fsutil.Backup(currentPath); err != nil {
return ApplyResult{}, err
}
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
return ApplyResult{}, err
}
if err := fsutil.WriteFileAtomic(currentPath, content, 0o644); err != nil {
return ApplyResult{}, err
}
result, err := m.activate(ctx)
if err != nil {
return ApplyResult{}, err
}
result.Message = "已回滚到所选版本," + result.Message
return result, nil
}
+112
View File
@@ -0,0 +1,112 @@
package nginx
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/fsutil"
)
func newVersionTestManager(t *testing.T) (*Manager, config.Config) {
t.Helper()
dir := t.TempDir()
cfg := config.Config{
DataDir: dir,
NginxBin: "havline-nginx-not-installed",
NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
NginxMimeTypes: filepath.Join(dir, "mime.types"),
}
mgr := NewManager(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err := mgr.EnsureDirs(); err != nil {
t.Fatal(err)
}
return mgr, cfg
}
func TestConfigVersionsListAndRead(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil {
t.Fatal(err)
}
versions, err := mgr.ListConfigVersions()
if err != nil || len(versions) != 1 {
t.Fatalf("应列出 1 个版本:%v %v", versions, err)
}
content, err := mgr.ReadConfigVersion(versions[0].Name)
if err != nil || content != "worker_processes 1;\n" {
t.Fatalf("版本内容不符:%q %v", content, err)
}
}
func TestRollbackConfigVersionRejectsTraversal(t *testing.T) {
mgr, _ := newVersionTestManager(t)
if _, err := mgr.RollbackConfigVersion(context.Background(), "../nginx.conf"); err == nil {
t.Fatal("带路径上跳的版本名应被拒绝")
}
}
func TestRollbackConfigVersionKeepsCurrentOnSyntaxError(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
dir := fsutil.VersionsDir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
badName := "20260101T000000.000-bad.conf"
if err := os.WriteFile(filepath.Join(dir, badName), []byte("server {\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := mgr.RollbackConfigVersion(context.Background(), badName); err == nil {
t.Fatal("语法错误的版本应拒绝回滚")
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "worker_processes 1;\n" {
t.Fatalf("校验失败时不应覆盖当前配置:%q %v", current, err)
}
}
func TestRollbackConfigVersionRestoresAndKeepsCurrent(t *testing.T) {
mgr, cfg := newVersionTestManager(t)
path := cfg.NginxConfigPath()
if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil {
t.Fatal(err)
}
versions, err := mgr.ListConfigVersions()
if err != nil || len(versions) != 1 {
t.Fatalf("准备版本失败:%v %v", versions, err)
}
if err := os.WriteFile(path, []byte("worker_processes 2;\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := mgr.RollbackConfigVersion(context.Background(), versions[0].Name)
if err != nil {
t.Fatalf("回滚失败: %v", err)
}
if !strings.Contains(result.Message, "已回滚") {
t.Fatalf("回滚结果缺少提示:%q", result.Message)
}
current, err := os.ReadFile(path)
if err != nil || string(current) != "worker_processes 1;\n" {
t.Fatalf("回滚后内容不符:%q %v", current, err)
}
after, err := mgr.ListConfigVersions()
if err != nil || len(after) < 2 {
t.Fatalf("回滚前的当前配置也应留存为版本:%v %v", after, err)
}
}