Skip to content

Commit 9ba3ffa

Browse files
committed
feat(nginx): Warn when a domain target service is not reachable
A domain could be mapped to a service or port where nothing listens, and the vhost was generated and reloaded with no signal, leaving a silently dead route. Proxy setup now probes each target through the same path the proxy resolves and surfaces a warning when it is unreachable, without failing the setup or warning when the target simply can't be probed.
1 parent f47a3ee commit 9ba3ffa

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

internal/nginx/manager.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,56 @@ func isNginxConfigValid(output string) bool {
325325
return !hasError && hasSuccess
326326
}
327327

328+
// ProbeTarget checks, from inside the nginx container, whether service:port
329+
// accepts a TCP connection, using the same name resolution the proxy itself
330+
// uses. The second return value is false when the check could not be performed
331+
// (no probe tool in the image, nginx container unavailable, unsupported flags),
332+
// so callers can stay silent instead of reporting a false dead route.
333+
func (m *Manager) ProbeTarget(service string, port int) (listening bool, checked bool) {
334+
if m.config.ContainerName == "" || service == "" {
335+
return false, false
336+
}
337+
338+
probe := fmt.Sprintf("nc -z -w2 %s %d", service, port)
339+
out, err := exec.Command("docker", "exec", m.config.ContainerName, "sh", "-c", probe).CombinedOutput()
340+
341+
exitCode := -1
342+
if err == nil {
343+
exitCode = 0
344+
} else if exitErr, ok := err.(*exec.ExitError); ok {
345+
exitCode = exitErr.ExitCode()
346+
}
347+
return interpretProbe(string(out), exitCode)
348+
}
349+
350+
// interpretProbe classifies a probe's output and exit code. A missing or
351+
// unsupported probe tool, or a docker/daemon-level failure (container down,
352+
// exec failed), is not evidence the target is closed, so it is reported as
353+
// unchecked rather than a dead route. Only a clean exit-1 from the probe
354+
// itself counts as "not listening".
355+
func interpretProbe(output string, exitCode int) (listening bool, checked bool) {
356+
if exitCode == 0 {
357+
return true, true
358+
}
359+
360+
lower := strings.ToLower(output)
361+
uncheckable := []string{
362+
"not found", "usage", "invalid", "unrecognized",
363+
"error response from daemon", "is not running", "no such container",
364+
"oci runtime", "exec failed",
365+
}
366+
for _, s := range uncheckable {
367+
if strings.Contains(lower, s) {
368+
return false, false
369+
}
370+
}
371+
372+
if exitCode == 1 {
373+
return false, true
374+
}
375+
return false, false
376+
}
377+
328378
func (m *Manager) waitForContainerReady(maxRetries int) error {
329379
for i := 0; i < maxRetries; i++ {
330380
cmd := exec.Command("docker", "inspect", "-f", "{{.State.Status}}", m.config.ContainerName)

internal/nginx/manager_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,45 @@ func TestGenerateMultiDomainConfig_ContainerPort(t *testing.T) {
20572057
})
20582058
}
20592059

2060+
func TestProbeTarget_UncheckedWithoutContainer(t *testing.T) {
2061+
m := NewManager(&config.NginxConfig{}, "/deployments", "")
2062+
2063+
if listening, checked := m.ProbeTarget("web", 80); checked || listening {
2064+
t.Errorf("probe must report unchecked when no nginx container is configured, got listening=%v checked=%v", listening, checked)
2065+
}
2066+
if _, checked := m.ProbeTarget("", 80); checked {
2067+
t.Error("probe must report unchecked for an empty service name")
2068+
}
2069+
}
2070+
2071+
func TestInterpretProbe(t *testing.T) {
2072+
tests := []struct {
2073+
name string
2074+
output string
2075+
exitCode int
2076+
wantListening bool
2077+
wantChecked bool
2078+
}{
2079+
{"open port", "", 0, true, true},
2080+
{"connection refused", "nc: connect failed: Connection refused", 1, false, true},
2081+
{"nc missing", "sh: nc: not found", 127, false, false},
2082+
{"container down", "Error response from daemon: Container abc is not running", 1, false, false},
2083+
{"no such container", "Error: No such container: nginx", 1, false, false},
2084+
{"exec failed", "OCI runtime exec failed: exec failed: ...", 126, false, false},
2085+
{"unknown nonzero", "something weird", 2, false, false},
2086+
}
2087+
2088+
for _, tt := range tests {
2089+
t.Run(tt.name, func(t *testing.T) {
2090+
listening, checked := interpretProbe(tt.output, tt.exitCode)
2091+
if listening != tt.wantListening || checked != tt.wantChecked {
2092+
t.Errorf("interpretProbe(%q, %d) = (%v, %v), want (%v, %v)",
2093+
tt.output, tt.exitCode, listening, checked, tt.wantListening, tt.wantChecked)
2094+
}
2095+
})
2096+
}
2097+
}
2098+
20602099
func TestCreateVirtualHost_WritesMapsConfig(t *testing.T) {
20612100
tmpDir, err := os.MkdirTemp("", "nginx-maps-*")
20622101
if err != nil {

internal/proxy/orchestrator.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ func (o *Orchestrator) setupMultiDomainDeployment(deployment *models.Deployment,
156156
result.NginxReloaded = true
157157
}
158158

159+
for _, d := range domains {
160+
port := d.ContainerPort
161+
if port == 0 {
162+
port = 80
163+
}
164+
if listening, checked := o.nginx.ProbeTarget(d.Service, port); checked && !listening {
165+
warning := fmt.Sprintf("target %s:%d for %s is not reachable; the route may be dead", d.Service, port, d.Domain)
166+
log.Printf("warning: %s", warning)
167+
result.TargetWarnings = append(result.TargetWarnings, warning)
168+
}
169+
}
170+
159171
certResults, err := o.ssl.RequestCertificatesForDomains(domains)
160172
if err != nil {
161173
log.Printf("warning: failed to request certificates: %v", err)
@@ -376,6 +388,7 @@ type SetupResult struct {
376388
CertificateResults []*ssl.CertificateResult `json:"certificate_results,omitempty"`
377389
SSLMessage string `json:"ssl_message,omitempty"`
378390
SSLError string `json:"ssl_error,omitempty"`
391+
TargetWarnings []string `json:"target_warnings,omitempty"`
379392
}
380393

381394
type ProxyStatus struct {

0 commit comments

Comments
 (0)