netguard

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package netguard is the outbound network policy the agent dials its own connections through: a default-deny gate that decides whether a given address may be reached. It is the in-process half of egress control, the layer that stops a connection the agent itself makes from going somewhere it should not, including a server-side request forgery to a private or cloud-metadata address.

One Policy type expresses the range of needs: a sandboxed step that may reach nothing (the zero value denies everything), a download that may reach any public host but never a private or loopback one, and a run granted a specific range. The gate is enforced at the point of connect, after DNS resolution on the actual address being dialed, so a name that resolves to a denied address, including a rebinding attack, is blocked rather than trusted.

This guards connections the agent's own Go code makes. Confining a child process the agent launches (an inference runtime, say) to no network is a complementary, OS-level layer that belongs with the execution sandbox.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Client

func Client(p Policy) *http.Client

Client builds a hardened HTTP client that dials only where p allows, follows a bounded number of redirects (re-applying the policy on each hop through the dial control) and refuses a non-https redirect, and honors no environment proxy so the policy stays authoritative. A request's own scheme is the caller's check; this guards where connections may go.

func DialControl

func DialControl(p Policy) func(network, address string, c syscall.RawConn) error

DialControl returns a net.Dialer Control function that enforces p. Because Control runs after DNS resolution on the address actually being dialed, it blocks a name that resolves to a denied address (a rebinding attack included), not just a literal one.

func DialControlContext

func DialControlContext(p Policy) func(ctx context.Context, network, address string, c syscall.RawConn) error

DialControlContext is the context-aware DialControl: it enforces p identically and, when an Observer is seeded on ctx, reports the decision to it before returning. It is the hook Dialer and Client install, so a run that seeds an observer records every dial its own code makes; a dial with no observer on its context reports nothing and behaves exactly as DialControl.

func Dialer

func Dialer(p Policy) *net.Dialer

Dialer returns a net.Dialer that gates every connection through p, for raw socket protocols that are not HTTP (a line-delimited or JSON-RPC service, say). It is the raw-socket counterpart of Client: a caller reaching a local, operator-supplied service over a loopback port grants exactly that address in p, and the policy still blocks anything else, including an address a name rebinds to.

func IsPublic

func IsPublic(addr netip.Addr) bool

IsPublic reports whether addr is a globally-routable public address. It rejects loopback, private (RFC1918 and IPv6 unique-local), link-local (which covers the 169.254.169.254 cloud metadata endpoint), multicast, the unspecified address, and the IANA special-purpose ranges above, so only an address that can legitimately be reached on the public internet is allowed.

func WithObserver

func WithObserver(ctx context.Context, obs Observer) context.Context

WithObserver returns a context that reports every netguard egress decision made on a dial using it to obs. A nil observer returns ctx unchanged, so a caller can seed unconditionally.

Types

type Decision

type Decision struct {
	Host    string
	Allowed bool
	Reason  string
}

Decision is one egress verdict netguard reached at dial time: the host it was asked to reach (the literal address resolved before connecting), whether the policy allowed it, and a short reason for the verdict. It is what an Observer is handed, so a run can record its own outbound network decisions without netguard depending on the recording layer.

type Observer

type Observer func(Decision)

Observer is notified of each egress decision netguard makes on a dial. It is registered on a context (see WithObserver), so netguard reports a run's outbound verdicts without importing the recording layer, and a dial made outside any run reports to no one. It runs on the dial goroutine, so an implementation must not block.

type Policy

type Policy struct {
	// AllowPublic permits any globally-routable public address while still denying
	// private, loopback, link-local, and the cloud metadata range. This is the
	// anti-SSRF mode for reaching public sources.
	AllowPublic bool
	// Allow is an explicit allowlist of address ranges, permitted even when not
	// public (a specific host's address, or a private range a run is granted).
	Allow []netip.Prefix
	// AllowHosts, when non-empty, restricts egress to these destination host names,
	// checked on the name the client asked to reach before it is resolved. An entry is a
	// host name matched case-insensitively; an entry beginning with a dot (".openai.com")
	// also matches any subdomain of it. It composes with the address rules above rather
	// than replacing them: a destination must pass both the name gate here and the
	// resolved-address gate (AllowPublic/Allow), so an allowlisted name that resolves to a
	// private or rebinding address is still denied. It is enforced where the destination
	// name is known, the egress proxy a child process is pointed at, so it governs a
	// process whose own code we do not control: "deny all egress except these providers".
	// An empty AllowHosts imposes no name restriction (the address rules still apply).
	AllowHosts []string
}

Policy decides which outbound addresses are reachable. The zero Policy denies everything (default-deny): a connection is allowed only if its address falls in an allowed range, or it is a public address and the policy permits public addresses.

func DenyAll

func DenyAll() Policy

DenyAll is the default-deny policy: no outbound connection is permitted.

func PublicOnly

func PublicOnly() Policy

PublicOnly permits public addresses and denies everything private (anti-SSRF).

func (Policy) Allows

func (p Policy) Allows(addr netip.Addr) bool

Allows reports whether addr may be connected to under this policy.

func (Policy) AllowsHost

func (p Policy) AllowsHost(host string) bool

AllowsHost reports whether host (a destination name without a port) passes the name allowlist. An empty allowlist permits any name, so the address rules alone decide; otherwise the name must equal an entry, or be a subdomain of a dotted entry (".example.com" matches example.com and any subdomain), compared case-insensitively. A trailing dot on a fully-qualified name is ignored. It is a name gate only: a caller still applies the address rules (Allows) on the resolved address, so this never widens what an address rule denies.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy is a forward egress proxy that authorizes every upstream connection through a Policy, so a child process pointed at it (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY) gets the same default-deny egress control the agent's own dials get. It is how the one egress policy engine governs a process whose own code we do not control: the child dials the proxy, the proxy enforces the policy after DNS resolution (rebinding-safe, via the same DialControl as Client and Dialer), and a denied destination is refused before any connection is made.

It speaks the standard proxy protocol: CONNECT to tunnel TLS (and any other TCP a client opens through CONNECT), and absolute-form requests to forward plain HTTP. It holds no listener of its own; a caller serves it on a loopback listener (through bindguard), so the proxy is reachable only on the host it runs on.

func NewProxy

func NewProxy(p Policy) *Proxy

NewProxy returns a proxy that admits only what p allows. Upstream connections are dialed through a policy-enforcing dialer, so the address rules are applied at connect time on the resolved address (the same DialControl Client and Dialer use). The name allowlist (p.AllowHosts) is applied earlier, on the requested destination name, before the connection is dialed.

func (*Proxy) Handler

func (px *Proxy) Handler() http.Handler

Handler returns the proxy's HTTP handler: CONNECT requests are tunneled, everything else is treated as a forward-proxy request and relayed. Mount it on an http.Server served on a loopback listener.

func (*Proxy) Serve

func (px *Proxy) Serve(ctx context.Context, ln net.Listener) error

Serve runs the proxy on ln until ctx is cancelled, then shuts it down. ln should be a loopback listener (opened through bindguard) so the proxy is reachable only locally.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL