Headscale — Interfaces#

Interface catalog#

PolicyManager#

  • Package: hscontrol/policy
  • File: hscontrol/policy/pm.go:14
  • Methods:
    Filter() ([]tailcfg.FilterRule, []matcher.Match)
    FilterForNode(node types.NodeView) ([]tailcfg.FilterRule, error)
    MatchersForNode(node types.NodeView) ([]matcher.Match, error)
    BuildPeerMap(nodes views.Slice[types.NodeView]) map[types.NodeID][]types.NodeView
    SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error)
    SSHCheckParams(srcNodeID, dstNodeID types.NodeID) (time.Duration, bool)
    SetPolicy(pol []byte) (bool, error)
    SetUsers(users []types.User) (bool, error)
    SetNodes(nodes views.Slice[types.NodeView]) (bool, error)
    NodeCanHaveTag(node types.NodeView, tag string) bool
    TagExists(tag string) bool
    NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
    ViaRoutesForPeer(viewer, peer types.NodeView) types.ViaRouteResult
    Version() int
    DebugString() string
  • Purpose: Central ACL contract. Governs every aspect of Tailscale access control: which nodes can see each other (BuildPeerMap), which packet filters apply (Filter, FilterForNode), SSH access rules (SSHPolicy, SSHCheckParams), route auto-approval (NodeCanApproveRoute), tag ownership (NodeCanHaveTag), and policy hot-reload (SetPolicy, SetUsers, SetNodes). Also provides Via grant support for multi-hop routing.
  • Implementations: policyv2.PolicyManager (in hscontrol/policy/v2/). The NewPolicyManager factory in pm.go delegates exclusively to v2; a legacy v1 implementation was removed. Test helpers expose PolicyManagerFuncsForTest to run the same test suite against all registered implementations.
  • Design quality: Broad but cohesive — all methods relate to a single concern (access control). Could arguably be split into a read-only query interface and a write interface (for policy updates), which would better follow ISP. The Version() and DebugString() utility methods are minor noise in an otherwise focused interface.

AuthProvider#

  • Package: hscontrol
  • File: hscontrol/auth.go:21
  • Methods:
    RegisterHandler(w http.ResponseWriter, r *http.Request)
    AuthHandler(w http.ResponseWriter, r *http.Request)
    RegisterURL(authID types.AuthID) string
    AuthURL(authID types.AuthID) string
  • Purpose: Pluggable authentication mechanism. The two handler methods are wired into the chi HTTP router; the two URL methods generate redirect targets sent in tailcfg.RegisterResponse.AuthURL. By hiding implementation details behind this 4-method interface, the rest of the application is blind to whether auth is handled via a local web form or an external OIDC provider.
  • Implementations: AuthProviderWeb (browser-based interactive login, auth.go) and AuthProviderOIDC (oidc.go). The concrete type is chosen once during NewHeadscale() based on cfg.OIDC.Issuer being set and is stored in Headscale.authProvider. There is no runtime switching.
  • Design quality: Excellent ISP compliance. 4 methods, 2 pairs that mirror each other (Register* vs Auth*). The handler pair integrates with net/http stdlib conventions; the URL pair covers the response-generation side. Minimal and complete.

nodeConnection#

  • Package: hscontrol/mapper
  • File: hscontrol/mapper/batcher.go:62
  • Methods:
    nodeID() types.NodeID
    version() tailcfg.CapabilityVersion
    send(data *tailcfg.MapResponse) error
    computePeerDiff(currentPeers []tailcfg.NodeID) (removed []tailcfg.NodeID)
    updateSentPeers(resp *tailcfg.MapResponse)
  • Purpose: Represents a single connected node’s output slot from the mapper’s perspective. send() delivers a tailcfg.MapResponse to the long-poll HTTP channel; computePeerDiff/updateSentPeers maintain bookkeeping of which peers have been sent so incremental diffs can be generated correctly. Unexported; used only within the mapper package.
  • Implementations: multiChannelNodeConn — the concrete type that manages a list of connectionEntry values (one per simultaneous connection from the same NodeID). The interface exists not for external extensibility but to make generateMapResponse and handleNodeChange testable in isolation without coupling to the full multi-channel connection machinery.
  • Design quality: Clean, internal-facing interface. All methods have a single clear responsibility. The unexported method names (lowercase) signal this is package-private by convention. Five methods is the right size: any narrower and the function signatures would bloat; any wider and it would start owning state.

