2026-09-23 09:40:59 +08:00
|
|
|
package nginx
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-10-01 16:54:35 +08:00
|
|
|
"github.com/havline/havline/internal/config"
|
|
|
|
|
"github.com/havline/havline/internal/nginxtmpl"
|
|
|
|
|
"github.com/havline/havline/internal/proxy"
|
2026-09-23 09:40:59 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type GenerateOptions struct {
|
2026-09-28 15:37:14 +08:00
|
|
|
TrustedProxy TrustedProxyConfig
|
|
|
|
|
GlobalIPBlacklist []string
|
|
|
|
|
GlobalIPWhitelist []string
|
|
|
|
|
ChinaCIDRAvailable bool
|
|
|
|
|
ChinaCIDRPathOverride string
|
|
|
|
|
GlobalCustomOverride string
|
|
|
|
|
GlobalCustomPathOverride string
|
|
|
|
|
RuleCustomOverrides map[int64]string
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-10-01 16:54:35 +08:00
|
|
|
zone := fmt.Sprintf("havline_rule_%d_req", rule.ID)
|
2026-09-28 15:37:14 +08:00
|
|
|
writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate))
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
if rule.Security.ConnLimitEnabled() {
|
2026-10-01 16:54:35 +08:00
|
|
|
writeIndentedZone(b, nginxtmpl.ConnLimitZone(fmt.Sprintf("havline_rule_%d_conn", rule.ID)))
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if hasLimitZones(rules) {
|
|
|
|
|
b.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 15:37:14 +08:00
|
|
|
func writeIndentedZone(b *strings.Builder, line string) {
|
|
|
|
|
if strings.TrimSpace(line) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(" " + line)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
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) {
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(` geo $havline_client_ip $havline_is_private {
|
2026-09-23 09:40:59 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(fmt.Sprintf(` geo $havline_client_ip $havline_is_china {
|
2026-09-23 09:40:59 +08:00
|
|
|
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)
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(fmt.Sprintf(" geo $havline_client_ip $havline_rule_%d_china_bypass {\n", rule.ID))
|
2026-09-23 09:40:59 +08:00
|
|
|
b.WriteString(" default 0;\n")
|
|
|
|
|
for _, cidr := range cidrs {
|
|
|
|
|
b.WriteString(fmt.Sprintf(" %s 1;\n", cidr))
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(" }\n\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 15:37:14 +08:00
|
|
|
func chinaOnlyCheckDirectives(rule proxy.Rule, opts GenerateOptions) string {
|
2026-09-23 09:40:59 +08:00
|
|
|
sec := rule.Security.Normalize()
|
|
|
|
|
if !sec.ChinaOnly {
|
2026-09-28 15:37:14 +08:00
|
|
|
return ""
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
if !opts.ChinaCIDRAvailable {
|
2026-10-01 16:54:35 +08:00
|
|
|
return ` if ($havline_is_private = 0) {
|
2026-09-23 09:40:59 +08:00
|
|
|
return 403;
|
|
|
|
|
}
|
2026-09-28 15:37:14 +08:00
|
|
|
`
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
2026-09-28 15:37:14 +08:00
|
|
|
var b strings.Builder
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(` set $havline_china_block 0;
|
|
|
|
|
if ($havline_is_china = 0) {
|
|
|
|
|
set $havline_china_block 1;
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
2026-10-01 16:54:35 +08:00
|
|
|
if ($havline_is_private = 1) {
|
|
|
|
|
set $havline_china_block 0;
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
`)
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(fmt.Sprintf(` if ($havline_rule_%d_china_bypass = 1) {
|
|
|
|
|
set $havline_china_block 0;
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
`, rule.ID))
|
2026-10-01 16:54:35 +08:00
|
|
|
b.WriteString(` if ($havline_china_block = 1) {
|
2026-09-23 09:40:59 +08:00
|
|
|
return 403;
|
|
|
|
|
}
|
|
|
|
|
`)
|
2026-09-28 15:37:14 +08:00
|
|
|
return b.String()
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
|
|
|
|
|
if !https || !sec.SecurityHeaders {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeSecurityHeadersComment(b)
|
2026-09-28 15:37:14 +08:00
|
|
|
b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true}))
|
|
|
|
|
b.WriteString("\n")
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-10-01 16:54:35 +08:00
|
|
|
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;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
2026-10-01 16:54:35 +08:00
|
|
|
location = /havline-errors/error.png {
|
2026-09-23 09:40:59 +08:00
|
|
|
alias ` + errorsDir + `/error.png;
|
|
|
|
|
}
|
2026-10-01 16:54:35 +08:00
|
|
|
location = /havline-errors/429.png {
|
2026-09-23 09:40:59 +08:00
|
|
|
alias ` + errorsDir + `/429.png;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 16:54:35 +08:00
|
|
|
location ^~ /havline-errors/ {
|
2026-09-23 09:40:59 +08:00
|
|
|
internal;
|
|
|
|
|
alias ` + errorsDir + `/;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) {
|
|
|
|
|
sec := rule.Security.Normalize()
|
|
|
|
|
writeLocationSecurityComments(b, rule, opts)
|
|
|
|
|
|
2026-09-28 15:37:14 +08:00
|
|
|
acl := nginxtmpl.AccessControl{
|
|
|
|
|
Deny: sec.IPBlacklist,
|
|
|
|
|
ChinaDeny: chinaOnlyCheckDirectives(rule, opts),
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
if sec.WhitelistEnabled() {
|
2026-09-28 15:37:14 +08:00
|
|
|
acl.Allow = sec.IPWhitelist
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
2026-09-28 15:37:14 +08:00
|
|
|
b.WriteString(nginxtmpl.AccessDirectives(acl, " "))
|
2026-09-23 09:40:59 +08:00
|
|
|
|
2026-10-01 16:54:35 +08:00
|
|
|
limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("havline_rule_%d", rule.ID)}
|
2026-09-23 09:40:59 +08:00
|
|
|
if sec.ConnLimitEnabled() {
|
2026-09-28 15:37:14 +08:00
|
|
|
limits.Conn = sec.ConnLimit.Max
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
if sec.RateLimitEnabled() {
|
2026-09-28 15:37:14 +08:00
|
|
|
limits.Rate = sec.RateLimit.Rate
|
|
|
|
|
limits.Burst = sec.RateLimit.Burst
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
2026-09-28 15:37:14 +08:00
|
|
|
b.WriteString(nginxtmpl.LimitDirectives(limits, " "))
|
2026-09-23 09:40:59 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|