-
Notifications
You must be signed in to change notification settings - Fork 2
Architecture
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 core / transport split
- Where identity, PKI, database and logging live
- Request / connection flow
- Deferred initialization & concurrency
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 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
-
server.New(...)andclient.New(...)build the cores;server.WithHandler(...)andclient.WithDialer(...)register the transport. - A
Serveris ateam.Clientof itself:server.Self()hands back an in-memory teamclient that answersUsers()/VersionServer()straight from the server, no transport involved. - A
Handlerembeds*server.Server(and aDialerembeds*client.Client) so the transport code can reachAuthenticate,UsersTLSConfig, the loggers, the filesystem, etc. See Writing a Transport → What the core gives your transport.
Each concern is owned by exactly one place; the public methods on Server/Client are thin
façades over the internal/ implementations.
Identity & authentication — server/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 TLS — internal/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.
Database — internal/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.
Filesystem — internal/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.
Logging — internal/-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.
Client connect — client.Connect(), guarded by a sync.Once (one connect per run):
- Resolve a
Config— the one passed withWithConfig, or one loaded/selected from disk (SelectConfigmay prompt if several exist). No dialer ⇒Connectis a no-op, no error. -
dialer.Init(client)— transport-agnostic: read the config, build credentials/middleware. -
dialer.Dial()— transport-specific: open the connection. -
Users()/VersionServer()now route through the dialer'steam.Clientbackend.VersionClient()never does — it is always computed locally from the binary.
Server serve — ServeAddr / ServeDaemon → the internal serve:
-
init(once,initServe): open the database, load the config, build thecerts.Manager. -
handler.Init(server)— the transport fetches credentials and builds middleware. -
handler.Listen(addr)— the transport binds and returns anet.Listener(must not block). - The listener is wrapped in a job (
addListenerJob) soListenerClose(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.
-
Nothing happens eagerly.
Newonly resolves and creates the app home directory; all database, certificate, log-file and network work is deferred until first needed. The guards aresync.Once:initOpts(options that may only be set once),initServe(first serve),dbInit(a single DB per lifetime) on the server, andconnecton the client. -
Options are applied repeatedly.
applyre-runs on each serve so per-listener options take effect, but theinitOptsblock inside it runs only once. Handlers registered viaWithHandleraccumulate into a map keyed byName(); the first registered becomes the default (self). -
Listeners are jobs. They live in a
sync.Map; each carries akillchannel closed byListenerClose. Closing a listener stops its accept loop but not in-flight connections. -
Revocation is instant. The user-token cache is a
sync.Map, cleared onUserDelete, 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 →