Skip to content

Architecture

maxlandon edited this page Jul 18, 2026 · 1 revision

Architecture · [Dev]

How the module is laid out, how the pieces fit, and how a request flows through them. This page pairs with Introduction & Concepts (the mental model) and Writing a Transport (the one extension seam you are expected to reach for).


Package layout

The module has a small public surface (three importable packages: client, server, log, plus the root team types) and a larger internal/ tree that consumers never import directly. Everything the internal/ code does surfaces only through the public API.

Package Import path Role
root github.com/reeflective/team Shared types: User, Version, and the Client interface.
client .../team/client Teamclient core (Client), the Dialer interface, Config, options.
client/commands .../team/client/commands Teamclient cobra tree (import, users, version) + reusable pre/post runners.
server .../team/server Teamserver core (Server), the Handler interface, config, jobs, users, options.
server/commands .../team/server/commands Teamserver cobra tree; nests the client tree under a client subcommand.
log .../team/log Public slog-based console logging (ConsoleHandler, formats, audit).
example/transports/grpc .../team/example/... Reference gRPC Handler + Dialer (copy it, don't depend on it).
example/transports/grpcslog .../team/example/... Same stack, showing a self-owned logging backend.
internal/* PKI (certs), database (db), filesystem (assets), systemd, version, command.

The two example binaries (example/teamserver, example/teamclient) are runnable, heavily commented reference programs that exercise every embedding shape — from the smallest in-memory server to a fully integrated one.


The core / transport split

The single most important structural fact: the core owns identity, certificates, the database, the filesystem and logging — but it does not own the transport. You plug a transport/RPC backend into the server (a Handler) and a matching one into the client (a Dialer). The same teamclient code then talks to an in-process or a remote server without changing.

flowchart TB
    subgraph public["public API (importable)"]
        C["client.Client<br/>+ Dialer iface"]
        S["server.Server<br/>+ Handler iface"]
        L["log.Logger / ConsoleHandler"]
    end
    subgraph internal["internal/ (not importable)"]
        CERTS["certs.Manager<br/>PKI · mTLS"]
        DB["db (gorm)<br/>users + certs"]
        FS["assets.FS<br/>(afero: disk or memory)"]
        VER["version"]
    end
    T["your transport<br/>(gRPC example, or your own)"]
    C -->|WithDialer| T
    S -->|WithHandler| T
    S --> CERTS --> DB
    S --> FS
    S --> L
    S --> VER
    C --> L
    C --> FS
    C --> VER
Loading
  • server.New(...) and client.New(...) build the cores; server.WithHandler(...) and client.WithDialer(...) register the transport.
  • A Server is a team.Client of itself: server.Self() hands back an in-memory teamclient that answers Users() / VersionServer() straight from the server, no transport involved.
  • A Handler embeds *server.Server (and a Dialer embeds *client.Client) so the transport code can reach Authenticate, UsersTLSConfig, the loggers, the filesystem, etc. See Writing a Transport → What the core gives your transport.

Where identity, PKI, database and logging live

Each concern is owned by exactly one place; the public methods on Server/Client are thin façades over the internal/ implementations.

Identity & authenticationserver/users.go. A user is a name plus two credentials: a 128-bit API token (SHA-256 hashed in the DB) and a client certificate. UserCreate, UserDelete, Users and the single Authenticate(token) (*team.User, error) primitive live here. A sync.Map of live tokens (Server.userTokens) is the online-session cache; it is wholesale-replaced on UserDelete so revocation is immediate. See Users & Authentication.

PKI / mutual TLSinternal/certs (certs.Manager, built in Server.init). It owns the users CA, mints client certificates, backs the whole CA up as PEM under CertificatesDir(), and produces the ready-to-run *tls.Config returned by Server.UsersTLSConfig(). This is the one subsystem allowed to abort the process (log.Fatal) if it cannot function — everything else returns errors.

Databaseinternal/db (gorm), reached via Server.Database(). The default is a pure-Go SQLite backend (a file under TeamDir(), or fully in-memory). Build tags select the SQLite engine (sql-go.go, sql-cgo.go, sql-wasm.go); the pure-Go and wasm builds also support transparent encryption-at-rest via the adiantum VFS (server.WithDatabaseKey). The schema is just users + certificates — no application data. You can substitute any gorm backend with WithDatabase / WithDatabaseConfig.

Filesysteminternal/assets (assets.FS, an afero wrapper), reached via Filesystem(). On-disk by default, fully in-memory under WithInMemory(). All of the core's config/log/db files go through it, which is what makes in-memory operation transparent.

Logginginternal/-free: the log package is public. Server/Client hold a *log.Logger (console + optional file) and expose NamedLogger(pkg, stream) and, on the server, an independent JSON AuditLogger(). See Logging.


Request / connection flow

Client connectclient.Connect(), guarded by a sync.Once (one connect per run):

  1. Resolve a Config — the one passed with WithConfig, or one loaded/selected from disk (SelectConfig may prompt if several exist). No dialer ⇒ Connect is a no-op, no error.
  2. dialer.Init(client) — transport-agnostic: read the config, build credentials/middleware.
  3. dialer.Dial() — transport-specific: open the connection.
  4. Users() / VersionServer() now route through the dialer's team.Client backend. VersionClient() never does — it is always computed locally from the binary.

Server serveServeAddr / ServeDaemon → the internal serve:

  1. init (once, initServe): open the database, load the config, build the certs.Manager.
  2. handler.Init(server) — the transport fetches credentials and builds middleware.
  3. handler.Listen(addr) — the transport binds and returns a net.Listener (must not block).
  4. The listener is wrapped in a job (addListenerJob) so ListenerClose(id) can stop it.
   teamclient                              teamserver
   ----------                              ----------
   Connect()                               ServeAddr(name, host, port)
     │                                        │  init()  ── DB + config + certs.Manager
     ├─ resolve Config (disk / WithConfig)    ├─ handler.Init(server)   (agnostic)
     ├─ dialer.Init(client)                   ├─ handler.Listen(addr)   (specific, non-blocking)
     ├─ dialer.Dial() ───────── mTLS ───────▶ └─ addListenerJob ── job (kill channel)
     │                                                │
     └─ Users()/VersionServer() ─ RPC ─▶ middleware ──┤
                                                       ├─ Authenticate(token) ─▶ *team.User
                                                       └─ app resolves user.Name ─▶ its own authz

Per-request authentication happens in your transport middleware, not the core: extract the bearer token, call server.Authenticate(token) to get a *team.User, then resolve user.Name against your own authorization model and inject your own identity onto the context. The core never sees permissions — see Users & Authentication → How it all fits in a transport.


Deferred initialization & concurrency

  • Nothing happens eagerly. New only resolves and creates the app home directory; all database, certificate, log-file and network work is deferred until first needed. The guards are sync.Once: initOpts (options that may only be set once), initServe (first serve), dbInit (a single DB per lifetime) on the server, and connect on the client.
  • Options are applied repeatedly. apply re-runs on each serve so per-listener options take effect, but the initOpts block inside it runs only once. Handlers registered via WithHandler accumulate into a map keyed by Name(); the first registered becomes the default (self).
  • Listeners are jobs. They live in a sync.Map; each carries a kill channel closed by ListenerClose. Closing a listener stops its accept loop but not in-flight connections.
  • Revocation is instant. The user-token cache is a sync.Map, cleared on UserDelete, so a deleted user is refused on its very next request rather than only at reconnect.
  • Failure is safe and early. Every error the API returns is logged before it is returned; functions fail as early as they can. The only path allowed to abort the process is the certificate infrastructure, for security reasons. Every method is non-blocking except ServeDaemon, which blocks until SIGTERM.

Related: Server API → · Client API → · Writing a Transport → · Testing & In-Memory Use →

Clone this wiki locally