// Package egress is the only place in the agent that opens a socket toward SimAdmin. // // Everything else hands requests to NewClient. That is the point: the agent holds a long-lived // credential and an outbound connection to a relay it does not trust, so the one capability that // would turn a compromised request path into a LAN pivot is the ability to dial an arbitrary // address. Here it does not exist. The host component of every dial target is discarded and // replaced with a loopback literal before the resolver is consulted, which means /etc/hosts, DNS, // and any future config field labelled "host" cannot move the destination. package egress import ( "context" "errors" "fmt" "net" "net/http" "net/url" "strconv" "strings" "time" ) // Loopback is the only address the agent will ever dial. const Loopback = "127.0.0.1" // Loopback6 is the IPv6 form, for a local service that is bound to ::1 only. const Loopback6 = "[::1]" var ( // ErrNoPort is returned when no local SimAdmin port is configured. There is deliberately no // default: guessing a port would make a misconfigured node fail quietly against some other // service on the same box. ErrNoPort = errors.New("egress: no local port configured") // ErrNotLoopback is returned when an origin names anything but a loopback literal. ErrNotLoopback = errors.New("egress: origin host is not a loopback literal") // ErrHTTPS is returned for https targets. A loopback TLS certificate cannot be verified // against a hostname, and disabling verification to work around that would be worse than // requiring plain HTTP on a socket that never leaves the machine. ErrHTTPS = errors.New("egress: loopback target must be plain http") ) // Target is a validated local origin: a loopback literal plus a port, and nothing else. It has no // host field, so there is nothing for a caller to fill in incorrectly. type Target struct { Port int IPv6 bool } // Origin renders the base URL used for requests. The host is always a literal. func (t Target) Origin() string { host := Loopback if t.IPv6 { host = Loopback6 } return "http://" + host + ":" + strconv.Itoa(t.Port) } // NewTarget validates a port number. It takes no host parameter at all; that is the API, not an // omission. The result is always the IPv4 loopback literal. func NewTarget(port int) (Target, error) { if port <= 0 || port > 65535 { return Target{}, ErrNoPort } return Target{Port: port}, nil } // NewTarget6 is NewTarget for a local service bound to ::1 only. Both addresses are equally // unreachable from outside the machine, so this is a compatibility switch, not a trust boundary. func NewTarget6(port int) (Target, error) { target, err := NewTarget(port) if err != nil { return Target{}, err } target.IPv6 = true return target, nil } // Dial discards whatever address the caller believed it was using and dials the loopback literal // on the same port. Both arguments are treated as untrusted. func Dial(ctx context.Context, network, address string) (net.Conn, error) { switch network { case "tcp", "tcp4", "tcp6": default: return nil, fmt.Errorf("egress: refusing network %q", network) } _, port, err := net.SplitHostPort(address) if err != nil { return nil, fmt.Errorf("egress: malformed dial address: %w", err) } num, err := strconv.Atoi(port) if err != nil || num <= 0 || num > 65535 { return nil, errors.New("egress: invalid port in dial address") } d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} return d.DialContext(ctx, "tcp4", net.JoinHostPort(Loopback, strconv.Itoa(num))) } // NewClient returns an HTTP client that cannot leave the machine. Redirects are not followed: a // local service answering with a Location header pointing elsewhere would otherwise be an exit the // policy never listed. func NewClient(target Target, timeout time.Duration) (*http.Client, error) { if target.Port <= 0 { return nil, ErrNoPort } transport := &http.Transport{ DialContext: Dial, MaxIdleConns: 8, MaxConnsPerHost: 16, IdleConnTimeout: 60 * time.Second, // No ProxyFromEnvironment on purpose. Honoring HTTP_PROXY would let the node's shell // environment decide where "loopback" traffic really goes. ForceAttemptHTTP2: false, } return &http.Client{ Transport: transport, Timeout: timeout, // Stop at the 3xx and hand it back rather than returning an error from here: the caller // classifies "the local service answered with a redirect" as an upstream problem, and the // off-loopback address the Location names is still never dialed. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, nil } // AssertLoopback checks an origin string that came from somewhere outside this package, so a // config path that grows a host field later fails here rather than at a dial. It is deliberately // stricter than the dialer: a base origin carrying a path, query, or userinfo is a mistake, and // silently ignoring those parts is how a client ends up talking to the wrong endpoint. func AssertLoopback(origin string) error { trimmed := strings.TrimSpace(origin) parsed, err := url.Parse(trimmed) if err != nil { return fmt.Errorf("egress: cannot parse origin %q: %w", trimmed, err) } if parsed.Scheme != "http" { return fmt.Errorf("%w: %q", ErrHTTPS, trimmed) } if parsed.Path != "" && parsed.Path != "/" { return fmt.Errorf("%w: %q has a path; the base origin must have none", ErrNotLoopback, parsed.Path) } if parsed.RawQuery != "" || parsed.Fragment != "" { return fmt.Errorf("%w: %q carries a query or fragment", ErrNotLoopback, trimmed) } if parsed.User != nil { return fmt.Errorf("%w: %q carries userinfo", ErrNotLoopback, trimmed) } host := parsed.Hostname() port := parsed.Port() if port == "" { return ErrNoPort } ip := net.ParseIP(host) if ip == nil { return fmt.Errorf("%w: %q is a name, and names resolve", ErrNotLoopback, host) } if !ip.IsLoopback() { return fmt.Errorf("%w: %q", ErrNotLoopback, host) } return nil }