netfilter

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrUnknownDecision = errors.New("unknown or already-resolved decision")

ErrUnknownDecision reports that a gate decision referenced a hold that is no longer outstanding — already resolved, expired, or never existed.

Functions

func SplitEntries

func SplitEntries(entries []string) (ips, cidrs []string)

SplitEntries partitions a list of allowed-IP entries into plain addresses and CIDR prefixes so NewAllowList can validate them distinctly.

Types

type Action

type Action int

Action is the verdict on a held connection.

const (
	// ActionDeny refuses the connection (RST to the guest), as the
	// non-interactive ACL would.
	ActionDeny Action = iota
	// ActionAllow lets the held connection through.
	ActionAllow
)

func (Action) String

func (a Action) String() string

String renders the wire form of an Action.

type AllowList

type AllowList struct {

	// OnDenial, if non-nil, is called (without the lock held) the first
	// time a destination host shows up in the denial ledger. Set it before
	// Start; it must not block.
	OnDenial func(Denial)
	// contains filtered or unexported fields
}

AllowList is a thread-safe network filter consulted by gvproxy's TCP forwarder for every outbound SYN. Policy is a Chain of origin-labeled blocks (see blocks.go); on top of the chain sit three runtime sets, kept separately so a live policy change (SetChain/SetPolicy) can revoke exactly the entries that no longer have a justification:

  • resolved: IPs from periodic DNS lookup of the chain's allow patterns, mapped to the name that justified them so a policy deny can veto them
  • observed: IPs the guest itself resolved (via gvproxy's DNS) for a name the chain allows — this is how wildcards work in practice
  • granted: IPs allowed at runtime by an interactive gate decision

The resolved set is rebuilt periodically so DNS-rotated services (CDNs, load balancers) don't break.

The AllowList also keeps two observability structures: a bounded map of every DNS answer the guest received (lastName), used to attribute a denied SYN to the hostname the guest was actually dialing, and a bounded ledger of those denials, queryable via Denials.

func NewAllowList

func NewAllowList(ips, cidrs, domains []string) (*AllowList, error)

NewAllowList builds an allow list from static entries (IPs/CIDRs) and a list of domains to resolve — the legacy flat-list form, kept as a shim over a single custom block. The caller should invoke Start() to begin periodic refresh and Stop() to release the goroutine.

func NewAllowListFromChain

func NewAllowListFromChain(c *Chain) *AllowList

NewAllowListFromChain builds an allow list evaluating the given policy chain. A nil chain means an empty policy: everything falls through to the gate or the fail-closed refusal.

func (*AllowList) Allow

func (al *AllowList) Allow(addr string) error

Allow reports whether addr (in "host:port" form) is allowed. Returns a descriptive error when rejected to aid debugging; every rejection is also recorded in the denial ledger, attributed to the DNS name the guest last resolved to that IP when one is known.

Evaluation order encodes the guardrail semantics, strongest first: the chain's name verdict (one precedence walk over both rule spaces — DecideAddr — so cross-space block precedence holds), then explicit interactive grants, then the chain's IP verdict, then the automatic DNS-derived justifications; only a destination nothing has an opinion on reaches the interactive gate. A named deny can never be overridden; an IP deny can be overridden by a human grant but never by automation.

func (*AllowList) AllowAllFor

func (al *AllowList) AllowAllFor(d time.Duration)

AllowAllFor opens a time-boxed bypass: every destination is allowed until now+d. A non-positive d clears the bypass.

func (*AllowList) AllowAllUntil

func (al *AllowList) AllowAllUntil() time.Time

AllowAllUntil returns the instant the time-boxed bypass expires, or the zero time when no bypass is active or it has elapsed.

func (*AllowList) AllowICMP

func (al *AllowList) AllowICMP(addr string) error

AllowICMP satisfies the machine.Filter interface. Forwards to Allow so gvproxy's ICMP filter hook gates ping against the same allow-list (addr is a bare destination IP, which Allow accepts).

func (*AllowList) AllowTCP

func (al *AllowList) AllowTCP(addr string) error

AllowTCP satisfies the machine.Filter interface. Forwards to Allow so gvproxy's TCP filter hook finds a method with the expected shape.

func (*AllowList) AllowUDP

func (al *AllowList) AllowUDP(addr string) error

AllowUDP satisfies the machine.Filter interface. Forwards to Allow so gvproxy's UDP filter hook gates UDP against the same allow-list as TCP (DNS-observed IPs auto-allow, so QUIC to allowed hosts is permitted).

func (*AllowList) Denials

func (al *AllowList) Denials() []Denial

Denials returns a snapshot of the denial ledger, most recent first.

func (*AllowList) DeniedDomains

func (al *AllowList) DeniedDomains() []string

DeniedDomains returns a copy of the custom block's denied domains.

func (*AllowList) EnableInteractive

func (al *AllowList) EnableInteractive() *Gate

EnableInteractive attaches a Gate so the deny path can prompt a human instead of refusing outright, and returns it for the daemon to wire its control socket and persistence callback to. Idempotent: repeated calls return the same Gate.

func (*AllowList) Gate

func (al *AllowList) Gate() *Gate

Gate returns the interactive gate, or nil if EnableInteractive was never called.

func (*AllowList) ObserveDNS

func (al *AllowList) ObserveDNS(name string, ip net.IP)

ObserveDNS satisfies the machine.Filter interface. Forwards to ObserveDNSAnswer so gvproxy's DNS observer hook finds a method with the expected shape.

func (*AllowList) ObserveDNSAnswer

func (al *AllowList) ObserveDNSAnswer(name string, ip net.IP)

ObserveDNSAnswer is the handler to install on gvproxy's DNS side. Every A-record the DNS server returns to the guest flows through here, keyed by the queried name. When the chain's verdict on the name is allow, the returned IP is added to the observed set — so the guest's next TCP SYN to that IP passes the filter.

This is how "*.snapcraft.io" actually works in practice: the ACL can't pre-enumerate every subdomain, but each one's IP becomes legitimate the moment the guest resolves it (which happens right before it dials).

Every answer — allowed or not — is also remembered so a later denied SYN to that IP can be attributed to the hostname instead of a bare address.

func (*AllowList) SetChain

func (al *AllowList) SetChain(c *Chain)

SetChain replaces the policy chain. Observed entries whose name the new chain no longer allows are dropped immediately; IPs resolved from removed patterns stay allowed only until the refresh kicked off here rebuilds the resolved set (sub-second in the common case).

func (*AllowList) SetDeniedDomains

func (al *AllowList) SetDeniedDomains(domains []string)

SetDeniedDomains replaces the legacy custom block's denied domains. Entries are registrable domains; each blocks itself and every subdomain. A trailing dot and surrounding space are tolerated.

func (*AllowList) SetPolicy

func (al *AllowList) SetPolicy(ips, cidrs, domains []string) error

SetPolicy replaces the legacy custom block's allows in place: static IPs, CIDR prefixes, and the domain list. Its denies (SetDeniedDomains) are preserved. Observed entries whose name no longer matches any pattern are dropped immediately; IPs resolved from removed domains stay allowed only until the refresh kicked off here rebuilds the resolved set.

func (*AllowList) Size

func (al *AllowList) Size() (ipCount, prefixCount int)

Size returns the current number of allowed IPs (chain exact-IP allows + resolved + observed + granted) plus CIDR allow prefixes.

func (*AllowList) Start

func (al *AllowList) Start(interval time.Duration)

Start runs the periodic domain resolver. interval is how often to refresh; a good default is 5 minutes. SetChain/SetPolicy trigger an immediate refresh in between ticks.

func (*AllowList) Stop

func (al *AllowList) Stop()

Stop ends the refresh goroutine.

type Block

type Block struct {
	// Origin labels where the block came from: "default", "policy",
	// "source", "namespace", "mod", "custom", "runtime".
	Origin string
	// Name attributes the block in denial messages — a policy name, a
	// source URL. May be empty (inline and legacy blocks).
	Name string

	// AllowDomains are exact names (api.example.com, matching only
	// themselves) or left-anchored wildcards (*.example.com, matching any
	// depth of subdomains but never the apex). Trailing dots and
	// surrounding space are tolerated; matching is case-insensitive.
	AllowDomains []string
	// DenyDomains take the same forms, except a bare domain is a subtree
	// deny: it matches the apex AND every subdomain. Denying a name is a
	// guardrail — at resolution time it skips the interactive gate and
	// cannot be overridden by a runtime grant, only by a higher block.
	DenyDomains []string

	// AllowIPs and DenyIPs are literal IPs or CIDR prefixes.
	AllowIPs []string
	DenyIPs  []string
}

Block is one origin-labeled layer of network policy: allow/deny sets for domain names and for literal IPs/CIDRs. A sandbox's effective policy is an ordered chain of blocks (defaults, external blocklists, named policies, inline file entries, runtime edits). Ordering lives *between* blocks — structural, few, stable — never between individual rules, so appending a rule to a block (a CLI edit, a gate-grant persistence) is never position-sensitive.

type Chain

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

Chain is the compiled, immutable form of an ordered block list. It is safe for concurrent readers; a policy change builds a new Chain and swaps it in atomically (see AllowList.SetChain) rather than mutating one in place.

func NewChain

func NewChain(blocks []Block) (*Chain, error)

NewChain compiles blocks, ordered lowest→highest precedence, into a Chain. Domain entries are normalized (lowercase, trailing dots and space stripped); empty ones are dropped. An invalid IP/CIDR entry is an error naming the offending block and entry — policy compilation is the one place a typo can still be attributed to its source.

func (*Chain) AllowDomainPatterns

func (c *Chain) AllowDomainPatterns() []string

AllowDomainPatterns returns the union of every block's AllowDomains (normalized, deduplicated, lowest block first) whose apex is not denied by a higher block. The periodic DNS resolver pre-resolves these apexes so SYNs to an allowed domain pass before the guest ever queries DNS; patterns a higher block has already overruled would only resolve IPs the chain then refuses, so they are skipped at the source.

func (*Chain) DecideAddr

func (c *Chain) DecideAddr(name string, ip netip.Addr) (v Verdict, m Match, byName bool)

DecideAddr walks blocks highest-precedence-first consulting BOTH rule spaces — the connection's name (when one is attributed) and its literal destination IP — in each block; the first block with an opinion on either decides. Running the two spaces in one walk is what makes precedence hold across them: a high block's `deny ip` must beat a low block's domain allow, which two independent walks (names fully first, then IPs) cannot express. Within one block, a deny in either space overrides an allow in the other — the Cedar forbid-overrides-permit rule, scoped to one block, same as the per-space tie rule.

byName reports which space the verdict came from (meaningless when the verdict is VerdictNone). Callers need it because runtime grants sit BETWEEN the spaces in guardrail strength: a name verdict is the operator naming the destination — no grant overrides it — while IP rules rank below an explicit interactive grant (see AllowList.Allow).

func (*Chain) DecideIP

func (c *Chain) DecideIP(ip netip.Addr) (Verdict, Match)

DecideIP is the same highest-block-first walk for literal-IP rules. Within a block specificity is: exact IP > longer prefix > shorter prefix; deny wins ties.

func (*Chain) DecideName

func (c *Chain) DecideName(name string) (Verdict, Match)

DecideName walks blocks highest-precedence-first; the first block with an opinion on name decides. Within a block the most-specific matching entry wins — more labels is more specific, and an exact match beats a wildcard of equal depth — with deny winning a full specificity tie (the Cedar forbid-overrides-permit rule, scoped to one block). VerdictNone when no block matches: the caller's fail-closed default is unchanged.

type Decision

type Decision struct {
	Action Action
	Scope  Scope
}

Decision is a resolved verdict for a held connection. The zero value denies.

func ParseDecision

func ParseDecision(action, scope string) (Decision, error)

ParseDecision maps wire-form action and scope strings to a Decision. An empty scope defaults to ScopeOnce.

type Denial

type Denial struct {
	Host      string    `json:"host"`
	IP        string    `json:"ip"`
	Port      string    `json:"port"`
	Count     int       `json:"count"`
	FirstSeen time.Time `json:"first_seen"`
	LastSeen  time.Time `json:"last_seen"`

	// Rule attributes the refusal to the chain rule that decided it, e.g.
	// "policy oisd: deny tracker.example.com". Empty for the default
	// fail-closed refusal (nothing matched, nothing to blame).
	Rule string `json:"rule,omitempty"`
}

Denial is one aggregated record of refused outbound connections to a single destination host. Host is the DNS name the guest resolved right before dialing when one is known, otherwise the literal IP.

type Event

type Event struct {
	Type    EventType `json:"type"`
	ID      string    `json:"id"`
	Pending *Pending  `json:"pending,omitempty"` // set for EventPending
	Action  string    `json:"action,omitempty"`  // set for EventResolved
}

Event is one item on a subscriber's stream.

type EventType

type EventType string

EventType distinguishes the lifecycle events on the gate's stream.

const (
	// EventPending announces a newly held connection.
	EventPending EventType = "pending"
	// EventResolved announces that a held connection was decided (by a
	// subscriber or by timeout).
	EventResolved EventType = "resolved"
)

type Gate

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

Gate turns the allow list's fail-closed deny into an interactive allow/deny prompt. When the AllowList would refuse a connection, it asks the Gate to Decide: the Gate holds the calling goroutine (one of gvisor's per-connection forwarder goroutines, so blocking is local to that connection), publishes a "pending" event to every subscriber — a menubar app or `clawk network watch` — and unblocks when a decision arrives via Resolve, the hold times out, or the Gate is closed.

