feat(converter,app): 添加 nginx 配置导入功能

- 新增 internal/converter/nginx 解析器和转换器
- main.go 添加 --import/-i 参数支持 nginx 配置导入
- app_test.go 添加导入功能相关测试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-24 17:12:49 +08:00
parent b444f5b1d7
commit 909bd405d2
6 changed files with 3018 additions and 3 deletions

View File

@ -200,6 +200,7 @@ func TestRun(t *testing.T) {
name string
cfgPath string
outputPath string
importPath string
wantContains string
wantErrContains string
wantExitCode int
@ -234,6 +235,25 @@ func TestRun(t *testing.T) {
wantExitCode: 1,
wantErrContains: "加载配置失败",
},
{
name: "generate 与 import 互斥",
genConfig: true,
importPath: "/tmp/nginx.conf",
wantExitCode: 1,
wantErrContains: "mutually exclusive",
},
{
name: "o 参数无 generate 或 import",
outputPath: "output.yaml",
wantExitCode: 1,
wantErrContains: "-o requires",
},
{
name: "导入 nginx 配置文件不存在",
importPath: "/tmp/nginx.conf",
wantExitCode: 1,
wantErrContains: "解析 nginx 配置失败",
},
}
for _, tt := range tests {
@ -241,7 +261,7 @@ func TestRun(t *testing.T) {
getStdout, restoreStdout := captureStdout(t)
getStderr, restoreStderr := captureStderr(t)
exitCode := Run(tt.cfgPath, tt.genConfig, tt.outputPath, tt.showVersion)
exitCode := Run(tt.cfgPath, tt.genConfig, tt.outputPath, tt.importPath, tt.showVersion)
restoreStderr()
restoreStdout()

View File

@ -0,0 +1,886 @@
// Package nginx provides a converter from nginx configuration to lolly configuration.
package nginx
import (
"fmt"
"strconv"
"strings"
"time"
"rua.plus/lolly/internal/config"
)
// Warning represents a conversion warning for unsupported or partially supported directives.
type Warning struct {
Directive string
Line int
File string
Message string
}
func (w Warning) String() string {
return fmt.Sprintf("warning: %s:%d: %s", w.File, w.Line, w.Message)
}
// ConvertResult holds the conversion output.
type ConvertResult struct {
Config *config.Config
Warnings []Warning
}
// upstreamInfo holds parsed upstream data for later reference.
type upstreamInfo struct {
Targets []config.ProxyTarget
LoadBalance string
}
// locationClassification classifies a location block for conversion.
type locationClassification struct {
LocType string // "proxy", "static", "redirect", "unsupported"
Path string // location path (without modifier)
Modifier string // "=", "^~", "~", "~*", "@"
Directives []Directive // original directives in the location block
}
// unsupportedDirectives are known nginx directives that have no lolly equivalent.
var unsupportedDirectives = map[string]string{
"if": "the 'if' directive is not supported; consider using map or rewrite",
"map": "the 'map' directive is not supported; use variables config instead",
"set": "the 'set' directive is not supported; use variables config instead",
"limit_req": "the 'limit_req' directive is not supported; use rate_limit config instead",
"limit_conn": "the 'limit_conn' directive is not supported",
"add_header": "the 'add_header' directive is not supported; use security.headers config instead",
"more_set_headers": "the 'more_set_headers' directive is not supported; use security.headers config instead",
"auth_request": "the 'auth_request' directive is not supported; use security.auth_request config instead",
"split_clients": "the 'split_clients' directive is not supported",
"geo": "the 'geo' directive is not supported; use access.geoip config instead",
"range": "the 'range' directive is not supported",
"return": "the 'return' directive is not supported for non-redirect status codes; only 301/302 are supported",
}
// Convert converts a parsed nginx configuration to a lolly configuration.
func Convert(nginxCfg *NginxConfig) (*ConvertResult, error) {
result := &ConvertResult{
Config: &config.Config{},
}
// 1. Build upstream map from top-level and http-level upstream blocks.
upstreams := make(map[string]*upstreamInfo)
for i := range nginxCfg.Directives {
d := &nginxCfg.Directives[i]
if d.Name == "upstream" {
info := convertUpstream(d, result)
if len(d.Args) > 0 {
upstreams[d.Args[0]] = info
}
}
if d.Name == "http" {
for j := range d.Block {
bd := &d.Block[j]
if bd.Name == "upstream" {
info := convertUpstream(bd, result)
if len(bd.Args) > 0 {
upstreams[bd.Args[0]] = info
}
}
}
}
}
// 2. Find all server blocks: inside http blocks, or at top level.
var serverBlocks []Directive
for i := range nginxCfg.Directives {
d := &nginxCfg.Directives[i]
if d.Name == "http" {
// Check for unsupported directives at the http level.
for j := range d.Block {
bd := &d.Block[j]
if bd.Name == "server" {
serverBlocks = append(serverBlocks, d.Block[j])
} else if msg, ok := unsupportedDirectives[bd.Name]; ok {
result.Warnings = append(result.Warnings, Warning{
Directive: bd.Name,
Line: bd.Line,
File: bd.File,
Message: msg,
})
}
}
} else if d.Name == "server" {
serverBlocks = append(serverBlocks, *d)
}
}
// 3. Convert each server block.
for i := range serverBlocks {
serverCfg := convertServerBlock(&serverBlocks[i], upstreams, result)
result.Config.Servers = append(result.Config.Servers, serverCfg)
}
return result, nil
}
// convertUpstream converts an upstream block to upstreamInfo.
func convertUpstream(d *Directive, result *ConvertResult) *upstreamInfo {
info := &upstreamInfo{}
for i := range d.Block {
bd := &d.Block[i]
switch bd.Name {
case "server":
target := convertUpstreamServer(bd)
info.Targets = append(info.Targets, target)
case "least_conn":
info.LoadBalance = "least_conn"
case "ip_hash":
info.LoadBalance = "ip_hash"
case "hash":
// hash $variable consistent → consistent_hash
if len(bd.Args) > 0 {
info.LoadBalance = "consistent_hash"
}
case "random":
info.LoadBalance = "random"
default:
result.Warnings = append(result.Warnings, Warning{
Directive: bd.Name,
Line: bd.Line,
File: bd.File,
Message: fmt.Sprintf("unsupported directive in upstream block: %s", bd.Name),
})
}
}
return info
}
// convertUpstreamServer parses a server directive inside an upstream block.
func convertUpstreamServer(d *Directive) config.ProxyTarget {
target := config.ProxyTarget{}
if len(d.Args) > 0 {
target.URL = d.Args[0]
}
for _, arg := range d.Args[1:] {
if after, ok := strings.CutPrefix(arg, "weight="); ok {
if v, err := strconv.Atoi(after); err == nil {
target.Weight = v
}
} else if after, ok := strings.CutPrefix(arg, "max_fails="); ok {
if v, err := strconv.Atoi(after); err == nil {
target.MaxFails = v
}
} else if after, ok := strings.CutPrefix(arg, "fail_timeout="); ok {
target.FailTimeout = parseDuration(after)
} else if arg == "backup" {
target.Backup = true
} else if arg == "down" {
target.Down = true
}
}
return target
}
// convertServerBlock converts a server block directive to a ServerConfig.
func convertServerBlock(d *Directive, upstreams map[string]*upstreamInfo, result *ConvertResult) config.ServerConfig {
server := config.ServerConfig{}
var sslDetected bool
for i := range d.Block {
bd := &d.Block[i]
switch bd.Name {
case "listen":
if parseListen(bd, &server) {
sslDetected = true
}
case "server_name":
parseServerName(bd, &server)
case "ssl_certificate":
if len(bd.Args) > 0 {
server.SSL.Cert = bd.Args[0]
}
case "ssl_certificate_key":
if len(bd.Args) > 0 {
server.SSL.Key = bd.Args[0]
}
case "gzip":
parseGzip(bd, &server)
case "gzip_types":
server.Compression.Types = bd.Args
case "gzip_min_length":
if len(bd.Args) > 0 {
if v, err := strconv.Atoi(bd.Args[0]); err == nil {
server.Compression.MinSize = v
}
}
case "client_max_body_size":
if len(bd.Args) > 0 {
server.ClientMaxBodySize = bd.Args[0]
}
case "server_tokens":
if len(bd.Args) > 0 {
server.ServerTokens = bd.Args[0] != "off"
}
case "access_log":
parseAccessLog(bd, result)
case "error_log":
parseErrorLog(bd, result)
case "return":
parseServerReturn(bd, &server, result)
case "rewrite":
parseRewrite(bd, &server)
case "location":
classification := classifyLocation(bd, result)
convertLocation(classification, &server, upstreams, result)
case "error_page":
parseErrorPage(bd, &server)
case "auth_basic":
parseAuthBasic(bd, &server)
case "auth_basic_user_file":
if len(bd.Args) > 0 {
result.Warnings = append(result.Warnings, Warning{
Directive: "auth_basic_user_file",
Line: bd.Line,
File: bd.File,
Message: fmt.Sprintf("auth_basic_user_file (%s) cannot be directly converted; htpasswd file must be manually migrated to auth.users", bd.Args[0]),
})
}
default:
if msg, ok := unsupportedDirectives[bd.Name]; ok {
result.Warnings = append(result.Warnings, Warning{
Directive: bd.Name,
Line: bd.Line,
File: bd.File,
Message: msg,
})
}
}
}
// Warn if SSL was detected (listen ... ssl) but cert/key are not configured.
if sslDetected && (server.SSL.Cert == "" || server.SSL.Key == "") {
result.Warnings = append(result.Warnings, Warning{
Directive: "listen",
Message: "SSL is enabled via listen directive but ssl_certificate and/or ssl_certificate_key are not configured; SSL config will be incomplete",
})
}
// Default listen address if no listen directive was specified.
if server.Listen == "" {
server.Listen = "0.0.0.0:80"
}
return server
}
// parseListen parses a listen directive.
func parseListen(d *Directive, server *config.ServerConfig) bool {
if len(d.Args) == 0 {
return false
}
addr := d.Args[0]
isSSL := false
isDefault := false
for _, arg := range d.Args[1:] {
if arg == "ssl" {
isSSL = true
}
if arg == "default_server" {
isDefault = true
}
}
// If addr is just a port number like "80" or "8080", prefix with ":".
if port, err := strconv.Atoi(addr); err == nil {
server.Listen = fmt.Sprintf(":%d", port)
} else if strings.Contains(addr, ":") {
server.Listen = addr
} else {
server.Listen = ":" + addr
}
// Set default_server flag.
if isDefault {
server.Default = true
}
// Enable SSL if specified.
if isSSL {
server.SSL.Cert = "" // Marker cleared; cert/key set by ssl_certificate directives.
server.SSL.Key = "" // If cert/key remain empty, a warning is added after processing.
}
return isSSL
}
// parseServerName parses a server_name directive.
func parseServerName(d *Directive, server *config.ServerConfig) {
if len(d.Args) == 0 {
return
}
server.Name = d.Args[0]
server.ServerNames = append(server.ServerNames, d.Args...)
}
// parseGzip parses a gzip directive.
func parseGzip(d *Directive, server *config.ServerConfig) {
if len(d.Args) > 0 && d.Args[0] == "on" {
server.Compression.Type = "gzip"
}
}
// parseAccessLog parses an access_log directive.
func parseAccessLog(d *Directive, result *ConvertResult) {
if len(d.Args) > 0 {
result.Config.Logging.Access.Path = d.Args[0]
}
if len(d.Args) > 1 {
result.Config.Logging.Access.Format = d.Args[1]
}
}
// parseErrorLog parses an error_log directive.
func parseErrorLog(d *Directive, result *ConvertResult) {
if len(d.Args) > 0 {
result.Config.Logging.Error.Path = d.Args[0]
}
if len(d.Args) > 1 {
result.Config.Logging.Error.Level = d.Args[1]
}
}
// parseServerReturn parses a return directive at server level.
func parseServerReturn(d *Directive, server *config.ServerConfig, result *ConvertResult) {
if len(d.Args) == 0 {
return
}
code, err := strconv.Atoi(d.Args[0])
if err != nil {
return
}
switch code {
case 301:
url := ""
if len(d.Args) > 1 {
url = d.Args[1]
}
server.Rewrite = append(server.Rewrite, config.RewriteRule{
Pattern: "^/",
Replacement: url,
Flag: "permanent",
})
case 302:
url := ""
if len(d.Args) > 1 {
url = d.Args[1]
}
server.Rewrite = append(server.Rewrite, config.RewriteRule{
Pattern: "^/",
Replacement: url,
Flag: "redirect",
})
default:
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Line: d.Line,
File: d.File,
Message: fmt.Sprintf("return %d is not a redirect; only 301/302 are supported at server level", code),
})
}
}
// parseRewrite parses a rewrite directive.
func parseRewrite(d *Directive, server *config.ServerConfig) {
if len(d.Args) < 2 {
return
}
rule := config.RewriteRule{
Pattern: d.Args[0],
Replacement: d.Args[1],
}
if len(d.Args) > 2 {
rule.Flag = d.Args[2]
}
server.Rewrite = append(server.Rewrite, rule)
}
// parseErrorPage parses an error_page directive.
func parseErrorPage(d *Directive, server *config.ServerConfig) {
// error_page 404 500 50x.html
// error_page 404 /404.html
if len(d.Args) < 2 {
return
}
// Last arg is the page path.
pagePath := d.Args[len(d.Args)-1]
if server.Security.ErrorPage.Pages == nil {
server.Security.ErrorPage.Pages = make(map[int]string)
}
for _, arg := range d.Args[:len(d.Args)-1] {
if code, err := strconv.Atoi(arg); err == nil {
server.Security.ErrorPage.Pages[code] = pagePath
}
}
}
// parseAuthBasic parses an auth_basic directive.
func parseAuthBasic(d *Directive, server *config.ServerConfig) {
if len(d.Args) > 0 {
if d.Args[0] != "off" {
server.Security.Auth.Type = "basic"
server.Security.Auth.Realm = d.Args[0]
}
}
}
// classifyLocation classifies a location block based on its directives.
func classifyLocation(d *Directive, result *ConvertResult) locationClassification {
class := locationClassification{
Directives: d.Block,
}
// Parse location path and modifier.
if len(d.Args) > 0 {
first := d.Args[0]
switch first {
case "=", "^~", "~", "~*":
class.Modifier = first
if len(d.Args) > 1 {
class.Path = d.Args[1]
}
default:
if strings.HasPrefix(first, "@") {
class.Modifier = "@"
class.Path = first[1:]
} else {
class.Path = first
}
}
}
// Classify based on content.
hasProxyPass := false
hasRootOrAlias := false
hasRedirect := false
for i := range d.Block {
switch d.Block[i].Name {
case "proxy_pass":
hasProxyPass = true
case "root", "alias":
hasRootOrAlias = true
case "return":
if len(d.Block[i].Args) > 0 {
code, err := strconv.Atoi(d.Block[i].Args[0])
if err == nil && (code == 301 || code == 302) {
hasRedirect = true
}
}
}
}
switch {
case hasProxyPass:
class.LocType = "proxy"
if hasRootOrAlias {
result.Warnings = append(result.Warnings, Warning{
Directive: "location",
Line: d.Line,
File: d.File,
Message: "location has both proxy_pass and root/alias; proxy_pass takes priority",
})
}
case hasRootOrAlias:
class.LocType = "static"
case hasRedirect:
class.LocType = "redirect"
default:
class.LocType = "unsupported"
}
return class
}
// convertLocation converts a classified location to the appropriate config entries.
func convertLocation(class locationClassification, server *config.ServerConfig, upstreams map[string]*upstreamInfo, result *ConvertResult) {
locType := modifierToLocationType(class.Modifier)
switch class.LocType {
case "proxy":
proxy := config.ProxyConfig{
Path: class.Path,
LocationType: locType,
}
if class.Modifier == "@" {
proxy.LocationName = class.Path
}
convertProxyDirectives(class.Directives, &proxy, upstreams, result)
server.Proxy = append(server.Proxy, proxy)
case "static":
static := config.StaticConfig{
Path: class.Path,
LocationType: locType,
}
convertStaticDirectives(class.Directives, &static, result)
server.Static = append(server.Static, static)
case "redirect":
convertRedirectDirectives(class.Directives, class.Path, server, result)
case "unsupported":
if len(class.Directives) == 0 {
result.Warnings = append(result.Warnings, Warning{
Directive: "location",
Message: fmt.Sprintf("location %s has no content and is unsupported", class.Path),
})
}
for i := range class.Directives {
bd := &class.Directives[i]
result.Warnings = append(result.Warnings, Warning{
Directive: bd.Name,
Line: bd.Line,
File: bd.File,
Message: fmt.Sprintf("unsupported directive in location: %s", bd.Name),
})
}
}
}
// modifierToLocationType maps nginx location modifiers to lolly location types.
func modifierToLocationType(modifier string) string {
switch modifier {
case "=":
return "exact"
case "^~":
return "prefix_priority"
case "~":
return "regex"
case "~*":
return "regex_caseless"
case "@":
return "named"
default:
return ""
}
}
// convertProxyDirectives converts directives within a proxy location block.
func convertProxyDirectives(directives []Directive, proxy *config.ProxyConfig, upstreams map[string]*upstreamInfo, result *ConvertResult) {
for i := range directives {
d := &directives[i]
switch d.Name {
case "proxy_pass":
if len(d.Args) > 0 {
url := d.Args[0]
// Check if URL references an upstream name (no scheme).
if upstreamName := extractUpstreamName(url); upstreamName != "" {
if info, ok := upstreams[upstreamName]; ok {
proxy.Targets = append(proxy.Targets, info.Targets...)
if info.LoadBalance != "" && proxy.LoadBalance == "" {
proxy.LoadBalance = info.LoadBalance
}
} else {
// Upstream not found; use URL as-is.
proxy.Targets = append(proxy.Targets, config.ProxyTarget{URL: url})
}
} else {
proxy.Targets = append(proxy.Targets, config.ProxyTarget{URL: url})
}
}
case "proxy_set_header":
if len(d.Args) >= 2 {
if proxy.Headers.SetRequest == nil {
proxy.Headers.SetRequest = make(map[string]string)
}
proxy.Headers.SetRequest[d.Args[0]] = mapVariable(d.Args[1], result, d)
}
case "proxy_hide_header":
if len(d.Args) > 0 {
proxy.Headers.HideResponse = append(proxy.Headers.HideResponse, d.Args[0])
}
case "proxy_pass_header":
if len(d.Args) > 0 {
proxy.Headers.PassResponse = append(proxy.Headers.PassResponse, d.Args[0])
}
case "proxy_redirect":
convertProxyRedirect(d, proxy)
case "proxy_connect_timeout":
if len(d.Args) > 0 {
proxy.Timeout.Connect = parseDuration(d.Args[0])
}
case "proxy_read_timeout":
if len(d.Args) > 0 {
proxy.Timeout.Read = parseDuration(d.Args[0])
}
case "proxy_send_timeout":
if len(d.Args) > 0 {
proxy.Timeout.Write = parseDuration(d.Args[0])
}
case "proxy_cache":
proxy.Cache.Enabled = true
case "proxy_cache_valid":
parseProxyCacheValid(d, proxy)
default:
if msg, ok := unsupportedDirectives[d.Name]; ok {
result.Warnings = append(result.Warnings, Warning{
Directive: d.Name,
Line: d.Line,
File: d.File,
Message: msg,
})
}
}
}
}
// extractUpstreamName extracts an upstream name from a proxy_pass URL.
// If the URL has no scheme (e.g., "http://upstream_name" where upstream_name
// has no port), it returns the host portion. Otherwise returns empty string.
func extractUpstreamName(url string) string {
if _, rest, ok := strings.Cut(url, "://"); ok {
host := rest
if slashIdx := strings.IndexAny(host, "/?"); slashIdx >= 0 {
host = host[:slashIdx]
}
// Check if this is an upstream reference by looking up known upstream names.
// An upstream name is a host with no port and no dot (not an IP or domain).
if !strings.Contains(host, ":") && !strings.Contains(host, ".") && host != "" {
return host
}
}
return ""
}
// mapVariable replaces nginx variables with lolly equivalents.
func mapVariable(value string, result *ConvertResult, d *Directive) string {
if strings.Contains(value, "$proxy_add_x_forwarded_for") {
result.Warnings = append(result.Warnings, Warning{
Directive: "proxy_set_header",
Line: d.Line,
File: d.File,
Message: "$proxy_add_x_forwarded_for is replaced with $remote_addr; lolly automatically appends to X-Forwarded-For",
})
return strings.ReplaceAll(value, "$proxy_add_x_forwarded_for", "$remote_addr")
}
return value
}
// convertProxyRedirect handles proxy_redirect directive.
func convertProxyRedirect(d *Directive, proxy *config.ProxyConfig) {
if len(d.Args) == 0 {
return
}
rr := &config.RedirectRewriteConfig{}
switch d.Args[0] {
case "off":
rr.Mode = "off"
case "default":
rr.Mode = "default"
default:
rr.Mode = "custom"
if len(d.Args) >= 2 {
rr.Rules = append(rr.Rules, config.RedirectRewriteRule{
Pattern: d.Args[0],
Replacement: d.Args[1],
})
}
}
proxy.RedirectRewrite = rr
}
// parseProxyCacheValid parses proxy_cache_valid directive.
func parseProxyCacheValid(d *Directive, proxy *config.ProxyConfig) {
// proxy_cache_valid 200 10m
// proxy_cache_valid 301 302 1h
// proxy_cache_valid any 1m
if len(d.Args) < 2 {
return
}
if proxy.CacheValid == nil {
proxy.CacheValid = &config.ProxyCacheValidConfig{}
}
// Last arg is the duration.
dur := parseDuration(d.Args[len(d.Args)-1])
for _, arg := range d.Args[:len(d.Args)-1] {
switch arg {
case "200", "201", "202", "203", "204", "205", "206", "207", "208", "226":
proxy.CacheValid.OK = dur
case "301", "302":
proxy.CacheValid.Redirect = dur
case "404":
proxy.CacheValid.NotFound = dur
case "any":
proxy.CacheValid.OK = dur
proxy.CacheValid.Redirect = dur
proxy.CacheValid.NotFound = dur
proxy.CacheValid.ClientError = dur
proxy.CacheValid.ServerError = dur
default:
code, err := strconv.Atoi(arg)
if err == nil {
switch {
case code >= 400 && code < 500 && code != 404:
proxy.CacheValid.ClientError = dur
case code >= 500:
proxy.CacheValid.ServerError = dur
}
}
}
}
}
// convertStaticDirectives converts directives within a static location block.
func convertStaticDirectives(directives []Directive, static *config.StaticConfig, result *ConvertResult) {
for i := range directives {
d := &directives[i]
switch d.Name {
case "root":
if len(d.Args) > 0 {
static.Root = d.Args[0]
}
case "alias":
if len(d.Args) > 0 {
static.Root = d.Args[0]
result.Warnings = append(result.Warnings, Warning{
Directive: "alias",
Line: d.Line,
File: d.File,
Message: "alias is converted to root; semantic differences may exist for locations with non-trailing paths",
})
}
case "index":
static.Index = append(static.Index, d.Args...)
case "try_files":
static.TryFiles = append(static.TryFiles, d.Args...)
default:
if msg, ok := unsupportedDirectives[d.Name]; ok {
result.Warnings = append(result.Warnings, Warning{
Directive: d.Name,
Line: d.Line,
File: d.File,
Message: msg,
})
}
}
}
}
// convertRedirectDirectives converts redirect directives within a location block.
func convertRedirectDirectives(directives []Directive, locPath string, server *config.ServerConfig, result *ConvertResult) {
for i := range directives {
d := &directives[i]
if d.Name != "return" {
continue
}
if len(d.Args) < 2 {
continue
}
code, err := strconv.Atoi(d.Args[0])
if err != nil {
continue
}
url := d.Args[1]
pattern := "^" + locPath + "$"
switch code {
case 301:
server.Rewrite = append(server.Rewrite, config.RewriteRule{
Pattern: pattern,
Replacement: url,
Flag: "permanent",
})
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Line: d.Line,
File: d.File,
Message: "return 301 converted to rewrite rule with permanent flag",
})
case 302:
server.Rewrite = append(server.Rewrite, config.RewriteRule{
Pattern: pattern,
Replacement: url,
Flag: "redirect",
})
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Line: d.Line,
File: d.File,
Message: "return 302 converted to rewrite rule with redirect flag",
})
default:
result.Warnings = append(result.Warnings, Warning{
Directive: "return",
Line: d.Line,
File: d.File,
Message: fmt.Sprintf("return %d in location is not a redirect; only 301/302 are supported", code),
})
}
}
}
// parseDuration parses a time duration string.
// Supports nginx-style durations: "10s", "5m", "1h", "1d".
func parseDuration(s string) time.Duration {
if s == "" {
return 0
}
// Try standard Go duration first.
if d, err := time.ParseDuration(s); err == nil {
return d
}
// Handle nginx-style durations without Go support.
s = strings.TrimSpace(s)
numStr := s[:len(s)-1]
unit := s[len(s)-1]
value, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return 0
}
switch unit {
case 's':
return time.Duration(value) * time.Second
case 'm':
return time.Duration(value) * time.Minute
case 'h':
return time.Duration(value) * time.Hour
case 'd':
return time.Duration(value) * 24 * time.Hour
default:
return 0
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,344 @@
// Package nginx provides a recursive descent parser for nginx configuration files.
package nginx
import (
"fmt"
"os"
"path/filepath"
)
// Directive represents a single nginx directive.
type Directive struct {
Name string // directive name (e.g., "server", "listen", "proxy_pass")
Args []string // directive arguments
Block []Directive // child directives for block directives (e.g., server { ... })
Line int // line number in source file
File string // source file path (for include tracking)
}
// NginxConfig represents a parsed nginx configuration.
type NginxConfig struct {
Directives []Directive
}
// ParseError represents a parse error with file and line information.
type ParseError struct {
File string
Line int
Message string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Message)
}
type parser struct {
input []byte
pos int
line int
file string
includeStack map[string]bool
depth int
extraDirectives []Directive // directives injected by include expansion
}
const maxDepth = 10
// Parse parses nginx configuration from a string.
func Parse(input string) (*NginxConfig, error) {
p := &parser{
input: []byte(input),
pos: 0,
line: 1,
file: "",
includeStack: make(map[string]bool),
depth: 0,
}
directives, err := p.parseDirectives()
if err != nil {
return nil, err
}
return &NginxConfig{Directives: directives}, nil
}
// ParseFile parses an nginx configuration file, handling include directives.
func ParseFile(path string) (*NginxConfig, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, &ParseError{File: path, Line: 1, Message: fmt.Sprintf("resolve absolute path: %v", err)}
}
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
return nil, &ParseError{File: path, Line: 1, Message: fmt.Sprintf("resolve symlinks: %v", err)}
}
return parseFileWithStack(resolved, map[string]bool{resolved: true}, 0)
}
func parseFileWithStack(path string, includeStack map[string]bool, depth int) (*NginxConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, &ParseError{File: path, Line: 1, Message: fmt.Sprintf("read file: %v", err)}
}
newStack := make(map[string]bool, len(includeStack)+1)
for k := range includeStack {
newStack[k] = true
}
newStack[path] = true
p := &parser{
input: data,
pos: 0,
line: 1,
file: path,
includeStack: newStack,
depth: depth,
}
directives, err := p.parseDirectives()
if err != nil {
return nil, err
}
return &NginxConfig{Directives: directives}, nil
}
func (p *parser) errorf(msg string, args ...any) error {
return &ParseError{File: p.file, Line: p.line, Message: fmt.Sprintf(msg, args...)}
}
func (p *parser) parseDirectives() ([]Directive, error) {
var directives []Directive
for {
p.skipWhitespaceAndComments()
if p.pos >= len(p.input) {
break
}
if p.input[p.pos] == '}' {
break
}
d, err := p.parseDirective()
if err != nil {
return nil, err
}
// handleInclude may produce zero directives (glob no match) or
// multiple directives (include expands to several files).
if d == nil {
continue
}
directives = append(directives, *d)
// Drain any extra directives injected by include expansion.
for _, extra := range p.extraDirectives {
directives = append(directives, extra)
}
p.extraDirectives = nil
}
return directives, nil
}
func (p *parser) parseDirective() (*Directive, error) {
p.skipWhitespaceAndComments()
line := p.line
name, err := p.readToken()
if err != nil {
return nil, err
}
if name == "" {
return nil, p.errorf("expected directive name")
}
d := &Directive{
Name: name,
Line: line,
File: p.file,
}
// Read arguments until ; or {
for {
p.skipWhitespaceAndComments()
if p.pos >= len(p.input) {
return nil, p.errorf("unexpected end of input, expected ';' or '{'")
}
ch := p.input[p.pos]
if ch == ';' {
p.pos++
break
}
if ch == '{' {
p.pos++
block, err := p.parseDirectives()
if err != nil {
return nil, err
}
p.skipWhitespaceAndComments()
if p.pos >= len(p.input) || p.input[p.pos] != '}' {
return nil, p.errorf("expected '}'")
}
p.pos++
d.Block = block
break
}
arg, err := p.readToken()
if err != nil {
return nil, err
}
if arg == "" {
return nil, p.errorf("unexpected character %q", p.input[p.pos])
}
d.Args = append(d.Args, arg)
}
// Handle include directive: replace with expanded content.
if d.Name == "include" && len(d.Args) > 0 {
return p.handleInclude(d.Args[0])
}
return d, nil
}
func (p *parser) handleInclude(pattern string) (*Directive, error) {
if p.depth >= maxDepth {
return nil, p.errorf("include depth exceeds maximum of %d", maxDepth)
}
var fullPattern string
if filepath.IsAbs(pattern) {
fullPattern = pattern
} else {
baseDir := filepath.Dir(p.file)
fullPattern = filepath.Join(baseDir, pattern)
}
matches, err := filepath.Glob(fullPattern)
if err != nil {
return nil, p.errorf("invalid include pattern %q: %v", pattern, err)
}
if len(matches) == 0 {
// If the pattern contains no glob metacharacters, it's a literal
// file path that should exist. Return an error if it doesn't.
if !isGlobPattern(pattern) {
return nil, p.errorf("include file not found: %s", fullPattern)
}
// Glob pattern with no matches — silently skip (matches nginx behavior).
return nil, nil
}
var allDirectives []Directive
for _, match := range matches {
resolved, err := filepath.EvalSymlinks(match)
if err != nil {
return nil, p.errorf("resolve symlinks for %q: %v", match, err)
}
if p.includeStack[resolved] {
return nil, p.errorf("circular include detected: %s", resolved)
}
cfg, err := parseFileWithStack(resolved, p.includeStack, p.depth+1)
if err != nil {
return nil, err
}
allDirectives = append(allDirectives, cfg.Directives...)
}
if len(allDirectives) == 0 {
return nil, nil
}
// Return the first directive; stash the rest for parseDirectives to drain.
p.extraDirectives = allDirectives[1:]
return &allDirectives[0], nil
}
func (p *parser) skipWhitespaceAndComments() {
for p.pos < len(p.input) {
ch := p.input[p.pos]
if ch == ' ' || ch == '\t' || ch == '\r' {
p.pos++
continue
}
if ch == '\n' {
p.pos++
p.line++
continue
}
if ch == '#' {
for p.pos < len(p.input) && p.input[p.pos] != '\n' {
p.pos++
}
continue
}
break
}
}
func (p *parser) readToken() (string, error) {
if p.pos >= len(p.input) {
return "", nil
}
ch := p.input[p.pos]
if ch == '"' || ch == '\'' {
return p.readQuotedString(ch)
}
if ch == '{' || ch == '}' || ch == ';' {
return "", nil
}
start := p.pos
for p.pos < len(p.input) {
ch = p.input[p.pos]
if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' ||
ch == '{' || ch == '}' || ch == ';' || ch == '#' ||
ch == '"' || ch == '\'' {
break
}
p.pos++
}
if p.pos == start {
return "", nil
}
return string(p.input[start:p.pos]), nil
}
func (p *parser) readQuotedString(quote byte) (string, error) {
p.pos++ // skip opening quote
var buf []byte
for p.pos < len(p.input) {
ch := p.input[p.pos]
if ch == '\\' {
p.pos++
if p.pos >= len(p.input) {
return "", p.errorf("unterminated escape in quoted string")
}
buf = append(buf, p.input[p.pos])
p.pos++
continue
}
if ch == quote {
p.pos++ // skip closing quote
return string(buf), nil
}
if ch == '\n' {
p.line++
}
buf = append(buf, ch)
p.pos++
}
return "", p.errorf("unterminated quoted string")
}
// isGlobPattern returns true if the path contains glob metacharacters.
func isGlobPattern(path string) bool {
for i := 0; i < len(path); i++ {
ch := path[i]
if ch == '*' || ch == '?' || ch == '[' {
return true
}
}
return false
}

