7a85441b81
Add standalone proxy management: - Multi-step validation pipeline (DNS, TCP, HTTP) with diagnostic hints - Proxy lifecycle: create/update/delete via NPM API with SSL auto-assign - Periodic health monitoring (5min) with event log on status transitions - Unified /api/proxies/all endpoint merging standalone + managed proxies - Frontend types and API functions for downstream UI phases
225 lines
6.0 KiB
Go
225 lines
6.0 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// Validation step names.
|
|
const (
|
|
StepSyntax = "syntax"
|
|
StepDNS = "dns"
|
|
StepTCP = "tcp"
|
|
StepHTTP = "http"
|
|
)
|
|
|
|
// ValidationStep holds the result of a single validation check.
|
|
type ValidationStep struct {
|
|
Name string `json:"name"`
|
|
Passed bool `json:"passed"`
|
|
Message string `json:"message,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
// ValidationResult holds the aggregate result of the validation pipeline.
|
|
type ValidationResult struct {
|
|
Valid bool `json:"valid"`
|
|
Steps []ValidationStep `json:"steps"`
|
|
}
|
|
|
|
// ValidateDestination runs the multi-step validation pipeline against the given
|
|
// destination host and port. It checks syntax, DNS, TCP reachability, and HTTP health.
|
|
// The pipeline short-circuits on failure: later steps are skipped if an earlier one fails.
|
|
func ValidateDestination(ctx context.Context, host string, port int) ValidationResult {
|
|
result := ValidationResult{Valid: true}
|
|
|
|
// Step 1: Syntax validation.
|
|
if step, ok := validateSyntax(host, port); !ok {
|
|
result.Valid = false
|
|
result.Steps = append(result.Steps, step)
|
|
return result
|
|
} else {
|
|
result.Steps = append(result.Steps, step)
|
|
}
|
|
|
|
// Step 2: DNS resolution (skip for IP addresses).
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
if step, ok := validateDNS(ctx, host); !ok {
|
|
result.Valid = false
|
|
result.Steps = append(result.Steps, step)
|
|
return result
|
|
} else {
|
|
result.Steps = append(result.Steps, step)
|
|
}
|
|
} else {
|
|
result.Steps = append(result.Steps, ValidationStep{
|
|
Name: StepDNS,
|
|
Passed: true,
|
|
Message: "Skipped (IP address provided)",
|
|
})
|
|
}
|
|
|
|
// Step 3: TCP port reachability.
|
|
if step, ok := validateTCP(ctx, host, port); !ok {
|
|
result.Valid = false
|
|
result.Steps = append(result.Steps, step)
|
|
return result
|
|
} else {
|
|
result.Steps = append(result.Steps, step)
|
|
}
|
|
|
|
// Step 4: HTTP health probe.
|
|
step := validateHTTP(ctx, host, port)
|
|
result.Steps = append(result.Steps, step)
|
|
if !step.Passed {
|
|
result.Valid = false
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// validateSyntax checks that the host and port values are syntactically valid.
|
|
func validateSyntax(host string, port int) (ValidationStep, bool) {
|
|
if host == "" {
|
|
return ValidationStep{
|
|
Name: StepSyntax,
|
|
Passed: false,
|
|
Message: "Host is empty",
|
|
Hint: "Provide a valid hostname or IP address.",
|
|
}, false
|
|
}
|
|
|
|
if port < 1 || port > 65535 {
|
|
return ValidationStep{
|
|
Name: StepSyntax,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("Port %d is out of range (1-65535)", port),
|
|
Hint: "Provide a valid port number between 1 and 65535.",
|
|
}, false
|
|
}
|
|
|
|
// Reject obviously invalid hostnames (but allow IPs).
|
|
if net.ParseIP(host) == nil {
|
|
// Basic hostname validation: must not contain spaces or schemes.
|
|
if _, err := url.Parse("http://" + host); err != nil {
|
|
return ValidationStep{
|
|
Name: StepSyntax,
|
|
Passed: false,
|
|
Message: "Invalid hostname: " + err.Error(),
|
|
Hint: "Provide a valid hostname without scheme (e.g., 'example.com' not 'http://example.com').",
|
|
}, false
|
|
}
|
|
}
|
|
|
|
return ValidationStep{
|
|
Name: StepSyntax,
|
|
Passed: true,
|
|
Message: fmt.Sprintf("Host %q port %d syntax OK", host, port),
|
|
}, true
|
|
}
|
|
|
|
// validateDNS performs a DNS lookup on the given host.
|
|
func validateDNS(ctx context.Context, host string) (ValidationStep, bool) {
|
|
resolver := net.DefaultResolver
|
|
addrs, err := resolver.LookupHost(ctx, host)
|
|
if err != nil {
|
|
return ValidationStep{
|
|
Name: StepDNS,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("DNS resolution failed for %q: %s", host, err.Error()),
|
|
Hint: diagnosticHint(StepDNS, err),
|
|
}, false
|
|
}
|
|
|
|
return ValidationStep{
|
|
Name: StepDNS,
|
|
Passed: true,
|
|
Message: fmt.Sprintf("Resolved to %v", addrs),
|
|
}, true
|
|
}
|
|
|
|
// validateTCP attempts a TCP connection to host:port with a 5-second timeout.
|
|
func validateTCP(ctx context.Context, host string, port int) (ValidationStep, bool) {
|
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
|
|
|
dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
var d net.Dialer
|
|
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
|
if err != nil {
|
|
return ValidationStep{
|
|
Name: StepTCP,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("TCP connect to %s failed: %s", addr, err.Error()),
|
|
Hint: diagnosticHint(StepTCP, err),
|
|
}, false
|
|
}
|
|
conn.Close()
|
|
|
|
return ValidationStep{
|
|
Name: StepTCP,
|
|
Passed: true,
|
|
Message: fmt.Sprintf("TCP connect to %s succeeded", addr),
|
|
}, true
|
|
}
|
|
|
|
// validateHTTP performs a GET request to the destination and checks for a response.
|
|
// Non-5xx responses are considered passing (the service is responding).
|
|
func validateHTTP(ctx context.Context, host string, port int) ValidationStep {
|
|
target := fmt.Sprintf("http://%s:%d/", host, port)
|
|
|
|
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return ValidationStep{
|
|
Name: StepHTTP,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("Failed to build HTTP request: %s", err.Error()),
|
|
Hint: diagnosticHint(StepHTTP, err),
|
|
}
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
// Do not follow redirects — we just want to see if the port responds to HTTP.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return ValidationStep{
|
|
Name: StepHTTP,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("HTTP probe to %s failed: %s", target, err.Error()),
|
|
Hint: diagnosticHint(StepHTTP, err),
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return ValidationStep{
|
|
Name: StepHTTP,
|
|
Passed: false,
|
|
Message: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
|
Hint: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
|
}
|
|
}
|
|
|
|
return ValidationStep{
|
|
Name: StepHTTP,
|
|
Passed: true,
|
|
Message: fmt.Sprintf("HTTP probe returned %d", resp.StatusCode),
|
|
}
|
|
}
|