diff --git a/api/template/quick_config.go b/api/template/quick_config.go new file mode 100644 index 00000000..3798422a --- /dev/null +++ b/api/template/quick_config.go @@ -0,0 +1,483 @@ +package template + +import ( + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/0xJacky/Nginx-UI/internal/nginx" + internalTemplate "github.com/0xJacky/Nginx-UI/internal/template" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "github.com/uozi-tech/cosy" +) + +const ( + QuickConfigTypeReverseProxy = "reverse_proxy" + QuickConfigTypeStatic = "static" + QuickConfigTypeRedirect = "redirect" +) + +type QuickConfigRequest struct { + Type string `json:"type" binding:"required,oneof=reverse_proxy static redirect"` + Domains []string `json:"domains"` + EnableTLS bool `json:"enable_tls"` + RedirectHTTPToHTTPS bool `json:"redirect_http_to_https"` + + // Reverse proxy + Scheme string `json:"scheme"` + Host string `json:"host"` + Port string `json:"port"` + EnableWebSocket bool `json:"enable_websocket"` + ClientMaxBodySize string `json:"client_max_body_size"` + + // Static site + WebRoot string `json:"web_root"` + Index string `json:"index"` + SpaFallback bool `json:"spa_fallback"` + + // Redirect + TargetURL string `json:"target_url"` + RedirectStatus string `json:"redirect_status"` +} + +func (r *QuickConfigRequest) fillDefaults() { + if r.Scheme == "" { + r.Scheme = "http" + } + if r.Host == "" { + r.Host = "127.0.0.1" + } + if r.Port == "" { + r.Port = "9000" + } + if r.ClientMaxBodySize == "" { + r.ClientMaxBodySize = "1000m" + } + if r.Index == "" { + r.Index = "index.html" + } + if r.RedirectStatus == "" { + r.RedirectStatus = "301" + } +} + +func (r *QuickConfigRequest) validate() error { + if len(r.Domains) == 0 { + return errors.New("domains is required") + } + + for _, domain := range r.Domains { + if strings.TrimSpace(domain) == "" { + return errors.New("domain cannot be empty") + } + if !isSafeNginxToken(domain) { + return errors.New("domain contains invalid characters") + } + } + + switch r.Type { + case QuickConfigTypeReverseProxy: + if r.Scheme != "http" && r.Scheme != "https" { + return errors.New("scheme must be http or https") + } + if !isSafeNginxToken(r.Host) { + return errors.New("host contains invalid characters") + } + port, err := strconv.Atoi(r.Port) + if err != nil || port < 1 || port > 65535 { + return errors.New("port must be between 1 and 65535") + } + if !reNginxSize.MatchString(r.ClientMaxBodySize) { + return errors.New("client_max_body_size must be a non-negative integer with an optional k, m, or g suffix") + } + case QuickConfigTypeStatic: + if strings.TrimSpace(r.WebRoot) == "" { + return errors.New("web_root is required") + } + if !isSafeNginxToken(r.WebRoot) { + return errors.New("web_root contains invalid characters") + } + if !isSafeNginxValue(r.Index) { + return errors.New("index contains invalid characters") + } + case QuickConfigTypeRedirect: + if strings.TrimSpace(r.TargetURL) == "" { + return errors.New("target_url is required") + } + if !isSafeNginxToken(r.TargetURL) { + return errors.New("target_url contains invalid characters") + } + target, err := url.ParseRequestURI(r.TargetURL) + if err != nil || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") { + return errors.New("target_url must be an absolute HTTP or HTTPS URL") + } + if r.RedirectStatus != "301" && r.RedirectStatus != "302" && r.RedirectStatus != "308" { + return errors.New("redirect_status must be 301, 302, or 308") + } + } + + return nil +} + +var ( + reUnsafeNginxValue = regexp.MustCompile(`[;{}\r\n]`) + reNginxSize = regexp.MustCompile(`^\d+[kKmMgG]?$`) +) + +func isSafeNginxValue(value string) bool { + return value == strings.TrimSpace(value) && value != "" && !reUnsafeNginxValue.MatchString(value) +} + +func isSafeNginxToken(value string) bool { + return isSafeNginxValue(value) && !strings.ContainsAny(value, " \t") +} + +// quickApp holds the per-type server-level content of a quick config. +type quickApp struct { + directives []*nginx.NgxDirective + locations []*nginx.NgxLocation + custom string +} + +func buildQuickApp(r *QuickConfigRequest) (app *quickApp, err error) { + app = &quickApp{} + + switch r.Type { + case QuickConfigTypeReverseProxy: + block, err := internalTemplate.ParseTemplate("block", "reverse-proxy.conf", map[string]internalTemplate.Variable{ + "enableWebSocket": {Value: r.EnableWebSocket}, + "clientMaxBodySize": {Value: r.ClientMaxBodySize}, + "scheme": {Value: r.Scheme}, + "host": {Value: r.Host}, + "port": {Value: r.Port}, + }) + if err != nil { + return nil, err + } + app.directives = block.Directives + app.locations = block.Locations + app.custom = block.Custom + case QuickConfigTypeStatic: + app.directives = append(app.directives, + &nginx.NgxDirective{Directive: "root", Params: r.WebRoot}, + &nginx.NgxDirective{Directive: "index", Params: r.Index}, + ) + if r.SpaFallback { + block, err := internalTemplate.ParseTemplate("block", "vue-router-history-mode.conf", nil) + if err != nil { + return nil, err + } + app.locations = block.Locations + } + case QuickConfigTypeRedirect: + block, err := internalTemplate.ParseTemplate("block", "redirect.conf", map[string]internalTemplate.Variable{ + "status": {Value: r.RedirectStatus}, + "target": {Value: r.TargetURL}, + }) + if err != nil { + return nil, err + } + app.locations = block.Locations + } + + return app, nil +} + +// letsEncryptLocation returns the HTTP challenge location block parsed from the +// letsencrypt.conf template. +func letsEncryptLocation() (*nginx.NgxLocation, error) { + block, err := internalTemplate.ParseTemplate("block", "letsencrypt.conf", nil) + if err != nil { + return nil, err + } + if len(block.Locations) == 0 { + return nil, errors.New("letsencrypt.conf contains no location") + } + return block.Locations[0], nil +} + +func buildQuickConfig(r *QuickConfigRequest) (ngxConfig *nginx.NgxConfig, err error) { + app, err := buildQuickApp(r) + if err != nil { + return nil, err + } + + serverName := strings.Join(r.Domains, " ") + + port80 := nginx.NewNgxServer() + port80.Directives = []*nginx.NgxDirective{ + {Directive: "listen", Params: "80"}, + {Directive: "listen", Params: "[::]:80"}, + {Directive: "server_name", Params: serverName}, + } + + var tlsServer *nginx.NgxServer + if r.EnableTLS { + tlsServer = nginx.NewNgxServer() + tlsServer.Directives = []*nginx.NgxDirective{ + {Directive: "listen", Params: "443 ssl"}, + {Directive: "listen", Params: "[::]:443 ssl"}, + {Directive: "server_name", Params: serverName}, + {Directive: "ssl_certificate"}, + {Directive: "ssl_certificate_key"}, + } + tlsServer.Directives = append(tlsServer.Directives, app.directives...) + tlsServer.Locations = append(tlsServer.Locations, app.locations...) + + challengeLocation, err := letsEncryptLocation() + if err != nil { + return nil, err + } + tlsServer.Locations = append(tlsServer.Locations, challengeLocation) + + if r.RedirectHTTPToHTTPS { + port80.Directives = append(port80.Directives, + &nginx.NgxDirective{Directive: "return", Params: "301 https://$host$request_uri"}) + port80.Locations = append(port80.Locations, challengeLocation) + } else { + port80.Directives = append(port80.Directives, app.directives...) + port80.Locations = append(port80.Locations, app.locations...) + port80.Locations = append(port80.Locations, challengeLocation) + } + } else { + port80.Directives = append(port80.Directives, app.directives...) + port80.Locations = append(port80.Locations, app.locations...) + } + + servers := []*nginx.NgxServer{port80} + if tlsServer != nil { + servers = append(servers, tlsServer) + } + + return &nginx.NgxConfig{ + Name: r.Domains[0], + Custom: app.custom, + Upstreams: make([]*nginx.NgxUpstream, 0), + Servers: servers, + }, nil +} + +func GetQuickConfig(c *gin.Context) { + var req QuickConfigRequest + if !cosy.BindAndValid(c, &req) { + return + } + + req.fillDefaults() + + if err := req.validate(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + + ngxConfig, err := buildQuickConfig(&req) + if err != nil { + cosy.ErrHandler(c, err) + return + } + + content, err := ngxConfig.BuildConfig() + if err != nil { + cosy.ErrHandler(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + "template": content, + "tokenized": ngxConfig, + }) +} + +var ( + reProxyPass = regexp.MustCompile(`proxy_pass\s+(\S+)`) + reReturn = regexp.MustCompile(`return\s+(\d{3})\s+(\S+)`) + reRoot = regexp.MustCompile(`root\s+(\S+)`) + reIndex = regexp.MustCompile(`index\s+(\S+)`) + reClientBodySize = regexp.MustCompile(`client_max_body_size\s+(\S+)`) + reTryFilesSpa = regexp.MustCompile(`try_files\s+.*\s+/index\.html`) + reProxyPassURL = regexp.MustCompile(`^(https?)://([^:/]+)(?::(\d+))?`) +) + +func findDirective(directives []*nginx.NgxDirective, name string) string { + for _, d := range directives { + if d.Directive == name { + return d.Params + } + } + return "" +} + +func cleanParam(raw string) string { + return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ";")) +} + +func serverListensSSL(directives []*nginx.NgxDirective) bool { + for _, d := range directives { + if d.Directive == "listen" && strings.Contains(d.Params, "ssl") { + return true + } + } + return false +} + +func parseProxyPass(raw string) (scheme, host, port string) { + m := reProxyPassURL.FindStringSubmatch(raw) + if m == nil { + return + } + return m[1], m[2], m[3] +} + +// analyzeNgxConfig best-effort derives a QuickConfigRequest from an existing +// nginx config so the quick-setup wizard can pre-fill the edit form. Configs +// that do not match the quick-setup shapes fall back to safe defaults. +func analyzeNgxConfig(ngxConfig *nginx.NgxConfig) (req QuickConfigRequest) { + req.Type = QuickConfigTypeReverseProxy + + var domains []string + seenDomains := make(map[string]struct{}) + var hasTLS, redirectHTTPToHTTPS bool + + var rpProxyPass, rpHasWebSocket bool + var staticRoot, staticIndex string + var staticSpa bool + var bodySize string + var redirectStatus, redirectTarget string + + for _, server := range ngxConfig.Servers { + if server == nil { + continue + } + + if params := findDirective(server.Directives, "server_name"); params != "" { + for _, name := range strings.Fields(params) { + if name == "_" { + continue + } + if _, ok := seenDomains[name]; ok { + continue + } + seenDomains[name] = struct{}{} + domains = append(domains, name) + } + } + + if serverListensSSL(server.Directives) { + hasTLS = true + } + + if params := findDirective(server.Directives, "return"); params != "" && + strings.Contains(params, "https://$host$request_uri") { + redirectHTTPToHTTPS = true + } + + if staticRoot == "" { + staticRoot = cleanParam(findDirective(server.Directives, "root")) + } + if staticIndex == "" { + staticIndex = cleanParam(findDirective(server.Directives, "index")) + } + if bodySize == "" { + bodySize = cleanParam(findDirective(server.Directives, "client_max_body_size")) + } + + for _, location := range server.Locations { + // The ACME HTTP-01 challenge location proxies to the local + // challenge port; ignore it when detecting the quick-setup type. + if strings.Contains(location.Path, "acme-challenge") { + continue + } + content := location.Content + + if m := reReturn.FindStringSubmatch(content); m != nil && m[2] != "https://$host$request_uri" { + redirectStatus = cleanParam(m[1]) + redirectTarget = cleanParam(m[2]) + } + + if !rpProxyPass { + if m := reProxyPass.FindStringSubmatch(content); m != nil { + rpProxyPass = true + req.Scheme, req.Host, req.Port = parseProxyPass(cleanParam(m[1])) + } + } + + if strings.Contains(content, "proxy_http_version 1.1") || + strings.Contains(content, "proxy_set_header Upgrade") { + rpHasWebSocket = true + } + + if staticRoot == "" { + if m := reRoot.FindStringSubmatch(content); m != nil { + staticRoot = cleanParam(m[1]) + } + } + if staticIndex == "" { + if m := reIndex.FindStringSubmatch(content); m != nil { + staticIndex = cleanParam(m[1]) + } + } + if reTryFilesSpa.MatchString(content) { + staticSpa = true + } + if bodySize == "" { + if m := reClientBodySize.FindStringSubmatch(content); m != nil { + bodySize = cleanParam(m[1]) + } + } + } + } + + req.Domains = domains + req.EnableTLS = hasTLS + req.RedirectHTTPToHTTPS = redirectHTTPToHTTPS + + switch { + case redirectStatus != "" && redirectTarget != "": + req.Type = QuickConfigTypeRedirect + req.RedirectStatus = redirectStatus + req.TargetURL = redirectTarget + case rpProxyPass: + req.Type = QuickConfigTypeReverseProxy + req.EnableWebSocket = rpHasWebSocket + default: + if staticRoot != "" { + req.Type = QuickConfigTypeStatic + req.WebRoot = staticRoot + req.Index = staticIndex + req.SpaFallback = staticSpa + } + } + + if bodySize != "" { + req.ClientMaxBodySize = bodySize + } + + req.fillDefaults() + return req +} + +func AnalyzeQuickConfig(c *gin.Context) { + var json struct { + Config string `json:"config" binding:"required"` + } + if !cosy.BindAndValid(c, &json) { + return + } + + ngxConfig, err := nginx.ParseNgxConfigByContent(json.Config) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + + req := analyzeNgxConfig(ngxConfig) + + c.JSON(http.StatusOK, gin.H{ + "message": "ok", + "request": req, + }) +} diff --git a/api/template/quick_config_test.go b/api/template/quick_config_test.go new file mode 100644 index 00000000..7af7fe82 --- /dev/null +++ b/api/template/quick_config_test.go @@ -0,0 +1,428 @@ +package template + +import ( + "strings" + "testing" + + "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/stretchr/testify/assert" +) + +func TestBuildQuickConfigReverseProxy(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeReverseProxy, + Domains: []string{"example.com", "www.example.com"}, + EnableWebSocket: true, + ClientMaxBodySize: "100m", + Scheme: "http", + Host: "127.0.0.1", + Port: "8080", + EnableTLS: true, + RedirectHTTPToHTTPS: true, + } + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + assert.Len(t, cfg.Servers, 2) + assert.Equal(t, "example.com", cfg.Name) + + port80 := cfg.Servers[0] + // HTTP -> HTTPS redirect + returnDirectives := findServerDirectives(port80, "return") + if assert.Len(t, returnDirectives, 1) { + assert.Equal(t, "301 https://$host$request_uri", returnDirectives[0].Params) + } + // Challenge location must be available on port 80 for HTTP-01. + assertLocation(t, port80, "~ /.well-known/acme-challenge") + + tls := cfg.Servers[1] + assert.Equal(t, "443 ssl", findServerDirectives(tls, "listen")[0].Params) + assert.Equal(t, "example.com www.example.com", findServerDirectives(tls, "server_name")[0].Params) + // Empty ssl_certificate placeholders, filled in by the cert flow. + assert.Empty(t, findServerDirectives(tls, "ssl_certificate")[0].Params) + assert.Empty(t, findServerDirectives(tls, "ssl_certificate_key")[0].Params) + + // Reverse proxy location / must proxy to the target. + location := findLocation(tls, "/") + assert.Contains(t, location.Content, "proxy_pass http://127.0.0.1:8080/;") + assert.Contains(t, location.Content, "client_max_body_size 100m;") + // WebSocket upgrade headers. + assert.Contains(t, location.Content, "proxy_set_header Upgrade $http_upgrade;") + assert.Contains(t, cfg.Custom, "map $http_upgrade $connection_upgrade") + + // The built config must be re-parseable. + content, err := cfg.BuildConfig() + assert.NoError(t, err) + assert.NotEmpty(t, content) + parsed, err := nginx.ParseNgxConfigByContent(content) + assert.NoError(t, err) + assert.Len(t, parsed.Servers, 2) +} + +func TestBuildQuickConfigReverseProxyNoTLS(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeReverseProxy, + Domains: []string{"example.com"}, + Scheme: "https", + Host: "10.0.0.5", + Port: "8443", + EnableWebSocket: false, + ClientMaxBodySize: "10m", + } + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + assert.Len(t, cfg.Servers, 1) + // Only the proxy location, no challenge location when TLS is disabled. + assertLocation(t, cfg.Servers[0], "/") + assert.Nil(t, findLocation(cfg.Servers[0], "~ /.well-known/acme-challenge")) + + location := findLocation(cfg.Servers[0], "/") + assert.Contains(t, location.Content, "proxy_pass https://10.0.0.5:8443/;") + // WebSocket headers only rendered when enabled. + assert.NotContains(t, location.Content, "proxy_set_header Upgrade $http_upgrade;") + // The Forwarded maps must stay even without WebSocket because the + // unconditional proxy_set_header Forwarded line depends on them. + assert.Contains(t, cfg.Custom, "map $http_forwarded $proxy_add_forwarded") + assert.Contains(t, cfg.Custom, "map $remote_addr $proxy_forwarded_elem") + assert.NotContains(t, cfg.Custom, "map $http_upgrade $connection_upgrade") +} + +func TestBuildQuickConfigStatic(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"static.example.com"}, + WebRoot: "/var/www/html", + SpaFallback: true, + } + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + assert.Len(t, cfg.Servers, 1) + assert.Equal(t, "/var/www/html", findServerDirectives(cfg.Servers[0], "root")[0].Params) + assert.Equal(t, "index.html", findServerDirectives(cfg.Servers[0], "index")[0].Params) + assertLocation(t, cfg.Servers[0], "/") + + content, err := cfg.BuildConfig() + assert.NoError(t, err) + assert.Contains(t, content, "root /var/www/html;") + assert.Contains(t, content, "try_files $uri $uri/ /index.html;") + + _, err = nginx.ParseNgxConfigByContent(content) + assert.NoError(t, err) +} + +func TestBuildQuickConfigStaticTLSWithoutRedirect(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"static.example.com"}, + WebRoot: "/srv/www", + EnableTLS: true, + RedirectHTTPToHTTPS: false, + } + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + assert.Len(t, cfg.Servers, 2) + // Port 80 keeps serving the site and exposes the challenge location. + assert.NotNil(t, findServerDirectives(cfg.Servers[0], "root")) + assertLocation(t, cfg.Servers[0], "~ /.well-known/acme-challenge") + assertLocation(t, cfg.Servers[1], "~ /.well-known/acme-challenge") +} + +func TestBuildQuickConfigRedirect(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeRedirect, + Domains: []string{"old.example.com"}, + TargetURL: "https://new.example.com", + RedirectStatus: "308", + } + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + assert.Len(t, cfg.Servers, 1) + + location := findLocation(cfg.Servers[0], "/") + assert.Contains(t, location.Content, "return 308 https://new.example.com;") + content, err := cfg.BuildConfig() + assert.NoError(t, err) + assert.Contains(t, content, "return 308 https://new.example.com;") + _, err = nginx.ParseNgxConfigByContent(content) + assert.NoError(t, err) +} + +func TestBuildQuickConfigValidation(t *testing.T) { + t.Run("missing domains", func(t *testing.T) { + req := QuickConfigRequest{Type: QuickConfigTypeStatic, WebRoot: "/var/www"} + assert.Error(t, req.validate()) + }) + + t.Run("invalid scheme", func(t *testing.T) { + req := QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"a.com"}, Scheme: "ftp"} + assert.Error(t, req.validate()) + }) + + t.Run("static missing web_root", func(t *testing.T) { + req := QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"a.com"}} + assert.Error(t, req.validate()) + }) + + t.Run("redirect missing target", func(t *testing.T) { + req := QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"a.com"}} + assert.Error(t, req.validate()) + }) + + t.Run("defaults applied", func(t *testing.T) { + req := QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"a.com"}, TargetURL: "https://b.com"} + req.fillDefaults() + assert.Equal(t, "301", req.RedirectStatus) + }) + + tests := []struct { + name string + req QuickConfigRequest + }{ + { + name: "domain directive injection", + req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com;\nreturn 200"}, WebRoot: "/var/www"}, + }, + { + name: "reverse proxy host directive injection", + req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1;", Port: "9000"}, + }, + { + name: "reverse proxy non-numeric port", + req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "http"}, + }, + { + name: "reverse proxy out-of-range port", + req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "65536"}, + }, + { + name: "reverse proxy invalid body size", + req: QuickConfigRequest{Type: QuickConfigTypeReverseProxy, Domains: []string{"example.com"}, Host: "127.0.0.1", Port: "9000", ClientMaxBodySize: "100m;"}, + }, + { + name: "static root directive injection", + req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com"}, WebRoot: "/var/www;\nreturn 200"}, + }, + { + name: "static index directive injection", + req: QuickConfigRequest{Type: QuickConfigTypeStatic, Domains: []string{"example.com"}, WebRoot: "/var/www", Index: "index.html;"}, + }, + { + name: "redirect target directive injection", + req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "https://new.example.com;return", RedirectStatus: "301"}, + }, + { + name: "redirect non-http target", + req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "javascript:alert(1)", RedirectStatus: "301"}, + }, + { + name: "redirect unsupported status", + req: QuickConfigRequest{Type: QuickConfigTypeRedirect, Domains: []string{"example.com"}, TargetURL: "https://new.example.com", RedirectStatus: "307"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.req.fillDefaults() + assert.Error(t, tt.req.validate()) + }) + } + + t.Run("bracketed IPv6 reverse proxy host", func(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeReverseProxy, + Domains: []string{"example.com"}, + Host: "[::1]", + Port: "9000", + } + req.fillDefaults() + assert.NoError(t, req.validate()) + }) +} + +func assertQuickConfigEqual(t *testing.T, expected, actual QuickConfigRequest) { + t.Helper() + assert.Equal(t, expected.Type, actual.Type) + assert.Equal(t, expected.Domains, actual.Domains) + assert.Equal(t, expected.EnableTLS, actual.EnableTLS) + assert.Equal(t, expected.RedirectHTTPToHTTPS, actual.RedirectHTTPToHTTPS) + assert.Equal(t, expected.Scheme, actual.Scheme) + assert.Equal(t, expected.Host, actual.Host) + assert.Equal(t, expected.Port, actual.Port) + assert.Equal(t, expected.EnableWebSocket, actual.EnableWebSocket) + assert.Equal(t, expected.ClientMaxBodySize, actual.ClientMaxBodySize) + assert.Equal(t, expected.WebRoot, actual.WebRoot) + assert.Equal(t, expected.Index, actual.Index) + assert.Equal(t, expected.SpaFallback, actual.SpaFallback) + assert.Equal(t, expected.TargetURL, actual.TargetURL) + assert.Equal(t, expected.RedirectStatus, actual.RedirectStatus) +} + +// assertAnalyzeRoundTrip builds a config from the request, re-parses it and +// asserts that analysis recovers the exact same request. This guarantees the +// edit-page pre-fill mirrors what the wizard generated. +func assertAnalyzeRoundTrip(t *testing.T, req QuickConfigRequest) { + t.Helper() + req.fillDefaults() + assert.NoError(t, req.validate()) + + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + content, err := cfg.BuildConfig() + assert.NoError(t, err) + + parsed, err := nginx.ParseNgxConfigByContent(content) + assert.NoError(t, err) + + analyzed := analyzeNgxConfig(parsed) + assertQuickConfigEqual(t, req, analyzed) +} + +func TestAnalyzeQuickConfigRoundTrip(t *testing.T) { + t.Run("reverse proxy with TLS and redirect", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeReverseProxy, + Domains: []string{"example.com", "www.example.com"}, + EnableWebSocket: true, + ClientMaxBodySize: "100m", + Scheme: "http", + Host: "127.0.0.1", + Port: "8080", + EnableTLS: true, + RedirectHTTPToHTTPS: true, + }) + }) + + t.Run("reverse proxy no TLS", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeReverseProxy, + Domains: []string{"example.com"}, + Scheme: "https", + Host: "10.0.0.5", + Port: "8443", + EnableWebSocket: false, + ClientMaxBodySize: "10m", + }) + }) + + t.Run("static with SPA", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"static.example.com"}, + WebRoot: "/var/www/html", + SpaFallback: true, + }) + }) + + t.Run("static TLS without redirect", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"static.example.com"}, + WebRoot: "/srv/www", + EnableTLS: true, + RedirectHTTPToHTTPS: false, + }) + }) + + t.Run("redirect", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeRedirect, + Domains: []string{"old.example.com"}, + TargetURL: "https://new.example.com", + RedirectStatus: "308", + }) + }) + + t.Run("redirect with TLS and redirect to https", func(t *testing.T) { + assertAnalyzeRoundTrip(t, QuickConfigRequest{ + Type: QuickConfigTypeRedirect, + Domains: []string{"old.example.com"}, + TargetURL: "https://new.example.com", + RedirectStatus: "301", + EnableTLS: true, + RedirectHTTPToHTTPS: true, + }) + }) +} + +func TestAnalyzeQuickConfigBestEffort(t *testing.T) { + t.Run("unrecognized config falls back to defaults", func(t *testing.T) { + cfg, err := buildQuickConfig(&QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"a.com"}, + WebRoot: "/tmp/site", + }) + assert.NoError(t, err) + + var kept []*nginx.NgxDirective + for _, d := range cfg.Servers[0].Directives { + if d.Directive == "root" || d.Directive == "index" { + continue + } + kept = append(kept, d) + } + cfg.Servers[0].Directives = kept + + req := analyzeNgxConfig(cfg) + assert.Equal(t, QuickConfigTypeReverseProxy, req.Type) + assert.Equal(t, []string{"a.com"}, req.Domains) + }) + + t.Run("server name default underscore is ignored", func(t *testing.T) { + req := QuickConfigRequest{ + Type: QuickConfigTypeStatic, + Domains: []string{"a.com"}, + WebRoot: "/tmp/site", + } + req.fillDefaults() + cfg, err := buildQuickConfig(&req) + assert.NoError(t, err) + cfg.Servers[0].Directives = append(cfg.Servers[0].Directives, + &nginx.NgxDirective{Directive: "listen", Params: "80 default_server"}) + cfg.Servers[0].Directives = append(cfg.Servers[0].Directives, + &nginx.NgxDirective{Directive: "server_name", Params: "_"}) + + analyzed := analyzeNgxConfig(cfg) + assert.Equal(t, []string{"a.com"}, analyzed.Domains) + }) +} + +func findServerDirectives(server *nginx.NgxServer, name string) []*nginx.NgxDirective { + var result []*nginx.NgxDirective + for _, d := range server.Directives { + if d.Directive == name { + result = append(result, d) + } + } + return result +} + +func findLocation(server *nginx.NgxServer, path string) *nginx.NgxLocation { + for _, l := range server.Locations { + if strings.TrimSpace(l.Path) == path { + return l + } + } + return nil +} + +func assertLocation(t *testing.T, server *nginx.NgxServer, path string) { + t.Helper() + l := findLocation(server, path) + if assert.NotNil(t, l, "expected location %q", path) { + assert.NotEmpty(t, l.Content) + } +} diff --git a/api/template/router.go b/api/template/router.go index 536ebf31..dda05b2f 100644 --- a/api/template/router.go +++ b/api/template/router.go @@ -8,4 +8,6 @@ func InitRouter(r *gin.RouterGroup) { r.GET("templates/blocks", GetTemplateBlockList) r.GET("templates/block/:name", GetTemplateBlock) r.POST("templates/block/:name", GetTemplateBlock) + r.POST("templates/quick_config", GetQuickConfig) + r.POST("templates/quick_config/analyze", AnalyzeQuickConfig) } diff --git a/app/src/api/template.ts b/app/src/api/template.ts index 3437ec5c..bca32ea1 100644 --- a/app/src/api/template.ts +++ b/app/src/api/template.ts @@ -1,4 +1,4 @@ -import type { NgxDirective, NgxLocation, NgxServer } from '@/api/ngx' +import type { NgxConfig, NgxDirective, NgxLocation, NgxServer } from '@/api/ngx' import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request' export interface Variable { @@ -20,6 +20,33 @@ export interface Template extends NgxServer { directives?: NgxDirective[] } +export type QuickConfigType = 'reverse_proxy' | 'static' | 'redirect' + +export interface QuickConfigRequest { + type: QuickConfigType + domains: string[] + enable_tls?: boolean + redirect_http_to_https?: boolean + // reverse_proxy + scheme?: 'http' | 'https' + host?: string + port?: string + enable_websocket?: boolean + client_max_body_size?: string + // static + web_root?: string + index?: string + spa_fallback?: boolean + // redirect + target_url?: string + redirect_status?: '301' | '302' | '308' +} + +export interface QuickConfigResponse { + template: string + tokenized: NgxConfig +} + const baseUrl = '/templates' const template = extendCurdApi(useCurdApi