The Gate only engages when at least one decider is subscribed. With no UI attached, Decide returns Deny immediately and the AllowList behaves exactly as it did before this feature existed — running the UI is what turns interactive prompting on.

Holds are coalesced per destination IP: several connections to the same host wait on a single prompt and are released together. Concurrent holds are capped (maxHold); past the cap, Decide fails closed rather than queueing an unbounded backlog of prompts.

func (*Gate) AllowAll

func (g *Gate) AllowAll(d time.Duration)

AllowAll opens a time-boxed bypass on the allow list (every destination passes for d) and releases every currently held connection as allowed. While the bypass is active no new holds occur, since Allow passes before reaching the gate. Used by the "allow all for 1h" escape hatch.

func (*Gate) AllowAllUntil

func (g *Gate) AllowAllUntil() time.Time

AllowAllUntil reports when the time-boxed bypass expires (zero if inactive).

func (*Gate) Close

func (g *Gate) Close()

Close denies every outstanding hold, releases all waiters, and drops all subscribers. Used on daemon shutdown so no forwarder goroutine is left blocked. Safe to call more than once.

func (*Gate) Decide

func (g *Gate) Decide(key netip.Addr, host, port string) Decision

Decide holds the calling goroutine until the connection to key (the destination IP) is allowed or denied. host is the DNS name the guest resolved for key, if known, shown to the human; port is informational.