View File

@ -0,0 +1,430 @@
package nginx
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func TestParseSimpleDirective(t *testing.T) {
cfg, err := Parse("listen 80;")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != "listen" {
t.Errorf("expected name %q, got %q", "listen", d.Name)
}
if len(d.Args) != 1 || d.Args[0] != "80" {
t.Errorf("expected args [80], got %v", d.Args)
}
}
func TestParseBlockDirective(t *testing.T) {
cfg, err := Parse("server { listen 80; }")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != "server" {
t.Errorf("expected name %q, got %q", "server", d.Name)
}
if len(d.Block) != 1 {
t.Fatalf("expected 1 child, got %d", len(d.Block))
}
if d.Block[0].Name != "listen" {
t.Errorf("expected child name %q, got %q", "listen", d.Block[0].Name)
}
if len(d.Block[0].Args) != 1 || d.Block[0].Args[0] != "80" {
t.Errorf("expected child args [80], got %v", d.Block[0].Args)
}
}
func TestParseComment(t *testing.T) {
cfg, err := Parse("# this is a comment\nlisten 80;")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
if cfg.Directives[0].Name != "listen" {
t.Errorf("expected name %q, got %q", "listen", cfg.Directives[0].Name)
}
}
func TestParseQuotedString(t *testing.T) {
tests := []struct {
name string
input string
dirName string
expected []string
}{
{
name: "double quoted",
input: `proxy_set_header Host "example.com";`,
dirName: "proxy_set_header",
expected: []string{"Host", "example.com"},
},
{
name: "single quoted",
input: `proxy_set_header Host 'example.com';`,
dirName: "proxy_set_header",
expected: []string{"Host", "example.com"},
},
{
name: "escaped quote inside double",
input: `set $x "hello\"world";`,
dirName: "set",
expected: []string{"$x", `hello"world`},
},
{
name: "escaped quote inside single",
input: `set $x 'hello\'world';`,
dirName: "set",
expected: []string{"$x", "hello'world"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != tt.dirName {
t.Errorf("expected name %q, got %q", tt.dirName, d.Name)
}
if len(d.Args) != len(tt.expected) {
t.Fatalf("expected %d args, got %d", len(tt.expected), len(d.Args))
}
for i, want := range tt.expected {
if d.Args[i] != want {
t.Errorf("arg[%d]: expected %q, got %q", i, want, d.Args[i])
}
}
})
}
}
func TestParseMultipleDirectives(t *testing.T) {
input := `listen 80;
server_name example.com;`
cfg, err := Parse(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 2 {
t.Fatalf("expected 2 directives, got %d", len(cfg.Directives))
}
if cfg.Directives[0].Name != "listen" {
t.Errorf("directive[0]: expected %q, got %q", "listen", cfg.Directives[0].Name)
}
if cfg.Directives[1].Name != "server_name" {
t.Errorf("directive[1]: expected %q, got %q", "server_name", cfg.Directives[1].Name)
}
}
func TestParseNestedBlocks(t *testing.T) {
input := `http { server { location / { root /var/www; } } }`
cfg, err := Parse(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 top-level directive, got %d", len(cfg.Directives))
}
http := cfg.Directives[0]
if http.Name != "http" || len(http.Block) != 1 {
t.Fatalf("expected http with 1 child")
}
srv := http.Block[0]
if srv.Name != "server" || len(srv.Block) != 1 {
t.Fatalf("expected server with 1 child")
}
loc := srv.Block[0]
if loc.Name != "location" || len(loc.Block) != 1 {
t.Fatalf("expected location with 1 child")
}
if loc.Block[0].Name != "root" {
t.Errorf("expected root, got %q", loc.Block[0].Name)
}
}
func TestParseUnclosedBlock(t *testing.T) {
_, err := Parse("server { listen 80;")
if err == nil {
t.Fatal("expected error for unclosed block")
}
pe, ok := err.(*ParseError)
if !ok {
t.Fatalf("expected *ParseError, got %T", err)
}
if pe.Line == 0 {
t.Error("expected non-zero line number")
}
}
func TestParseMissingSemicolon(t *testing.T) {
_, err := Parse("listen 80")
if err == nil {
t.Fatal("expected error for missing semicolon")
}
pe, ok := err.(*ParseError)
if !ok {
t.Fatalf("expected *ParseError, got %T", err)
}
if pe.Line == 0 {
t.Error("expected non-zero line number")
}
}
func TestParseFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nginx.conf")
content := "listen 80;\nserver_name example.com;"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
cfg, err := ParseFile(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 2 {
t.Fatalf("expected 2 directives, got %d", len(cfg.Directives))
}
if cfg.Directives[0].Name != "listen" {
t.Errorf("directive[0]: expected %q, got %q", "listen", cfg.Directives[0].Name)
}
if cfg.Directives[1].Name != "server_name" {
t.Errorf("directive[1]: expected %q, got %q", "server_name", cfg.Directives[1].Name)
}
}
func TestParseIncludeGlob(t *testing.T) {
dir := t.TempDir()
// Create a subdirectory for included files to avoid matching nginx.conf itself.
incDir := filepath.Join(dir, "includes")
if err := os.Mkdir(incDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(incDir, "a.conf"), []byte("listen 80;\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(incDir, "b.conf"), []byte("server_name a.com;\n"), 0644); err != nil {
t.Fatal(err)
}
// Create main config with include.
main := filepath.Join(dir, "nginx.conf")
content := "include " + incDir + "/*.conf;"
if err := os.WriteFile(main, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := ParseFile(main)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 2 {
t.Fatalf("expected 2 directives from included files, got %d", len(cfg.Directives))
}
names := map[string]bool{}
for _, d := range cfg.Directives {
names[d.Name] = true
}
if !names["listen"] || !names["server_name"] {
t.Errorf("expected listen and server_name, got %v", cfg.Directives)
}
}
func TestParseIncludeCircular(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a.conf")
b := filepath.Join(dir, "b.conf")
if err := os.WriteFile(a, []byte("include "+b+";"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(b, []byte("include "+a+";"), 0644); err != nil {
t.Fatal(err)
}
_, err := ParseFile(a)
if err == nil {
t.Fatal("expected error for circular include")
}
pe, ok := err.(*ParseError)
if !ok {
t.Fatalf("expected *ParseError, got %T", err)
}
if pe.Message == "" {
t.Error("expected non-empty error message")
}
}
func TestParseIncludeMaxDepth(t *testing.T) {
dir := t.TempDir()
// Create a chain of includes: 0.conf includes 1.conf, 1.conf includes 2.conf, etc.
for i := 0; i <= maxDepth+1; i++ {
path := filepath.Join(dir, fmt.Sprintf("%d.conf", i))
var content string
if i < maxDepth+1 {
next := filepath.Join(dir, fmt.Sprintf("%d.conf", i+1))
content = "include " + next + ";"
} else {
content = "listen 80;"
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
_, err := ParseFile(filepath.Join(dir, "0.conf"))
if err == nil {
t.Fatal("expected error for max include depth")
}
if _, ok := err.(*ParseError); !ok {
t.Fatalf("expected *ParseError, got %T", err)
}
}
func TestParseIncludeNotFound(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "nginx.conf")
content := "include /nonexistent/path.conf;"
if err := os.WriteFile(main, []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, err := ParseFile(main)
if err == nil {
t.Fatal("expected error for include of nonexistent file")
}
}
func TestParseIncludeGlobNoMatch(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "nginx.conf")
content := "include " + dir + "/nonexistent/*.conf;"
if err := os.WriteFile(main, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := ParseFile(main)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 0 {
t.Fatalf("expected 0 directives for glob with no matches, got %d", len(cfg.Directives))
}
}
func TestParseLocationModifiers(t *testing.T) {
tests := []struct {
name string
input string
dirName string
args []string
}{
{
name: "exact match",
input: "location = /path {}",
dirName: "location",
args: []string{"=", "/path"},
},
{
name: "regex",
input: `location ~ \.php$ {}`,
dirName: "location",
args: []string{"~", `\.php$`},
},
{
name: "case insensitive regex",
input: `location ~* \.jpg$ {}`,
dirName: "location",
args: []string{"~*", `\.jpg$`},
},
{
name: "prefix with continuation",
input: "location ^~ /images/ {}",
dirName: "location",
args: []string{"^~", "/images/"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != tt.dirName {
t.Errorf("expected name %q, got %q", tt.dirName, d.Name)
}
if len(d.Args) != len(tt.args) {
t.Fatalf("expected %d args, got %d: %v", len(tt.args), len(d.Args), d.Args)
}
for i, want := range tt.args {
if d.Args[i] != want {
t.Errorf("arg[%d]: expected %q, got %q", i, want, d.Args[i])
}
}
})
}
}
func TestParseEmptyBlock(t *testing.T) {
cfg, err := Parse("server {}")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != "server" {
t.Errorf("expected name %q, got %q", "server", d.Name)
}
if len(d.Block) != 0 {
t.Errorf("expected empty block, got %d children", len(d.Block))
}
}
func TestParseMultipleArgs(t *testing.T) {
cfg, err := Parse("return 301 https://example.com;")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Directives) != 1 {
t.Fatalf("expected 1 directive, got %d", len(cfg.Directives))
}
d := cfg.Directives[0]
if d.Name != "return" {
t.Errorf("expected name %q, got %q", "return", d.Name)
}
expected := []string{"301", "https://example.com"}
if len(d.Args) != len(expected) {
t.Fatalf("expected %d args, got %d", len(expected), len(d.Args))
}
for i, want := range expected {
if d.Args[i] != want {
t.Errorf("arg[%d]: expected %q, got %q", i, want, d.Args[i])
}
}
}

11
main.go
View File

@ -3,6 +3,7 @@
// 该文件包含命令行参数解析和应用程序启动逻辑:
// - 配置文件路径指定(-c/--config
// - 默认配置生成(--generate-config
// - nginx 配置导入(--import/-i
// - 版本信息显示(-v
//
// 使用示例:
@ -26,7 +27,9 @@ func main() {
cfgPathLong := flag.String("config", "", "配置文件路径(长参数)")
genConfig := flag.Bool("generate-config", false, "生成默认配置")
genConfigShort := flag.Bool("g", false, "生成默认配置(短参数)")
outputPath := flag.String("o", "", "输出文件路径(配合 --generate-config")
outputPath := flag.String("o", "", "输出文件路径(配合 --generate-config 或 --import")
importPath := flag.String("import", "", "导入 nginx 配置文件")
importPathShort := flag.String("i", "", "导入 nginx 配置文件(短参数)")
showVersion := flag.Bool("v", false, "显示版本")
flag.Parse()
@ -37,6 +40,10 @@ func main() {
configPath = *cfgPathLong
}
generate := *genConfig || *genConfigShort
nginxImport := *importPath
if *importPathShort != "" {
nginxImport = *importPathShort
}
os.Exit(app.Run(configPath, generate, *outputPath, *showVersion))
os.Exit(app.Run(configPath, generate, *outputPath, nginxImport, *showVersion))
}