mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
344 lines
9.9 KiB
Go
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,
|
|
})
|
|
}
|