It returns Deny immediately when no decider is subscribed, when the gate is closed, or when the concurrent-hold cap is reached — in every such case the AllowList records the denial and refuses the connection, exactly as the non-interactive path does.

func (*Gate) Pending

func (g *Gate) Pending() []Pending

Pending returns a snapshot of every outstanding hold, soonest deadline first.

func (*Gate) Resolve

func (g *Gate) Resolve(id string, d Decision) error

Resolve decides the held connection identified by id. It returns ErrUnknownDecision if no such hold is outstanding (already resolved, expired, or never existed).

func (*Gate) SetOnAlways

func (g *Gate) SetOnAlways(fn func(host string, ip netip.Addr))

SetOnAlways registers the callback invoked when a hold is allowed with ScopeAlways. Set it once, before the gate serves traffic.

func (*Gate) SetOnDenyDomain

func (g *Gate) SetOnDenyDomain(fn func(domain string))

SetOnDenyDomain registers the callback invoked when a hold is denied with ScopeAlways (the blocked registrable domain). Set it once, before serving.

func (*Gate) Subscribe

func (g *Gate) Subscribe() (<-chan Event, func())

Subscribe registers a stream of gate events and returns it with a cancel func that must be called to release it. Outstanding holds are replayed immediately so a UI attaching mid-flight sees what's already waiting.

