feat(observability): phase 3 - direct proxy creation with validation
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
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user