A 200 with a bad Tavily body is tavily: error, not an empty success that would cache for the hour. Upstream clients stay in the source list if DNS is down at boot and retry the pin on Search; /healthz lists them as unresolved until a pin lands.
252 lines
5.5 KiB
Go
252 lines
5.5 KiB
Go
// Package dial builds an HTTP client pinned to one config URL.
|
|
// The agent cannot name the target; only the operator-configured host:port is reachable.
|
|
package dial
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/sift/internal/guard"
|
|
)
|
|
|
|
const DefaultRefresh = 10 * time.Minute
|
|
|
|
// Lookup resolves a hostname. Tests inject a fake.
|
|
type Lookup func(ctx context.Context, host string) ([]netip.Addr, error)
|
|
|
|
type Options struct {
|
|
// Loopback permits loopback (and only loopback) addresses. Public sources
|
|
// must leave this false: every record must pass guard.CheckAddr.
|
|
Loopback bool
|
|
Lookup Lookup
|
|
Refresh time.Duration
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Client is an http.Client that may only connect to the config URL's host:port.
|
|
type Client struct {
|
|
URL *url.URL
|
|
HTTP *http.Client
|
|
Port string
|
|
|
|
loopback bool
|
|
lookup Lookup
|
|
refresh time.Duration
|
|
|
|
mu sync.Mutex
|
|
ips []netip.Addr
|
|
resolved time.Time
|
|
}
|
|
|
|
func New(raw string, opt Options) (*Client, error) {
|
|
u, err := parseConfigURL(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
port := u.Port()
|
|
if port == "" {
|
|
if u.Scheme == "https" {
|
|
port = "443"
|
|
} else {
|
|
port = "80"
|
|
}
|
|
}
|
|
if opt.Refresh <= 0 {
|
|
opt.Refresh = DefaultRefresh
|
|
}
|
|
if opt.Timeout <= 0 {
|
|
opt.Timeout = 8 * time.Second
|
|
}
|
|
lookup := opt.Lookup
|
|
if lookup == nil {
|
|
lookup = defaultLookup
|
|
}
|
|
c := &Client{
|
|
URL: u,
|
|
Port: port,
|
|
loopback: opt.Loopback,
|
|
lookup: lookup,
|
|
refresh: opt.Refresh,
|
|
}
|
|
_ = c.resolve(context.Background()) // DNS down: keep the client, retry in Search
|
|
d := &net.Dialer{
|
|
Timeout: opt.Timeout,
|
|
Control: c.control,
|
|
}
|
|
tr := &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: d.DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: opt.Timeout,
|
|
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, // system roots; InsecureSkipVerify stays false
|
|
DisableCompression: false,
|
|
}
|
|
c.HTTP = &http.Client{
|
|
Timeout: opt.Timeout + time.Second,
|
|
Transport: tr,
|
|
CheckRedirect: c.checkRedirect,
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func parseConfigURL(raw string) (*url.URL, error) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return nil, fmt.Errorf("search upstream: scheme %q", u.Scheme)
|
|
}
|
|
if u.User != nil {
|
|
return nil, fmt.Errorf("search upstream: userinfo not allowed")
|
|
}
|
|
if u.Hostname() == "" {
|
|
return nil, fmt.Errorf("search upstream: empty host")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func defaultLookup(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
if ip, err := netip.ParseAddr(host); err == nil {
|
|
return []netip.Addr{ip}, nil
|
|
}
|
|
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
}
|
|
|
|
func (c *Client) resolve(ctx context.Context) error {
|
|
addrs, err := c.lookup(ctx, c.URL.Hostname())
|
|
if err != nil {
|
|
return fmt.Errorf("search resolve: %w", err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return fmt.Errorf("search resolve: no addresses")
|
|
}
|
|
for i, a := range addrs {
|
|
addrs[i] = a.Unmap()
|
|
if err := c.checkAddr(addrs[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.ips = addrs
|
|
c.resolved = time.Now()
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) checkAddr(a netip.Addr) error {
|
|
if c.loopback {
|
|
if !a.IsLoopback() {
|
|
return fmt.Errorf("search loopback source got non-loopback %s", a)
|
|
}
|
|
return nil
|
|
}
|
|
if err := guard.CheckAddr(a); err != nil {
|
|
return fmt.Errorf("search public source: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) allow(ip netip.Addr, port string) bool {
|
|
if port != c.Port {
|
|
return false
|
|
}
|
|
ip = ip.Unmap()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, a := range c.ips {
|
|
if a == ip {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Client) control(network, address string, _ syscall.RawConn) error {
|
|
switch network {
|
|
case "tcp", "tcp4", "tcp6":
|
|
default:
|
|
return fmt.Errorf("search dial: network %s", network)
|
|
}
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return fmt.Errorf("search dial: %w", err)
|
|
}
|
|
ip, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return fmt.Errorf("search dial: not an IP")
|
|
}
|
|
if !c.allow(ip, port) {
|
|
return fmt.Errorf("search dial refused")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error {
|
|
if len(via) > 1 {
|
|
return fmt.Errorf("search redirect: extra hop")
|
|
}
|
|
if len(via) == 1 && !strings.EqualFold(req.URL.Hostname(), via[0].URL.Hostname()) {
|
|
return fmt.Errorf("search redirect: different host")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RefreshIfStale re-resolves when unpinned or the pin is older than Refresh.
|
|
// A failed refresh leaves existing pins in place.
|
|
func (c *Client) RefreshIfStale(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
need := len(c.ips) == 0 || time.Since(c.resolved) >= c.refresh
|
|
c.mu.Unlock()
|
|
if !need {
|
|
return nil
|
|
}
|
|
return c.resolve(ctx)
|
|
}
|
|
|
|
// Ensure resolves if needed. Error only when still unpinned afterwards.
|
|
func (c *Client) Ensure(ctx context.Context) error {
|
|
if c == nil {
|
|
return fmt.Errorf("search dial: nil")
|
|
}
|
|
if err := c.RefreshIfStale(ctx); err != nil && !c.Ready() {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) Ready() bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.ips) > 0
|
|
}
|
|
|
|
func (c *Client) IPs() []netip.Addr {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make([]netip.Addr, len(c.ips))
|
|
copy(out, c.ips)
|
|
return out
|
|
}
|
|
|
|
func (c *Client) TLSConfig() *tls.Config {
|
|
tr, _ := c.HTTP.Transport.(*http.Transport)
|
|
if tr == nil {
|
|
return nil
|
|
}
|
|
return tr.TLSClientConfig
|
|
}
|