func (*Gate) Subscribers

func (g *Gate) Subscribers() int

Subscribers reports how many deciders are currently attached.

type Match

type Match struct {
	Origin  string
	Block   string // Block.Name
	Entry   string // the (normalized) rule text that matched
	Verdict Verdict
}

Match attributes a Verdict to the rule that produced it, so a refused connection can be explained ("policy oisd: deny tracker.example.com") instead of just refused.

func (Match) String

func (m Match) String() string

String renders the human-readable attribution recorded with a denial, e.g. "policy oisd: deny tracker.example.com".

type Pending

type Pending struct {
	ID   string `json:"id"`
	Host string `json:"host"` // DNS name the guest resolved, else the IP
	IP   string `json:"ip"`
	Port string `json:"port"`
	// Waiters is how many connections to this destination are coalesced
	// behind this single prompt — a UI can surface "3 connections waiting".
	Waiters  int       `json:"waiters"`
	Deadline time.Time `json:"deadline"`
}

Pending is a snapshot of one connection held awaiting a decision.

type Scope

type Scope int

Scope qualifies how long an ActionAllow persists.

const (
	// ScopeOnce allows only the connection(s) currently held for this
	// destination; the next connection to it prompts again.
	ScopeOnce Scope = iota
	// ScopeSession grants the destination IP for the life of the VM. Not
	// persisted: a restart forgets it.
	ScopeSession
	// ScopeAlways grants the destination and persists it to the sandbox's
	// network policy so it survives restarts.
	ScopeAlways
)

func (Scope) String

func (s Scope) String() string

String renders the wire form of a Scope.

type Verdict

type Verdict int

Verdict is a policy chain's opinion on one destination: allow it, deny it, or no opinion at all (VerdictNone), in which case the caller falls through to the next mechanism — runtime grants, the interactive gate, or the default fail-closed refusal.

const (
	VerdictNone Verdict = iota
	VerdictAllow
	VerdictDeny
)

func (Verdict) String

func (v Verdict) String() string

String renders a Verdict for attribution strings and test failures.

Jump to

Keyboard shortcuts

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