mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
In a gated scope, the two HTTP clients the platform owns connect only to addresses that a pin or a stored exemption covers at the moment of the connection, on every hop. - intercept.PinnedDialer(tool, scoped): the DialContext of both clients. It reads the target scope from the context net/http passes to the dial. Gate off, or no scope in a client built without one: plain dial, as today. A client built for a scope whose scope is missing at the dial fails closed with TARGET_GATE_UNAVAILABLE. - dialTarget runs the gate decision on the dialled host:port with a fresh lookup (block rules first, exemptions, pins, port precedence) and connects only to the addresses it compared, never to the name. Derived exemptions and name-only pins dial the address as received. An address no pin covers finds or opens the request (new_ip / no_pin, wait 0) and fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled against the hop's own (host, port). - WebFetch (withWebFetch, now given the run context): DialContext set on every agent kind; DisableDirectRetry when the run's scope is gated. - Custom http tool: a direct call in a gated scope gets a DefaultTransport clone with the pinned dialer, no environment proxy and keep-alive off. Proxied calls are unchanged (the recording proxy is step 2.2). - Name-only: request reason says the platform does not bind the connection; the first-use pin_match row records the effective state. - Derived exemptions: a proxy URL without a port is listed with the port net/http dials (1080 for socks5) (OPEN-9). - go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
185 lines
5.2 KiB
Go
185 lines
5.2 KiB
Go
package intercept
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Resolver turns a host name into its addresses. The gate compares what it
|
|
// returns with the addresses a human pinned.
|
|
type Resolver interface {
|
|
// Name identifies the resolver in pins and in audit rows.
|
|
Name() string
|
|
// LookupIPs returns the A and AAAA answers for host.
|
|
LookupIPs(ctx context.Context, host string) ([]netip.Addr, error)
|
|
}
|
|
|
|
// SetResolver replaces the resolver of the target gate, which is the system
|
|
// resolver unless one is set.
|
|
func (i *Interceptor) SetResolver(r Resolver) { i.resolve.use(r) }
|
|
|
|
// SetBaseDialer replaces the dialer that opens the connections PinnedDialer
|
|
// lets through, which is a net.Dialer with the settings of
|
|
// http.DefaultTransport unless one is set. Tests use it to map addresses to
|
|
// local listeners and to see what was dialled.
|
|
func (i *Interceptor) SetBaseDialer(f func(ctx context.Context, network, addr string) (net.Conn, error)) {
|
|
i.mu.Lock()
|
|
i.dial = f
|
|
i.mu.Unlock()
|
|
}
|
|
|
|
// systemResolver is the resolver of the ARTEX process. It reads the same
|
|
// resolver configuration as the tools that run in the same container.
|
|
type systemResolver struct{}
|
|
|
|
func (systemResolver) Name() string { return "system" }
|
|
|
|
func (systemResolver) LookupIPs(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
|
}
|
|
|
|
const (
|
|
// resolveTimeout bounds one lookup.
|
|
resolveTimeout = 3 * time.Second
|
|
// resolveCacheTTL is how long a positive answer is reused. It bounds latency
|
|
// and resolver load when several calls name one host, and adds at most this
|
|
// much to the time a changed address goes unnoticed.
|
|
resolveCacheTTL = 10 * time.Second
|
|
)
|
|
|
|
// resolution is one positive answer: canonical addresses, sorted, never empty.
|
|
type resolution struct {
|
|
ips []string
|
|
at time.Time
|
|
}
|
|
|
|
type resolveFlight struct {
|
|
done chan struct{}
|
|
res resolution
|
|
err error
|
|
}
|
|
|
|
// resolveCache resolves names through one Resolver. Positive answers are kept
|
|
// per host for resolveCacheTTL, concurrent lookups for one host collapse into
|
|
// one, and a failure or an empty answer is never kept: the next call tries
|
|
// again.
|
|
type resolveCache struct {
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
resolver Resolver
|
|
entries map[string]resolution
|
|
flights map[string]*resolveFlight
|
|
}
|
|
|
|
func newResolveCache(r Resolver) *resolveCache {
|
|
return &resolveCache{resolver: r, now: time.Now, entries: map[string]resolution{}, flights: map[string]*resolveFlight{}}
|
|
}
|
|
|
|
// use replaces the resolver and drops the answers of the previous one.
|
|
func (c *resolveCache) use(r Resolver) {
|
|
c.mu.Lock()
|
|
c.resolver, c.entries = r, map[string]resolution{}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *resolveCache) name() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.resolver.Name()
|
|
}
|
|
|
|
// lookup resolves host. fresh skips the cached answer: it is what a new
|
|
// approval request uses, because a human reads and pins those addresses.
|
|
func (c *resolveCache) lookup(ctx context.Context, host string, fresh bool) (resolution, error) {
|
|
c.mu.Lock()
|
|
if e, ok := c.entries[host]; ok && !fresh && c.now().Sub(e.at) < resolveCacheTTL {
|
|
c.mu.Unlock()
|
|
return e, nil
|
|
}
|
|
f := c.flights[host]
|
|
if f == nil {
|
|
f = &resolveFlight{done: make(chan struct{})}
|
|
c.flights[host] = f
|
|
go c.fly(c.resolver, host, f)
|
|
}
|
|
c.mu.Unlock()
|
|
select {
|
|
case <-f.done:
|
|
return f.res, f.err
|
|
case <-ctx.Done():
|
|
return resolution{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// fly runs the one lookup the waiters of host share. It is not bound to the
|
|
// context of the call that started it: that call leaving must not fail the others.
|
|
func (c *resolveCache) fly(r Resolver, host string, f *resolveFlight) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
|
|
defer cancel()
|
|
addrs, err := r.LookupIPs(ctx, host)
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
f.err = fmt.Errorf("lookup timed out after %s", resolveTimeout)
|
|
case err != nil:
|
|
f.err = lookupError(err)
|
|
case len(addrs) == 0:
|
|
f.err = errors.New("the lookup returned no address")
|
|
default:
|
|
f.res = resolution{ips: canonicalIPs(addrs), at: c.now()}
|
|
}
|
|
c.mu.Lock()
|
|
delete(c.flights, host)
|
|
if f.err == nil {
|
|
c.entries[host] = f.res
|
|
}
|
|
c.mu.Unlock()
|
|
close(f.done)
|
|
}
|
|
|
|
// lookupError keeps the part of a resolver error that says what went wrong.
|
|
func lookupError(err error) error {
|
|
var dns *net.DNSError
|
|
if errors.As(err, &dns) {
|
|
if dns.IsTimeout {
|
|
return fmt.Errorf("lookup timed out after %s", resolveTimeout)
|
|
}
|
|
if dns.IsNotFound {
|
|
return errors.New("no such host")
|
|
}
|
|
return errors.New(dns.Err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// canonicalIPs returns the addresses as sorted, unique strings, IPv4-mapped
|
|
// addresses unmapped and zones dropped.
|
|
func canonicalIPs(addrs []netip.Addr) []string {
|
|
out := make([]string, 0, len(addrs))
|
|
for _, a := range addrs {
|
|
out = append(out, a.Unmap().WithZone("").String())
|
|
}
|
|
slices.Sort(out)
|
|
return slices.Compact(out)
|
|
}
|
|
|
|
// subset reports whether every resolved address is one of the pinned ones. An
|
|
// empty answer is never a subset: a lookup that returned nothing matches no pin.
|
|
func subset(resolved, pinned []string) bool {
|
|
if len(resolved) == 0 {
|
|
return false
|
|
}
|
|
for _, ip := range resolved {
|
|
if !slices.Contains(pinned, ip) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|