Documentation
¶
Index ¶
- Variables
- func SplitEntries(entries []string) (ips, cidrs []string)
- type Action
- type AllowList
- func (al *AllowList) Allow(addr string) error
- func (al *AllowList) AllowAllFor(d time.Duration)
- func (al *AllowList) AllowAllUntil() time.Time
- func (al *AllowList) AllowICMP(addr string) error
- func (al *AllowList) AllowTCP(addr string) error
- func (al *AllowList) AllowUDP(addr string) error
- func (al *AllowList) Denials() []Denial
- func (al *AllowList) DeniedDomains() []string
- func (al *AllowList) EnableInteractive() *Gate
- func (al *AllowList) Gate() *Gate
- func (al *AllowList) ObserveDNS(name string, ip net.IP)
- func (al *AllowList) ObserveDNSAnswer(name string, ip net.IP)
- func (al *AllowList) SetChain(c *Chain)
- func (al *AllowList) SetDeniedDomains(domains []string)
- func (al *AllowList) SetPolicy(ips, cidrs, domains []string) error
- func (al *AllowList) Size() (ipCount, prefixCount int)
- func (al *AllowList) Start(interval time.Duration)
- func (al *AllowList) Stop()
- type Block
- type Chain
- type Decision
- type Denial
- type Event
- type EventType
- type Gate
- func (g *Gate) AllowAll(d time.Duration)
- func (g *Gate) AllowAllUntil() time.Time
- func (g *Gate) Close()
- func (g *Gate) Decide(key netip.Addr, host, port string) Decision
- func (g *Gate) Pending() []Pending
- func (g *Gate) Resolve(id string, d Decision) error
- func (g *Gate) SetOnAlways(fn func(host string, ip netip.Addr))
- func (g *Gate) SetOnDenyDomain(fn func(domain string))
- func (g *Gate) Subscribe() (<-chan Event, func())
- func (g *Gate) Subscribers() int
- type Match
- type Pending
- type Scope
- type Verdict
Constants ¶
This section is empty.
Variables ¶
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 ¶
SplitEntries partitions a list of allowed-IP entries into plain addresses and CIDR prefixes so NewAllowList can validate them distinctly.
Types ¶
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 ¶
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 ¶
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 ¶
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 ¶
AllowAllFor opens a time-boxed bypass: every destination is allowed until now+d. A non-positive d clears the bypass.
func (*AllowList) AllowAllUntil ¶
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 ¶
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 ¶
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 ¶
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) DeniedDomains ¶
DeniedDomains returns a copy of the custom block's denied domains.
func (*AllowList) EnableInteractive ¶
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 ¶
Gate returns the interactive gate, or nil if EnableInteractive was never called.
func (*AllowList) ObserveDNS ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Size returns the current number of allowed IPs (chain exact-IP allows + resolved + observed + granted) plus CIDR allow prefixes.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Decision is a resolved verdict for a held connection. The zero value denies.
func ParseDecision ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
Pending returns a snapshot of every outstanding hold, soonest deadline first.
func (*Gate) Resolve ¶
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 ¶
SetOnAlways registers the callback invoked when a hold is allowed with ScopeAlways. Set it once, before the gate serves traffic.
func (*Gate) SetOnDenyDomain ¶
SetOnDenyDomain registers the callback invoked when a hold is denied with ScopeAlways (the blocked registrable domain). Set it once, before serving.
func (*Gate) Subscribe ¶
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 ¶
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.
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 )