ControlServer#

  • Package: integration
  • File: integration/control.go:16
  • Methods (31): Shutdown, SaveLog, ReadLog, SaveProfile, Execute, WriteFile, ConnectToNetwork, GetHealthEndpoint, GetEndpoint, WaitForRunning, CreateUser, CreateAuthKey, CreateAuthKeyWithTags, CreateAuthKeyWithOptions, DeleteAuthKey, ListNodes, DeleteNode, NodesByUser, NodesByName, ListUsers, MapUsers, DeleteUser, ApproveRoutes, SetNodeTags, GetCert, GetHostname, GetIPInNetwork, SetPolicy, GetAllMapReponses, PrimaryRoutes, DebugBatcher, DebugNodeStore, DebugFilter
  • Purpose: Integration-test abstraction over a running headscale server. All test scenarios program against ControlServer, allowing the same test code to run against a Docker container (hsic package) or an in-process server. The Debug* methods (DebugBatcher, DebugNodeStore, DebugFilter) are especially notable — they expose live internal state to test assertions without requiring a real gRPC round-trip.
  • Implementations: HeadscaleInDocker (via integration/hsic/) and at least one in-process variant used by unit-level integration tests.
  • Design quality: Far too wide for ISP. 31 methods conflate lifecycle control (Shutdown, WaitForRunning), infrastructure (ConnectToNetwork, GetIPInNetwork), admin CRUD (user/node/key management), and diagnostics (Debug*). This is a test-convenience god-interface, acceptable in a test package but not a model to emulate. That said, having it as a single interface makes scenario setup code clean and uniform.

TailscaleClient#

  • Package: integration
  • File: integration/tailscale.go:22
  • Methods (~30): Hostname, Shutdown, Version, Execute, Login, LoginWithURL, Logout, Restart, Up, Down, IPs, MustIPs, IPv4, MustIPv4, MustIPv6, FQDN, MustFQDN, Status, MustStatus, Netmap, DebugDERPRegion, GetNodePrivateKey, Netcheck, WaitForNeedsLogin, WaitForRunning, WaitForPeers, Ping, Curl, CurlFailFast, Traceroute, ContainerID, MustID, ReadFile, PacketFilter, ConnectToNetwork
  • Purpose: Integration-test abstraction over a Tailscale client container. Enables test scenarios to issue network-level assertions (Ping, Curl, Traceroute) and introspect client state (Status, Netmap, PacketFilter) without caring whether the client runs in Docker or some other harness. The Must* variants return values directly and panic on error — a common Go test-helper pattern to reduce boilerplate inside EventuallyWithT loops.
  • Implementations: tsic.TailscaleInContainer (Docker-based).
  • Design quality: Same problem as ControlServer — too wide. The Must* variants are pure ergonomics duplicates of their non-panicking counterparts and inflate method count. A split into lifecycle/network-ops/introspection sub-interfaces would be cleaner but was not done here.

Alias (policy v2)#

  • Package: hscontrol/policy/v2
  • File: hscontrol/policy/v2/types.go:815
  • Methods:
    Validate() error
    UnmarshalJSON(b []byte) error
    Resolve(pol *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error)
    resolve(pol *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error)
  • Purpose: Tagged-union abstraction for all source/destination terms in the HuJSON policy DSL. Every string token that appears in an ACL src or dst field — "user@example.com", "group:admins", "tag:server", "10.0.0.0/8", "*" — is parsed into a concrete type that satisfies Alias. The Resolve method turns any alias into a concrete set of IP addresses at evaluation time, contextualised by the current users and nodes.
  • Implementations: Username, Group, Tag, Host, Prefix, AutoGroup, Asterix. AliasWithPorts embeds Alias to add port-range semantics for dst fields.
  • Design quality: The unexported resolve method (returning raw *netipx.IPSet) alongside the exported Resolve (returning ResolvedAddresses) is a pragmatic but slightly awkward split — it exists because internal composition uses the raw IPSet form to avoid intermediate allocations. Functionally sound; the interface acts as a discriminated union, a pattern Go handles well through dynamic dispatch.

