package nginxconfig import ( "regexp" "strings" "testing" "github.com/gin-gonic/gin" ) func TestRenderAllowlistAndHTTP01(t *testing.T) { routes := gin.RoutesInfo{ {Method: "GET", Path: "/api/health"}, {Method: "GET", Path: "/api/tunnels/:id/status"}, {Method: "POST", Path: "/api/tunnels/:id/start"}, {Method: "PUT", Path: "/api/tunnels/:id/start"}, } httpSite, httpsSite, err := Render("test-link.appx365.com", routes) if err != nil { t.Fatal(err) } for _, fragment := range []string{"server_name test-link.appx365.com;", "location ^~ /.well-known/acme-challenge/", "try_files $uri =404;", "location / { return 444; }"} { if !strings.Contains(httpSite, fragment) { t.Errorf("missing HTTP rule %q", fragment) } } for _, fragment := range []string{ "ssl_reject_handshake on;", "location = /api/health", "if ($host != test-link.appx365.com) { return 444; }", "location ~ ^/api/tunnels/[^/]+/status$", "location ~ ^/api/tunnels/[^/]+/start$", "location = /login", "location = /settings", "location = /identity", "location = /plugins", "location = /nodes", "location = /favicon.svg", "location / { return 444; }", } { if !strings.Contains(httpsSite, fragment) { t.Errorf("missing HTTPS rule %q", fragment) } } if strings.Count(httpsSite, "location ~ ^/api/tunnels/[^/]+/start$") != 1 { t.Fatal("duplicate API paths must have one location") } pattern := regexp.MustCompile(routePattern("/api/tunnels/:id/status")) for _, input := range []string{"/api/tunnels/4/status", "/api/tunnels/abc/status"} { if !pattern.MatchString(input) { t.Errorf("valid route rejected: %s", input) } } for _, input := range []string{"/api/tunnels/4/status/evil", "/api/tunnels/4/config", "/.well-known/openid-configuration"} { if pattern.MatchString(input) { t.Errorf("unknown route accepted: %s", input) } } } func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) { if _, _, err := Render("example.com; return 200", nil); err == nil { t.Fatal("injected domain accepted") } _, httpsSite, err := Render("example.com", gin.RoutesInfo{{Path: "/api/health\nreturn 200;"}}) if err != nil { t.Fatal(err) } if strings.Contains(httpsSite, "return 200;") { t.Fatal("injected route accepted") } } func TestRenderIdentityAndSingleAuthPort(t *testing.T) { _, site, err := RenderWithAuth("vpn.example.com", gin.RoutesInfo{ {Method: "GET", Path: "/api/health"}, {Method: "PUT", Path: "/api/system/tls/domain"}, {Method: "PUT", Path: "/api/system/identity"}, {Method: "POST", Path: "/api/system/identity/gateway"}, }, 19090) if err != nil { t.Fatal(err) } for _, route := range []string{"location = /id/.well-known/openid-configuration", "location = /id/feishu/callback", "location = /id/token", "location = /api/system/tls/domain", "location = /api/system/identity", "location = /api/system/identity/gateway", "location ^~ /oauth2/", "proxy_pass http://127.0.0.1:19090;", "location / { return 444; }"} { if !strings.Contains(site, route) { t.Errorf("identity route missing: %s", route) } } if strings.Contains(site, "location ^~ /id/") || strings.Contains(site, "location ^~ /.well-known/") { t.Fatal("identity routes must stay allowlisted") } if _, _, err := RenderWithAuth("vpn.example.com", nil, 80); err == nil { t.Fatal("invalid auth port accepted") } _, withoutAuth, err := Render("vpn.example.com", nil) if err != nil || strings.Contains(withoutAuth, "location ^~ /oauth2/") { t.Fatal("auth component exposed without a configured port") } } func TestRenderHTTPCustomPort(t *testing.T) { routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}} httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "http", Port: 8080}, routes, 0) if err != nil { t.Fatal(err) } if httpsSite != "" { t.Fatalf("http scheme must not render https site, got %d bytes", len(httpsSite)) } for _, fragment := range []string{"listen 8080;", "server_name vpn.example.com;", "X-Forwarded-Proto http;", "location = /api/health"} { if !strings.Contains(httpSite, fragment) { t.Errorf("missing HTTP rule %q in:\n%s", fragment, httpSite) } } if strings.Contains(httpSite, "acme-challenge") { t.Fatal("http entry must not include ACME challenge location") } } func TestRenderHTTPIPv4AndRejectHTTPSIPv4(t *testing.T) { opts := RenderOptions{Domain: "121.4.111.208", Scheme: "http", Port: 28080} site, httpsSite, err := RenderWithOptions(opts, nil, 0) if err != nil || httpsSite != "" || !strings.Contains(site, "listen 28080;") || !strings.Contains(site, "server_name 121.4.111.208;") { t.Fatalf("HTTP IPv4 rendering failed: %v, %q, %q", err, site, httpsSite) } opts.Scheme = "https" if _, _, err := RenderWithOptions(opts, nil, 0); err == nil { t.Fatal("HTTPS IPv4 must be rejected") } } func TestRenderHTTPSCustomPort(t *testing.T) { routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}} httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0) if err != nil { t.Fatal(err) } if !strings.Contains(httpSite, "listen 80;") || !strings.Contains(httpSite, "acme-challenge") { t.Fatalf("https custom port must keep :80 ACME http site:\n%s", httpSite) } if !strings.Contains(httpsSite, "listen 8443 ssl;") { t.Fatalf("https custom port listen missing:\n%s", httpsSite) } if !strings.Contains(httpsSite, "X-Forwarded-Proto https;") { t.Fatal("missing forwarded proto https") } }