Lattice builds a private network out of machines you already have. Each node generates its own WireGuard key, enrolls with a control plane, receives an address from a shared pool, and is told exactly which other nodes it may peer with. The peer list is derived from a policy you write, so a contractor's laptop never learns the database's public key and cannot send it a packet, whatever the firewall on the far side says.
Everything is a single Go binary: lattice for the control plane, lattice-agent on each machine, and
latticectl to drive both. The data plane is the kernel's own WireGuard implementation.
flowchart LR
subgraph plane[Control plane]
direction TB
registry[(Nodes, keys<br/>and addresses)]
policy[Policy engine]
api[Enrollment and<br/>configuration API]
dash[Dashboard, API<br/>and latticectl]
end
subgraph node1[Node: laptop]
agent1[lattice-agent]
wg1[(wg device)]
end
subgraph node2[Node: db-1]
agent2[lattice-agent]
wg2[(wg device)]
end
agent1 -->|enrol, poll, heartbeat| api
agent2 -->|enrol, poll, heartbeat| api
api --> registry
api --> policy
agent1 --> wg1
agent2 --> wg2
wg1 <-->|encrypted, only if the policy allows| wg2
dash --> registry
The control plane never sees a private key: agents generate their own and send only the public half. It never carries traffic either, so if it goes down existing tunnels keep working, they simply stop learning about changes.
Start the control plane:
git clone https://github.com/cansarihan/lattice.git
cd lattice
make build
./bin/lattice -config configs/lattice.yaml -check
./bin/lattice -config configs/lattice.yamlIssue an enrollment key, tagged with the role the machines will take:
$ latticectl keys create -owner svc -tags database -reusable -lifetime 2h
enrollment key created 4f7c1a2b9d0e3c88
token 6gnh87UAqvJ2mYc0LZ1pRt4X
this token is shown once, store it nowRun the agent on the machine, which needs wireguard-tools, iproute2 and NET_ADMIN:
sudo lattice-agent -control-plane https://lattice.example.com -key 6gnh87UAqvJ2mYc0LZ1pRt4X -name db-1The agent generates a key pair, enrolls, receives 100.80.0.6, brings up a lattice0 device and starts
reporting. Without root, -dry-run prints the WireGuard configuration it would have applied instead.
The whole thing on one machine, with seven nodes and real tunnels between containers:
make demo # dashboard on http://127.0.0.1:18080
make demo-downThe policy is a small document of groups, tags and rules. Rules are evaluated from the bottom up, and the first one that matches decides.
groups:
group:platform: [can, deniz]
group:contractors: [external]
rules:
- name: platform team reaches everything
action: accept
src: [group:platform]
dst: ["*"]
- name: servers talk to the database
action: accept
src: [tag:app]
dst: [tag:database]
ports: ["5432"]
protocol: tcp
- name: contractors reach the build machines only
action: accept
src: [group:contractors]
dst: [tag:ci]
ports: ["22", "8080-8090"]
protocol: tcpSelectors are *, group:name for people, tag:name for machine roles, and node:name for one specific
machine. Ask the control plane what a rule means before you rely on it:
$ latticectl check app-1 db-1 5432 tcp
app-1 to db-1:5432/tcp is allowed by servers talk to the database
$ latticectl check contractor-1 db-1
contractor-1 to db-1 is denied by no rule matchedA denied pair is not filtered, it is absent. Neither node is given the other's public key or endpoint, so there is no tunnel to send a packet down.
- Generates a Curve25519 key pair on first run and stores the private half locally, mode 0600.
- Enrolls with the token, reporting its name, public key, endpoint and any subnets it wants to advertise.
- Polls for its configuration, sending the version it already has. The control plane answers
304 Not Modifiedwhen nothing changed. - Applies changes with
wg syncconf, which updates peers without tearing down live tunnels, then sets the address and any approved subnet routes. - Reports what the kernel sees: per peer handshake age and transfer counters, which is what the dashboard
and
latticectl nodedisplay.
A restarted agent re-applies its configuration rather than trusting the version it cached, because the device may have disappeared with the process.
Addresses. The control plane owns one pool, 100.80.0.0/16 by default. Allocation is deterministic and
lowest first, the network and broadcast addresses are skipped, and removing a node returns its address to the
pool. Leases are part of the persisted state, so a restart does not renumber the mesh.
Subnet routes. A node can advertise a subnet it can reach; the control plane will not distribute it until an operator approves it, and approval is rejected for a route the node never advertised.
latticectl routes app-1 192.168.7.0/24Key rotation and revocation. POST /api/v1/rotate replaces a node's public key without re-enrolling it.
latticectl revoke <node> drops the node from every peer list on the next poll and refuses its credential.
Node registrations expire after mesh.node_expiry, and enrollment keys carry their own expiry, reuse flag and
use limit.
The dashboard draws the mesh the policy produces: each chord is a peer relationship, solid where the two nodes are actually handshaking and dashed where they have not exchanged traffic yet. Selecting a node isolates it and shows its live links.
The same state is available to a terminal, along with a policy check and the raw WireGuard view of any node:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/enroll |
Exchange an enrollment token for an address, credential and configuration |
GET |
/api/v1/config |
Fetch the peer list, with ETag and If-None-Match |
POST |
/api/v1/heartbeat |
Report endpoints, advertised routes and per peer handshakes |
POST |
/api/v1/rotate |
Replace this node's public key |
GET |
/admin/v1/status |
Mesh counters, address usage, policy summary |
GET |
/admin/v1/nodes |
Every node with tags, peers and live links |
GET |
/admin/v1/graph |
Nodes and edges as the policy computes them |
POST |
/admin/v1/policy/check |
Ask whether one node may reach another |
POST |
/admin/v1/policy/reload |
Re-read the policy file |
POST |
/admin/v1/keys |
Create an enrollment key |
POST |
/admin/v1/nodes/{id}/tags, /routes, /revoke |
Change a node |
GET |
/metrics |
Prometheus exposition |
Node endpoints authenticate with the credential issued at enrollment; admin endpoints use server.admin_token
when it is set.
The demo stack is not a mock. Seven agents in containers bring up kernel WireGuard devices and handshake with each other, and the policy decides which pairs can:
$ docker compose exec laptop wg show lattice0
interface: lattice0
public key: OFRjFMezWnYy9Wcv0XQLFsB3ouCcH/hX6g28WBJlhVg=
listening port: 51820
peer: kF17u9QDprXTd1uQzus6/DNfGkO81FEsEwjH732P+is=
endpoint: 172.30.0.26:51820
allowed ips: 100.80.0.7/32
latest handshake: 18 seconds ago
transfer: 1.42 KiB received, 1.35 KiB sent
$ docker compose exec app-1 ping -c 2 100.80.0.6 # tag:app to tag:database
2 packets transmitted, 2 received, 0% packet loss
rtt min/avg/max/mdev = 0.336/0.339/0.342/0.003 ms
$ docker compose exec contractor-1 ping -c 2 100.80.0.6 # no rule allows this
2 packets transmitted, 0 received, +2 errors, 100% packet lossThe key derivation is checked against the reference implementation: the test suite carries a private key and
the public key that wg pubkey produces from it, and fails if Lattice derives anything else.
server:
listen: "0.0.0.0:8080"
state_file: /var/lib/lattice/state.json
admin_token: "${LATTICE_ADMIN_TOKEN}"
tls:
enabled: true
cert_file: /etc/lattice/tls/server.crt
key_file: /etc/lattice/tls/server.key
mesh:
prefix: 100.80.0.0/16
listen_port: 51820
keepalive: 25s
mtu: 1380
node_expiry: 4320h
online_window: 2m
policy:
file: policy.yamlState is one JSON file written atomically through a temporary file and a rename, so an interrupted write
cannot leave a half written mesh behind. SIGHUP and POST /admin/v1/policy/reload re-read the policy
without dropping tunnels. The container image runs as an unprivileged user; agents need NET_ADMIN and the
wireguard kernel module, or a userspace implementation on the host.
make test # unit and integration tests
make race # the whole suite under the race detector
make cover # coverage summary
make lint # golangci-lint
make demo # seven nodes with real tunnelsThe suite covers key generation against the reference vector, address allocation and reuse, policy evaluation
including rule precedence and port ranges, enrollment with reusable, limited and expiring keys, credential
authentication with revocation and expiry, peer graph construction, the difference between a wg setconf
file and a wg-quick file, the exact command sequence the agent runs to bring an interface up, and both HTTP
surfaces end to end.
docs/architecture.md covers the enrollment and configuration protocol, the state model, and the trust boundaries in more detail.
- Direct connection establishment through NAT, with a relay for the pairs that cannot
- Exit nodes, so a whole machine's traffic can leave through a chosen peer
- Short lived node credentials issued from an identity provider
- An audit trail for every policy and membership change
- IPv6 mesh addressing alongside the IPv4 pool
MIT. Copyright (c) 2026 Can Sarıhan.

