Files
tiny-forge/internal/proxy/validator.go
T
alexei.dolgolyov 7a85441b81 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
2026-03-30 11:19:55 +03:00

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),
}
}