-
Notifications
You must be signed in to change notification settings - Fork 2
Writing a Transport
The core (client + server) owns users, certificates, the database and logging. It does
not own the transport. You bring your own by implementing two small interfaces:
-
server.Handler— the server-side "listener/server/RPC" stack. -
client.Dialer— the client-side connection backend.
The gRPC backend under example/transports/grpc is one such implementation — a reference to
copy, not a dependency. This page walks the interfaces and the gRPC example so you can write
your own (HTTP, QUIC, WebSocket, Tailscale, in-process channels, ...).
- The two interfaces
- The two-phase contract: Init then Listen/Dial
- Server side: implementing Handler
- Client side: implementing Dialer
- Wiring it together
- In-memory vs remote
- What the core gives your transport
- Composing / extending a transport
// server.Handler — a transport stack registered with server.WithHandler().
type Handler interface {
// Name keys this stack in the teamserver (must be unique), e.g. "gRPC".
Name() string
// Init: transport-AGNOSTIC preparation. Access the server for credentials,
// loggers, users, filesystem. Any non-nil error aborts starting.
Init(s *Server) error
// Listen: transport-SPECIFIC binding. Create and return a net.Listener bound
// to addr. MUST NOT block (start serving in a goroutine).
Listen(addr string) (net.Listener, error)
}
// client.Dialer — the client-side connection backend, set with client.WithDialer().
type Dialer interface {
// Init: query the driving *client.Client for the remote config, credentials,
// loggers, filesystem.
Init(c *Client) error
// Dial: connect to the endpoint in the client's remote config.
Dial() error
// Close: tear down the connection / related components.
Close() error
}Terminology: you register Handlers (transport stacks, keyed by Name()); serving one on
a host:port creates a Listener (a bind job, controlled via the server's
Listeners() / ListenerClose() / ListenerStartPersistents() methods).
A dialer will very often also implement team.Client (Users + VersionServer), since
the connection it dials is what answers those queries. When it does, the core uses it as its
teamclient backend automatically — WithDialer() alone is enough, no separate
WithTeamClient() needed.
Both interfaces split preparation from binding on purpose:
-
Initis transport-agnostic: fetch credentials, build middleware, grab loggers. -
Listen/Dialis transport-specific: actually bind or connect.
Keeping them separate lets implementations compose by embedding a base handler/dialer and
overriding only Listen() / Dial() — e.g. a variant that reuses all the gRPC
credential/middleware setup from Init and only changes how the listener is created (a
different network stack such as Tailscale, QUIC, etc.).
Errors: every error returned by these interface methods is treated as critical and stops the start/serve process. Return only genuinely fatal errors; log non-critical ones via the core loggers.
Below is the shape of the gRPC example (example/transports/grpc/server/server.go),
annotated. It embeds *teamserver.Server so it can reach the core directly.
type Teamserver struct {
*teamserver.Server // embed the core: gives us Authenticate, UsersTLSConfig, loggers...
options []grpc.ServerOption
conn *bufconn.Listener // set only for in-memory serving
mutex *sync.RWMutex
hooks []func(*grpc.Server) error
}
func NewListener(opts ...grpc.ServerOption) *Teamserver { /* ... */ }
// Name keys this stack in the teamserver.
func (h *Teamserver) Name() string { return "gRPC" }
// Init: transport-agnostic. Grab the core server, build logging + auth middleware.
func (h *Teamserver) Init(serv *teamserver.Server) (err error) {
h.Server = serv
// Logging middleware (uses this transport's own logrus backend + core config).
h.options, err = LogMiddlewareOptions(h.Server)
if err != nil {
return err
}
// Authentication middleware: token auth for remote conns, no-auth for in-memory.
authOpts, err := h.initAuthMiddleware()
if err != nil {
return err
}
h.options = append(h.options, authOpts...)
return nil
}
// Listen: transport-specific. Bind, wrap remote binds in mutual TLS, serve in a goroutine.
func (h *Teamserver) Listen(addr string) (ln net.Listener, err error) {
if h.conn == nil { // remote
ln, err = net.Listen("tcp", addr)
if err != nil {
return nil, err
}
// Mutual TLS gate built from the core users CA + server cert.
tlsOpts, err := TLSAuthMiddlewareOptions(h.Server) // -> server.UsersTLSConfig()
if err != nil {
return nil, err
}
h.options = append(h.options, tlsOpts...)
} else { // in-memory: reuse the bufconn, no TLS
h.mutex.Lock()
ln, h.conn = h.conn, nil
h.mutex.Unlock()
}
grpcServer := grpc.NewServer(h.options...)
proto.RegisterTeamServer(grpcServer, newServer(h.Server)) // core RPC service
for _, hook := range h.hooks { // let the app register extra services
if err := hook(grpcServer); err != nil {
return nil, err
}
}
go grpcServer.Serve(ln) // MUST NOT block: serve in the background
return ln, nil
}Key points:
-
Authentication goes through the core: the middleware calls
h.Authenticate(token)to learn who is calling, then the application injects its own identity — see Users & Authentication → How it all fits in a transport. -
Encryption comes from
server.UsersTLSConfig()— a fully-formed mutual-TLS config; you do not assemble certificates yourself. -
Listenmust not block. Return thenet.Listener; the core wraps it in a job soListenerClose()can stop it later. -
Pre-serve hooks are your own mechanism, not part of the interface. The gRPC example
exposes
PostServe(func(*grpc.Server) error)so applications can register extra RPC services on the same server before it starts.
The gRPC dialer (example/transports/grpc/client/client.go) embeds *client.Client and
also implements team.Client:
type Teamclient struct {
*client.Client
conn *grpc.ClientConn
rpc proto.TeamClient
options []grpc.DialOption
}
// Init: transport-agnostic. Ask the core for the remote config; if it carries credentials,
// build mutual-TLS dial options. An empty private key means in-memory (no TLS).
func (h *Teamclient) Init(cli *client.Client) error {
h.Client = cli
config := cli.Config()
opts := LogMiddlewareOptions()
if config.PrivateKey != "" { // remote
tlsOpts, err := tlsAuthMiddleware(cli) // -> client.NewTLSConfigFrom(...)
if err != nil {
return err
}
h.options = append(h.options, tlsOpts...)
}
h.options = append(h.options, opts...)
return nil
}
// Dial: connect using the remote config's host:port.
func (h *Teamclient) Dial() (err error) {
host := fmt.Sprintf("%s:%d", h.Config().Host, h.Config().Port)
h.conn, err = grpc.DialContext(ctx, host, h.options...)
if err != nil {
return err
}
h.rpc = proto.NewTeamClient(h.conn)
return nil
}
// Close: tear down the connection.
func (h *Teamclient) Close() error { return h.conn.Close() }
// team.Client — because the dialer's connection is what answers these, the core picks the
// dialer up as its backend automatically (no WithTeamClient needed).
func (h *Teamclient) Users() ([]team.User, error) { /* rpc.GetUsers ... */ }
func (h *Teamclient) VersionServer() (team.Version, error) { /* rpc.GetVersion ... */ }The core drives this in client.Connect(): it loads/selects a config, then calls
dialer.Init(client) and dialer.Dial() exactly once (guarded by a sync.Once).
client.Disconnect() calls dialer.Close().
Server and self-client sharing the same gRPC backend:
gTeamserver := grpc.NewListener()
teamserver, _ := server.New("myapp", server.WithHandler(gTeamserver))
gTeamclient := grpc.NewClientFrom(gTeamserver) // in-memory dialer bound to server
teamclient := teamserver.Self(client.WithDialer(gTeamclient))A separate remote client binary:
gTeamclient := grpc.NewTeamClient() // remote dialer (mutual TLS)
teamclient, _ := client.New("myapp", client.WithDialer(gTeamclient))Registering multiple handlers is fine — the first registered becomes the default (self),
and users pick others with teamserver listen --listener <name>:
teamserver, _ := server.New("myapp",
server.WithHandler(grpcStack), // default
server.WithHandler(httpStack), // also available by Name()
)A single backend usually handles both; the discriminator is whether a connection was pre-seeded:
| Remote | In-memory (self-client) | |
|---|---|---|
| Transport | net.Listen("tcp", addr) |
a pre-seeded bufconn / channel |
| TLS |
server.UsersTLSConfig() (mutual TLS) |
none |
| Auth | token via server.Authenticate
|
a fixed "server" identity, no token |
| Config credentials | full cert/key/token | empty (PrivateKey == "") |
The gRPC example encodes exactly this: h.conn == nil ⇒ remote path (TCP + TLS + token
auth); non-nil ⇒ in-memory path (bufconn, insecure creds, server auth func).
Note: the shipped gRPC client backend (
grpc.NewTeamClient) is remote-only; the in-memory dialer is produced bygrpc.NewClientFrom(server)on the server side.
Inside Init/Listen/Dial (via the embedded core or the passed pointer) you can use:
From *server.Server:
-
Authenticate(token) (*team.User, error)— the identity primitive. -
UsersTLSConfig() (*tls.Config, error)— server-side mutual-TLS config. -
Users(),VersionServer()— data your RPC service exposes. -
NamedLogger(pkg, stream)— a tagged*slog.Logger. -
AuditLogger() (*slog.Logger, error)— a JSON audit logger (defaultaudit.json). -
GetConfig(),Filesystem(),TeamDir()/LogsDir()/CertificatesDir().
From *client.Client:
-
Config() *client.Config— the remote endpoint + credentials to dial. -
NewTLSConfigFrom(ca, cert, key)— client-side mutual-TLS config. -
NamedLogger(pkg, stream),SetLogWriter(...),Filesystem().
See Server API and Client API for the full surface, and Logging for the audit log and console styling.
Because Init (agnostic) and Listen/Dial (specific) are separate, the recommended way
to add a variant is to embed the base handler/dialer and override only the binding
method. For example, to serve the same gRPC stack over a different network layer you can
reuse the gRPC credential and middleware setup from Init and replace only how the listener
is created in Listen:
type CustomServer struct {
*grpc.Teamserver // reuse Name(), Init() and all middleware
}
func (h *CustomServer) Listen(addr string) (net.Listener, error) {
// bind via your own network stack instead of net.Listen, then serve gRPC on it
}Note: the
server.Handlergodoc mentions a "Tailscale variant" as the motivating example for this composition pattern, but no such transport is bundled in this repository today — the shipped examples areexample/transports/grpc(the main reference) andexample/transports/grpcslog(the same stack demonstrating a self-owned logrus logging backend). Both are copy-and-adapt starting points, not dependencies.
Related: Architecture → · Logging → · Users & Authentication →