ResolvedAddresses (policy v2)#

  • Package: hscontrol/policy/v2
  • File: hscontrol/policy/v2/types.go:193
  • Methods:
    Strings() []string
    Prefixes() []netip.Prefix
    Empty() bool
    Iter() iter.Seq[netip.Addr]
    Contains(ip netip.Addr) bool
  • Purpose: Opaque result type returned by Alias.Resolve(). Wraps a netipx.IPSet and provides multiple views of the resolved addresses: prefix representation (for Tailscale wire protocol), string representation (for policy debugging), iteration (for ACL rule building), and membership testing. Insulates callers from the raw IPSet implementation.
  • Implementations: resolved struct (unexported concrete type).
  • Design quality: Well-designed value-object interface. Five methods, all read-only, each providing a distinct projection of the same underlying data. A good example of the “interface as immutable view” pattern.

AutoApprover (policy v2)#

  • Package: hscontrol/policy/v2
  • File: hscontrol/policy/v2/types.go:1136
  • Methods:
    CanBeAutoApprover() bool
    UnmarshalJSON(b []byte) error
    String() string
  • Purpose: Tagged-union for identities allowed to auto-approve subnet routes. Values are Username, Group, or Tag. CanBeAutoApprover() is a marker method that distinguishes this interface at compile time.
  • Implementations: *Username, *Group, *Tag.
  • Design quality: The marker method CanBeAutoApprover() is a Go anti-pattern for type discrimination — it exists to help the decoder distinguish AutoApprover from Owner and Alias during JSON unmarshalling, since all three can be string-typed. Acceptable as a workaround, but reflects the fundamental awkwardness of implementing discriminated unions in Go.

Owner (policy v2)#

  • Package: hscontrol/policy/v2
  • File: hscontrol/policy/v2/types.go:1213
  • Methods:
    CanBeTagOwner() bool
    UnmarshalJSON(b []byte) error
    String() string
  • Purpose: Tagged-union for identities that can own tags (appear in tagOwners ACL section). Values are Username or Group.
  • Implementations: *Username, *Group.
  • Design quality: Same marker-method pattern as AutoApprover. Both share the same structural criticism.

HeadscaleServiceServer (generated gRPC)#

  • Package: gen/go/headscale/v1
  • File: gen/go/headscale/v1/headscale_grpc.pb.go:384
  • Methods (generated): GetUser, CreateUser, RenameUser, DeleteUser, ListUsers, CreatePreAuthKey, ExpirePreAuthKey, ListPreAuthKeys, CreateApiKey, GetApiKey, ExpireApiKey, ListApiKeys, DeleteApiKey, ListNodes, GetNode, UpdateNode, DeleteNode, ExpireNode, MoveNode, SetTags, RegisterNode, GetNodeRoutes, EnableNodeRoutes, EnableRoute, GetPolicy, SetPolicy, GetSSHPolicy, DebugCreateNode, CreateNode
  • Purpose: Admin gRPC contract generated from proto/headscale/v1/headscale.proto. Defines all management operations for the headscale server. Also served as REST via grpc-gateway bridge.
  • Implementations: headscaleV1APIServer (in hscontrol/grpcv1.go).
  • Design quality: Protobuf-generated; size reflects the scope of the admin API rather than a design choice. The UnsafeHeadscaleServiceServer companion interface (also generated) is a forward-compatibility shim that forces implementors to embed UnimplementedHeadscaleServiceServer to avoid breaking changes when new RPCs are added.

