Skip to content

Commit b923999

Browse files
committed
enhance(upstream): proxy parser to support grpc_pass
1 parent d37a463 commit b923999

File tree

2 files changed

+202
-80
lines changed

2 files changed

+202
-80
lines changed

internal/upstream/proxy_parser.go

Lines changed: 54 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import (
55
"regexp"
66
"strings"
77

8-
"github.com/0xJacky/Nginx-UI/internal/nginx"
98
"github.com/0xJacky/Nginx-UI/settings"
109
)
1110

1211
// ProxyTarget represents a proxy destination
1312
type ProxyTarget struct {
1413
Host string `json:"host"`
1514
Port string `json:"port"`
16-
Type string `json:"type"` // "proxy_pass" or "upstream"
15+
Type string `json:"type"` // "proxy_pass", "grpc_pass" or "upstream"
1716
Resolver string `json:"resolver"` // DNS resolver address (e.g., "127.0.0.1:8600")
1817
IsConsul bool `json:"is_consul"` // Whether this is a consul service discovery target
1918
ServiceURL string `json:"service_url"` // Full service URL for consul (e.g., "service.consul service=redacted-net resolve")
@@ -82,88 +81,59 @@ func ParseProxyTargetsFromRawContent(content string) []ProxyTarget {
8281
proxyPassURL := strings.TrimSpace(match[1])
8382
// Skip if this proxy_pass references an upstream
8483
if !isUpstreamReference(proxyPassURL, upstreamNames) {
85-
target := parseProxyPassURL(proxyPassURL)
84+
target := parseProxyPassURL(proxyPassURL, "proxy_pass")
8685
if target.Host != "" {
8786
targets = append(targets, target)
8887
}
8988
}
9089
}
9190
}
9291

