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 providesViagrant support for multi-hop routing. - Implementations:
policyv2.PolicyManager(inhscontrol/policy/v2/). TheNewPolicyManagerfactory inpm.godelegates exclusively to v2; a legacy v1 implementation was removed. Test helpers exposePolicyManagerFuncsForTestto 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()andDebugString()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) andAuthProviderOIDC(oidc.go). The concrete type is chosen once duringNewHeadscale()based oncfg.OIDC.Issuerbeing set and is stored inHeadscale.authProvider. There is no runtime switching. - Design quality: Excellent ISP compliance. 4 methods, 2 pairs that mirror each other (
Register*vsAuth*). The handler pair integrates withnet/httpstdlib 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 atailcfg.MapResponseto the long-poll HTTP channel;computePeerDiff/updateSentPeersmaintain bookkeeping of which peers have been sent so incremental diffs can be generated correctly. Unexported; used only within themapperpackage. - Implementations:
multiChannelNodeConn— the concrete type that manages a list ofconnectionEntryvalues (one per simultaneous connection from the same NodeID). The interface exists not for external extensibility but to makegenerateMapResponseandhandleNodeChangetestable 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 (hsicpackage) or an in-process server. TheDebug*methods (DebugBatcher,DebugNodeStore,DebugFilter) are especially notable — they expose live internal state to test assertions without requiring a real gRPC round-trip. - Implementations:
HeadscaleInDocker(viaintegration/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. TheMust*variants return values directly and panic on error — a common Go test-helper pattern to reduce boilerplate insideEventuallyWithTloops. - Implementations:
tsic.TailscaleInContainer(Docker-based). - Design quality: Same problem as
ControlServer— too wide. TheMust*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
srcordstfield —"user@example.com","group:admins","tag:server","10.0.0.0/8","*"— is parsed into a concrete type that satisfiesAlias. TheResolvemethod 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.AliasWithPortsembedsAliasto add port-range semantics fordstfields. - Design quality: The unexported
resolvemethod (returning raw*netipx.IPSet) alongside the exportedResolve(returningResolvedAddresses) 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 anetipx.IPSetand 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 rawIPSetimplementation. - Implementations:
resolvedstruct (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, orTag.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 distinguishAutoApproverfromOwnerandAliasduring 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
tagOwnersACL section). Values areUsernameorGroup. - 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(inhscontrol/grpcv1.go). - Design quality: Protobuf-generated; size reflects the scope of the admin API rather than a design choice. The
UnsafeHeadscaleServiceServercompanion interface (also generated) is a forward-compatibility shim that forces implementors to embedUnimplementedHeadscaleServiceServerto 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:
AliasWithPortsembedsAliasto extend it with port-range fields — a clean composition idiom.AutoApproverEncembedsAutoApproverandOwnerEncembedsOwneras 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 (
nodeConnectioninmapper/,AuthProviderinhscontrol/), not at the provider side.PolicyManageris an exception: it is defined in thepolicypackage (provider side), which is typical for a central domain abstraction.stdlib interfaces used:
http.ResponseWriterand*http.Requestappear inAuthProvider, following standard Go HTTP handler conventions. No explicit use ofio.Reader,io.Writer,fmt.Stringer, orsort.Interfacein the primary interfaces, though many concrete types implementString()informally.
Key abstractions#
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.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.nodeConnection— An invisible but critical internal contract. The fan-out path ingenerateMapResponse/handleNodeChangeis 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.Alias(policy v2) — A discriminated union disguised as an interface. The 7 concrete implementations (Username,Group,Tag,Host,Prefix,AutoGroup,Asterix) share only theResolvecontract; the interface is what allows the policy parser to produce a heterogeneous collection of ACL terms that all evaluate toResolvedAddresses. This is the Go idiom for sum types.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.