Interface patterns#

  • Size distribution: Bimodal. Internal production interfaces are small and focused: AuthProvider (4), nodeConnection (5), AutoApprover/Owner (3), ResolvedAddresses (5). Consumer-facing contracts are larger: PolicyManager (15), HeadscaleServiceServer (29+ generated). Test interfaces are God-interfaces: ControlServer (31), TailscaleClient (35+). Average across all is ~14, but that number is misleading given the bimodal distribution.

  • Embedding: AliasWithPorts embeds Alias to extend it with port-range fields — a clean composition idiom. AutoApproverEnc embeds AutoApprover and OwnerEnc embeds Owner as decode-helper wrappers. No interface-on-interface embedding is used for the main production interfaces.

  • Implicit satisfaction: All interfaces follow Go idiom — satisfaction is implicit. Most production interfaces are defined close to their primary consumer (nodeConnection in mapper/, AuthProvider in hscontrol/), not at the provider side. PolicyManager is an exception: it is defined in the policy package (provider side), which is typical for a central domain abstraction.

  • stdlib interfaces used: http.ResponseWriter and *http.Request appear in AuthProvider, following standard Go HTTP handler conventions. No explicit use of io.Reader, io.Writer, fmt.Stringer, or sort.Interface in the primary interfaces, though many concrete types implement String() informally.


Key abstractions#

  1. PolicyManager — The architectural linchpin. Every node-to-node communication decision passes through it. Its 15-method surface reflects the genuine complexity of access control: filtering, SSH, tagging, routing, and hot-reload are all policy concerns that cannot be separated without losing coherence. The jump from v1 to v2 implementation (with v1 now fully removed) is a model of how to evolve behind an interface.

  2. AuthProvider — The cleanest interface in the codebase. Its 4-method surface perfectly isolates the two sides of an auth flow (inbound HTTP callbacks + outbound URL generation) and enables a clean swap between web and OIDC auth without any conditional logic leaking into the rest of the server.

  3. nodeConnection — An invisible but critical internal contract. The fan-out path in generateMapResponse/handleNodeChange is tested against this interface without coupling to the multi-channel connection implementation. It is the key seam that keeps the Batcher’s dispatch logic unit-testable.

  4. Alias (policy v2) — A discriminated union disguised as an interface. The 7 concrete implementations (Username, Group, Tag, Host, Prefix, AutoGroup, Asterix) share only the Resolve contract; the interface is what allows the policy parser to produce a heterogeneous collection of ACL terms that all evaluate to ResolvedAddresses. This is the Go idiom for sum types.

  5. HeadscaleServiceServer — Not architecturally interesting in design terms (protobuf-generated), but operationally significant: it defines the boundary between the admin CLI/REST clients and the running server. The grpc-gateway bridge makes it serve double duty as both gRPC and REST without a separate HTTP handler layer.


Interface-driven extensibility#

Auth backend pluggability (AuthProvider): This is the main use of interfaces for swappable production backends. Adding a new auth mechanism (e.g., a CLI-token provider or SAML) requires only implementing 4 methods and wiring the choice in NewHeadscale(). The rest of the application is unchanged.

Policy implementation swapping (PolicyManager): The factory pattern in NewPolicyManager() and PolicyManagerFuncsForTest() shows how the interface enables running the same test suite against multiple policy engine implementations simultaneously — useful during the v1→v2 migration to validate behavioral equivalence.

Test harness abstraction (ControlServer, TailscaleClient): The integration test suite uses interfaces to run the same scenarios against Docker containers (the production path) and potentially in-process implementations (faster feedback). The ControlServer.Debug* methods are a notable pattern: by exposing internal state through the test interface, test assertions can verify subsystem state (batcher connections, nodestore contents, filter rules) without white-box coupling to specific struct fields.

Policy DSL type system (Alias, AutoApprover, Owner): The v2 policy package uses interfaces as Go’s answer to algebraic data types. The HuJSON parser produces slices of Alias / AutoApprover / Owner values; the evaluation engine operates on them polymorphically. New ACL term types (e.g., a hypothetical ServiceAccount) could be added by implementing the appropriate 3-4-method interface. The marker methods (CanBeAutoApprover, CanBeTagOwner) are a necessary workaround for the JSON unmarshalling bootstrapping problem.