93-
return deduplicateTargets(targets)
94-
}
95-
96-
// parseUpstreamServers extracts server addresses from upstream blocks
97-
func parseUpstreamServers(upstream *nginx.NgxUpstream) []ProxyTarget {
98-
var targets []ProxyTarget
99-
100-
// Create upstream context for this upstream block
101-
ctx := &UpstreamContext{
102-
Name: upstream.Name,
103-
}
104-
105-
// Extract resolver from upstream directives
106-
for _, directive := range upstream.Directives {
107-
if directive.Directive == "resolver" {
108-
resolverParts := strings.Fields(directive.Params)
109-
if len(resolverParts) > 0 {
110-
ctx.Resolver = resolverParts[0]
111-
}
112-
}
113-
}
114-
115-
for _, directive := range upstream.Directives {
116-
if directive.Directive == "server" {
117-
target := parseServerAddress(directive.Params, "upstream", ctx)
118-
if target.Host != "" {
119-
targets = append(targets, target)
120-
}
121-
}
122-
}
123-
124-
return targets
125-
}
126-
127-
// parseLocationProxyPass extracts proxy_pass from location content
128-
func parseLocationProxyPass(content string) []ProxyTarget {
129-
var targets []ProxyTarget
130-
131-
// Use regex to find proxy_pass directives
132-
proxyPassRegex := regexp.MustCompile(`(?m)^\s*proxy_pass\s+([^;]+);`)
133-
matches := proxyPassRegex.FindAllStringSubmatch(content, -1)
92+
// Parse grpc_pass directives, but skip upstream references
93+
grpcPassRegex := regexp.MustCompile(`(?m)^\s*grpc_pass\s+([^;]+);`)
94+
grpcMatches := grpcPassRegex.FindAllStringSubmatch(content, -1)
13495

135-
for _, match := range matches {
96+
for _, match := range grpcMatches {
13697
if len(match) >= 2 {
137-
target := parseProxyPassURL(strings.TrimSpace(match[1]))
138-
if target.Host != "" {
139-
targets = append(targets, target)
98+
grpcPassURL := strings.TrimSpace(match[1])
99+
// Skip if this grpc_pass references an upstream
100+
if !isUpstreamReference(grpcPassURL, upstreamNames) {
101+
target := parseProxyPassURL(grpcPassURL, "grpc_pass")
102+
if target.Host != "" {
103+
targets = append(targets, target)
104+
}
140105
}
141106
}
142107
}
143108

144-
return targets
109+
return deduplicateTargets(targets)
145110
}
146111

147-
// parseProxyPassURL parses a proxy_pass URL and extracts host and port
148-
func parseProxyPassURL(proxyPass string) ProxyTarget {
149-
proxyPass = strings.TrimSpace(proxyPass)
112+
// parseProxyPassURL parses a proxy_pass or grpc_pass URL and extracts host and port
113+
func parseProxyPassURL(passURL, passType string) ProxyTarget {
114+
passURL = strings.TrimSpace(passURL)
150115

151116
// Skip URLs that contain Nginx variables
152-
if strings.Contains(proxyPass, "$") {
117+
if strings.Contains(passURL, "$") {
153118
return ProxyTarget{}
154119
}
155120

156-
// Handle HTTP/HTTPS URLs (e.g., "http://backend")
157-
if strings.HasPrefix(proxyPass, "http://") || strings.HasPrefix(proxyPass, "https://") {
158-
if parsedURL, err := url.Parse(proxyPass); err == nil {
121+
// Handle HTTP/HTTPS/gRPC URLs (e.g., "http://backend", "grpc://backend")
122+
if strings.HasPrefix(passURL, "http://") || strings.HasPrefix(passURL, "https://") || strings.HasPrefix(passURL, "grpc://") || strings.HasPrefix(passURL, "grpcs://") {
123+
if parsedURL, err := url.Parse(passURL); err == nil {
159124
host := parsedURL.Hostname()
160125
port := parsedURL.Port()
161126

162127
// Set default ports if not specified
163128
if port == "" {
164-
if parsedURL.Scheme == "https" {
129+
switch parsedURL.Scheme {
130+
case "https":
131+
port = "443"
132+
case "grpcs":
165133
port = "443"
166-
} else {
134+
case "grpc":
135+
port = "80"
136+
default: // http
167137
port = "80"
168138
}
169139
}
@@ -176,15 +146,15 @@ func parseProxyPassURL(proxyPass string) ProxyTarget {
176146
return ProxyTarget{
177147
Host: host,
178148
Port: port,
179-
Type: "proxy_pass",
149+
Type: passType,
180150
}
181151
}
182152
}
183153

184154
// Handle direct address format for stream module (e.g., "127.0.0.1:8080", "backend.example.com:12345")
185-
// This is used in stream configurations where proxy_pass doesn't require a protocol
186-
if !strings.Contains(proxyPass, "://") {
187-
target := parseServerAddress(proxyPass, "proxy_pass", nil) // No upstream context for this function
155+
// This is used in stream configurations where proxy_pass/grpc_pass doesn't require a protocol
156+
if !strings.Contains(passURL, "://") {
157+
target := parseServerAddress(passURL, passType, nil) // No upstream context for this function
188158

189159
// Skip if this is the HTTP challenge port used by Let's Encrypt
190160
if target.Host == "127.0.0.1" && target.Port == settings.CertSettings.HTTPChallengePort {
@@ -262,7 +232,7 @@ func isConsulServiceDiscovery(serverAddr string) bool {
262232
if strings.Contains(serverAddr, "service=") && strings.Contains(serverAddr, "resolve") {
263233
return true
264234
}
265-
// Legacy consul format: "service.consul service=name resolve"
235+
// Legacy consul format: "service.consul service=name resolve"
266236
return strings.Contains(serverAddr, "service.consul") &&
267237
(strings.Contains(serverAddr, "service=") || strings.Contains(serverAddr, "resolve"))
268238
}
@@ -327,17 +297,17 @@ func deduplicateTargets(targets []ProxyTarget) []ProxyTarget {
327297
return result
328298
}
329299

330-
// isUpstreamReference checks if a proxy_pass URL references an upstream block
331-
func isUpstreamReference(proxyPass string, upstreamNames map[string]bool) bool {
332-
proxyPass = strings.TrimSpace(proxyPass)
300+
// isUpstreamReference checks if a proxy_pass or grpc_pass URL references an upstream block
301+
func isUpstreamReference(passURL string, upstreamNames map[string]bool) bool {
302+
passURL = strings.TrimSpace(passURL)
333303

334-
// For HTTP/HTTPS URLs, parse the URL to extract the hostname
335-
if strings.HasPrefix(proxyPass, "http://") || strings.HasPrefix(proxyPass, "https://") {
304+
// For HTTP/HTTPS/gRPC URLs, parse the URL to extract the hostname
305+
if strings.HasPrefix(passURL, "http://") || strings.HasPrefix(passURL, "https://") || strings.HasPrefix(passURL, "grpc://") || strings.HasPrefix(passURL, "grpcs://") {
336306
// Handle URLs with nginx variables (e.g., "https://myUpStr$request_uri")
337307
// Extract the scheme and hostname part before any nginx variables
338-
schemeAndHost := proxyPass
339-
if dollarIndex := strings.Index(proxyPass, "$"); dollarIndex != -1 {
340-
schemeAndHost = proxyPass[:dollarIndex]
308+
schemeAndHost := passURL
309+
if dollarIndex := strings.Index(passURL, "$"); dollarIndex != -1 {
310+
schemeAndHost = passURL[:dollarIndex]
341311
}
342312

343313
// Try to parse the URL, if it fails, try manual extraction
@@ -348,11 +318,15 @@ func isUpstreamReference(proxyPass string, upstreamNames map[string]bool) bool {
348318
} else {
349319
// Fallback: manually extract hostname for URLs with variables
350320
// Remove scheme prefix
351-
withoutScheme := proxyPass
352-
if strings.HasPrefix(proxyPass, "https://") {
353-
withoutScheme = strings.TrimPrefix(proxyPass, "https://")
354-
} else if strings.HasPrefix(proxyPass, "http://") {
355-
withoutScheme = strings.TrimPrefix(proxyPass, "http://")
321+
withoutScheme := passURL
322+
if strings.HasPrefix(passURL, "https://") {
323+
withoutScheme = strings.TrimPrefix(passURL, "https://")
324+
} else if strings.HasPrefix(passURL, "http://") {
325+
withoutScheme = strings.TrimPrefix(passURL, "http://")
326+
} else if strings.HasPrefix(passURL, "grpc://") {
327+
withoutScheme = strings.TrimPrefix(passURL, "grpc://")
328+
} else if strings.HasPrefix(passURL, "grpcs://") {
329+
withoutScheme = strings.TrimPrefix(passURL, "grpcs://")
356330
}
357331

358332
// Extract hostname before any path, port, or variable
@@ -371,10 +345,10 @@ func isUpstreamReference(proxyPass string, upstreamNames map[string]bool) bool {
371345
}
372346
}
373347

374-
// For stream module, proxy_pass can directly reference upstream name without protocol
375-
// Check if the proxy_pass value directly matches an upstream name
376-
if !strings.Contains(proxyPass, "://") && !strings.Contains(proxyPass, ":") {
377-
return upstreamNames[proxyPass]
348+
// For stream module, proxy_pass/grpc_pass can directly reference upstream name without protocol
349+
// Check if the pass value directly matches an upstream name
350+
if !strings.Contains(passURL, "://") && !strings.Contains(passURL, ":") {
351+
return upstreamNames[passURL]
378352
}
379353

380354
return false

internal/upstream/proxy_parser_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,151 @@ server {
605605
}
606606
}
607607
}
608+
609+
func TestParseGrpcPassDirectives(t *testing.T) {
610+
config := `
611+
upstream grpc-backend {
612+
server 127.0.0.1:9090;
613+
server 127.0.0.1:9091;
614+
}
615+
616+
server {
617+
listen 80 http2;
618+
server_name grpc.example.com;
619+
620+
location /api.v1.Service/ {
621+
grpc_pass grpc://127.0.0.1:9090;
622+
}
623+
624+
location /api.v2.Service/ {
625+
grpc_pass grpcs://secure-grpc.example.com:443;
626+
}
627+
628+
location /upstream-service/ {
629+
grpc_pass grpc://grpc-backend;
630+
}
631+
632+
location /direct-service/ {
633+
grpc_pass 192.168.1.100:9090;
634+
}
635+
}
636+
`
637+
638+
targets := ParseProxyTargetsFromRawContent(config)
639+
640+
// Verify we found the expected targets
641+
expected := []struct {
642+
host string
643+
port string
644+
typ string
645+
}{
646+
{"127.0.0.1", "9090", "upstream"},
647+
{"127.0.0.1", "9091", "upstream"},
648+
{"127.0.0.1", "9090", "grpc_pass"},
649+
{"secure-grpc.example.com", "443", "grpc_pass"},
650+
{"192.168.1.100", "9090", "grpc_pass"},
651+
}
652+
653+
if len(targets) < len(expected) {
654+
t.Errorf("Expected at least %d targets, got %d", len(expected), len(targets))
655+
for i, target := range targets {
656+
t.Logf("Target %d: Host=%s, Port=%s, Type=%s", i+1, target.Host, target.Port, target.Type)
657+
}
658+
return
659+
}
660+
661+
// Count targets by type
662+
grpcPassCount := 0
663+
upstreamCount := 0
664+
for _, target := range targets {
665+
switch target.Type {
666+
case "grpc_pass":
667+
grpcPassCount++
668+
case "upstream":
669+
upstreamCount++
670+
}
671+
}
672+
673+
if grpcPassCount != 3 {
674+
t.Errorf("Expected 3 grpc_pass targets, got %d", grpcPassCount)
675+
}
676+
if upstreamCount != 2 {
677+
t.Errorf("Expected 2 upstream targets, got %d", upstreamCount)
678+
}
679+
680+
// Verify specific targets exist
681+
found := make(map[string]bool)
682+
for _, target := range targets {
683+
key := target.Host + ":" + target.Port + ":" + target.Type
684+
found[key] = true
685+
}
686+
687+
expectedKeys := []string{
688+
"127.0.0.1:9090:upstream",
689+
"127.0.0.1:9091:upstream",
690+
"127.0.0.1:9090:grpc_pass",
691+
"secure-grpc.example.com:443:grpc_pass",
692+
"192.168.1.100:9090:grpc_pass",
693+
}
694+
695+
for _, key := range expectedKeys {
696+
if !found[key] {
697+
t.Errorf("Expected to find target: %s", key)
698+
}
699+
}
700+
}
701+
702+
func TestGrpcPassPortDefaults(t *testing.T) {
703+
tests := []struct {
704+
name string
705+
grpcPassURL string
706+
expectedHost string
707+
expectedPort string
708+
expectedType string
709+
}{
710+
{
711+
name: "grpc:// without port should default to 80",
712+
grpcPassURL: "grpc://api.example.com",
713+
expectedHost: "api.example.com",
714+
expectedPort: "80",
715+
expectedType: "grpc_pass",
716+
},
717+
{
718+
name: "grpcs:// without port should default to 443",
719+
grpcPassURL: "grpcs://secure-api.example.com",
720+
expectedHost: "secure-api.example.com",
721+
expectedPort: "443",
722+
expectedType: "grpc_pass",
723+
},
724+
{
725+
name: "grpc:// with explicit port",
726+
grpcPassURL: "grpc://api.example.com:9090",
727+
expectedHost: "api.example.com",
728+
expectedPort: "9090",
729+
expectedType: "grpc_pass",
730+
},
731+
{
732+
name: "grpcs:// with explicit port",
733+
grpcPassURL: "grpcs://secure-api.example.com:9443",
734+
expectedHost: "secure-api.example.com",
735+
expectedPort: "9443",
736+
expectedType: "grpc_pass",
737+
},
738+
}
739+
740+
for _, tt := range tests {
741+
t.Run(tt.name, func(t *testing.T) {
742+
target := parseProxyPassURL(tt.grpcPassURL, "grpc_pass")
743+
744+
if target.Host != tt.expectedHost {
745+
t.Errorf("Expected host %s, got %s", tt.expectedHost, target.Host)
746+
}
747+
if target.Port != tt.expectedPort {
748+
t.Errorf("Expected port %s, got %s", tt.expectedPort, target.Port)
749+
}
750+
if target.Type != tt.expectedType {
751+
t.Errorf("Expected type %s, got %s", tt.expectedType, target.Type)
752+
}
753+
})
754+
}
755+
}

0 commit comments

Comments
 (0)