Pulse/internal/api/connections_probe.go
2026-07-09 19:46:40 +01:00

344 lines
9.9 KiB
Go

package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
)
// Probe budget. Per-candidate and whole-request ceilings are kept tight to
// make the endpoint unusable as a slow-leak scanner. Each candidate gets
// 2s connect + 1s read, and the whole request is capped at the total budget.
const (
probeDialTimeout = 2 * time.Second
probeTotalBudget = 3 * time.Second
probeMaxConcurrent = 5
probeMaxAddressBytes = 512
)
// ProbeRequest is the POST body for /api/connections/probe.
type ProbeRequest struct {
Address string `json:"address"`
}
// ProbeCandidate is one detected product at a host:port.
type ProbeCandidate struct {
Type ConnectionType `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Hints map[string]string `json:"hints,omitempty"`
}
// ProbeResponse is the envelope returned to the frontend.
type ProbeResponse struct {
Candidates []ProbeCandidate `json:"candidates"`
ProbedMs int64 `json:"probedMs"`
}
// probeTarget is a single {type, scheme, port, path} fingerprint we attempt.
type probeTarget struct {
Type ConnectionType
Scheme string
Port int
Path string
identifyFn func(resp *http.Response, body []byte) (match bool, hints map[string]string)
}
// PVE, PBS, and PMG all delay auth-required 401 responses by ~3 seconds as a
// timing-attack mitigation. /api2/json/version requires auth, so probing it
// costs the full probe budget on every attempt. The web-UI root at / returns
// the login page in <100ms and carries the same identifying Server banner
// (PVE/PMG) or an unambiguous HTML title (PBS, which omits Server on /).
var defaultProbeTargets = []probeTarget{
{
Type: ConnectionTypePVE,
Scheme: "https",
Port: 8006,
Path: "/",
identifyFn: func(resp *http.Response, body []byte) (bool, map[string]string) {
server := strings.ToLower(resp.Header.Get("Server"))
if strings.Contains(server, "pve-api-daemon") {
return true, map[string]string{"product": "Proxmox VE"}
}
if strings.Contains(string(body), "Proxmox Virtual Environment") {
return true, map[string]string{"product": "Proxmox VE"}
}
return false, nil
},
},
{
Type: ConnectionTypePBS,
Scheme: "https",
Port: 8007,
Path: "/",
identifyFn: func(resp *http.Response, body []byte) (bool, map[string]string) {
server := strings.ToLower(resp.Header.Get("Server"))
if strings.Contains(server, "proxmox-backup") {
return true, map[string]string{"product": "Proxmox Backup Server"}
}
if strings.Contains(string(body), "Proxmox Backup Server") {
return true, map[string]string{"product": "Proxmox Backup Server"}
}
return false, nil
},
},
{
Type: ConnectionTypePMG,
Scheme: "https",
Port: 8006,
Path: "/",
identifyFn: func(resp *http.Response, body []byte) (bool, map[string]string) {
server := strings.ToLower(resp.Header.Get("Server"))
if strings.Contains(server, "pmg-api-daemon") {
return true, map[string]string{"product": "Proxmox Mail Gateway"}
}
if strings.Contains(string(body), "Proxmox Mail Gateway") {
return true, map[string]string{"product": "Proxmox Mail Gateway"}
}
return false, nil
},
},
{
Type: ConnectionTypeVMware,
Scheme: "https",
Port: 443,
Path: "/sdk/vimServiceVersions.xml",
identifyFn: func(_ *http.Response, body []byte) (bool, map[string]string) {
if strings.Contains(string(body), "urn:vim25") {
return true, map[string]string{"product": "VMware vCenter"}
}
return false, nil
},
},
{
Type: ConnectionTypeTrueNAS,
Scheme: "https",
Port: 443,
Path: "/api/v2.0/system/product_name",
identifyFn: func(_ *http.Response, body []byte) (bool, map[string]string) {
upper := strings.ToUpper(string(body))
if strings.Contains(upper, "TRUENAS") {
return true, map[string]string{"product": "TrueNAS"}
}
return false, nil
},
},
}
// parseProbeAddress normalizes user input into (host, explicitPort).
// Accepted forms: "host", "host:port", "ip", "ip:port", "scheme://host[:port]".
// explicitPort == 0 means "probe all defaults"; otherwise only probe targets
// that match the given port.
func parseProbeAddress(raw string) (host string, explicitPort int, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", 0, fmt.Errorf("address is required")
}
if len(raw) > probeMaxAddressBytes {
return "", 0, fmt.Errorf("address is too long")
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "", 0, fmt.Errorf("invalid URL: %s", raw)
}
host = u.Hostname()
if portStr := u.Port(); portStr != "" {
p, err := strconv.Atoi(portStr)
if err != nil || p < 1 || p > 65535 {
return "", 0, fmt.Errorf("invalid port in URL")
}
explicitPort = p
}
return host, explicitPort, nil
}
if h, p, splitErr := net.SplitHostPort(raw); splitErr == nil {
portNum, convErr := strconv.Atoi(p)
if convErr != nil || portNum < 1 || portNum > 65535 {
return "", 0, fmt.Errorf("invalid port")
}
return h, portNum, nil
}
return raw, 0, nil
}
// targetsForPort narrows defaultProbeTargets to only those matching the
// user's explicit port. Same host can serve PVE on 8006 and PBS on 8007, so
// zero port means "try them all."
func targetsForPort(port int) []probeTarget {
if port == 0 {
return defaultProbeTargets
}
out := make([]probeTarget, 0, 2)
for _, t := range defaultProbeTargets {
if t.Port == port {
out = append(out, t)
}
}
return out
}
// probeHTTPClient builds one client per probe so that dial timeouts,
// TLS-skip, and cancellation are self-contained. InsecureSkipVerify is on
// because the whole point of probing is to talk to a server whose cert we
// haven't trusted yet.
func probeHTTPClient() *http.Client {
return securityutil.NewRestrictedOutboundHTTPClient(probeDialTimeout+time.Second, securityutil.RestrictedOutboundHTTPOptions{
AllowedSchemes: []string{"https"},
AllowPrivateIPs: true,
AllowLoopback: true,
TLSConfig: tlsutil.UnverifiedPeerCertificateCaptureTLSConfig(),
})
}
func validateProbeHost(ctx context.Context, host string) error {
host = strings.TrimSpace(host)
if host == "" {
return fmt.Errorf("address is required")
}
urlHost := host
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
urlHost = "[" + host + "]"
}
_, err := securityutil.ValidateOutboundFetchURL(ctx, "https://"+urlHost+"/", securityutil.RestrictedOutboundHTTPOptions{
AllowedSchemes: []string{"https"},
AllowPrivateIPs: true,
AllowLoopback: true,
})
if err != nil {
return fmt.Errorf("address is not allowed: %w", err)
}
return nil
}
// runProbe fans out probe requests against every candidate target. It
// returns (sorted-deduped candidates, total elapsed). The function never
// returns an error — individual probe failures are swallowed as "not that
// type" rather than bubbling up and confusing the caller.
func runProbe(ctx context.Context, host string, port int, client *http.Client) ([]ProbeCandidate, time.Duration) {
start := time.Now()
budget := probeTotalBudget
ctx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
targets := targetsForPort(port)
if len(targets) == 0 {
return []ProbeCandidate{}, time.Since(start)
}
sem := make(chan struct{}, probeMaxConcurrent)
var wg sync.WaitGroup
var mu sync.Mutex
results := make([]ProbeCandidate, 0, len(targets))
for _, target := range targets {
target := target
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
if cand, ok := probeOne(ctx, host, target, client); ok {
mu.Lock()
results = append(results, cand)
mu.Unlock()
}
}()
}
wg.Wait()
sort.Slice(results, func(i, j int) bool {
if results[i].Type != results[j].Type {
return results[i].Type < results[j].Type
}
return results[i].Port < results[j].Port
})
return results, time.Since(start)
}
// probeOne runs a single probe. It returns (candidate, true) only when the
// target's identifyFn confirms the product.
func probeOne(ctx context.Context, host string, target probeTarget, client *http.Client) (ProbeCandidate, bool) {
endpoint, err := validatedProbeEndpoint(ctx, host, target)
if err != nil {
return ProbeCandidate{}, false
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return ProbeCandidate{}, false
}
req.Header.Set("User-Agent", "Pulse/connections-probe")
req.Header.Set("Accept", "application/json,text/xml,*/*")
resp, err := client.Do(req)
if err != nil {
return ProbeCandidate{}, false
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return ProbeCandidate{}, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return ProbeCandidate{}, false
}
match, hints := target.identifyFn(resp, body)
if !match {
return ProbeCandidate{}, false
}
if hints == nil {
hints = map[string]string{}
}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
hints["fingerprint"] = "SHA256:" + hex.EncodeToString(sum[:])
}
return ProbeCandidate{
Type: target.Type,
Host: fmt.Sprintf("%s://%s", target.Scheme, endpoint.Host),
Port: target.Port,
Hints: hints,
}, true
}
func validatedProbeEndpoint(ctx context.Context, host string, target probeTarget) (*url.URL, error) {
endpoint := &url.URL{
Scheme: target.Scheme,
Host: net.JoinHostPort(host, strconv.Itoa(target.Port)),
Path: target.Path,
}
return securityutil.ValidateOutboundFetchURL(ctx, endpoint.String(), securityutil.RestrictedOutboundHTTPOptions{
AllowedSchemes: []string{"https"},
AllowPrivateIPs: true,
AllowLoopback: true,
})
}