Skip to content

Writing a Transport

maxlandon edited this page Jul 18, 2026 · 1 revision

Writing a Transport · [Dev]

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

// 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.


The two-phase contract: Init then Listen/Dial

Both interfaces split preparation from binding on purpose:

  • Init is transport-agnostic: fetch credentials, build middleware, grab loggers.
  • Listen / Dial is 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.


Server side: implementing Handler

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.
  • Listen must not block. Return the net.Listener; the core wraps it in a job so ListenerClose() 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.

Client side: implementing Dialer

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().


Wiring it together

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()
)

In-memory vs remote

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 by grpc.NewClientFrom(server) on the server side.


What the core gives your transport

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 (default audit.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.


Composing / extending a transport

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.Handler godoc 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 are example/transports/grpc (the main reference) and example/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 →

Clone this wiki locally