Skip to content

Repository files navigation

Lattice

CI CodeQL Go report card Go 1.25 MIT

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.

The three pieces

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
Loading

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.

Bring up a mesh

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

Issue 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 now

Run 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-1

The 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-down

Deciding who may reach whom

The 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: tcp

Selectors 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 matched

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

What the agent does on a node

  1. Generates a Curve25519 key pair on first run and stores the private half locally, mode 0600.
  2. Enrolls with the token, reporting its name, public key, endpoint and any subnets it wants to advertise.
  3. Polls for its configuration, sending the version it already has. The control plane answers 304 Not Modified when nothing changed.
  4. Applies changes with wg syncconf, which updates peers without tearing down live tunnels, then sets the address and any approved subnet routes.
  5. Reports what the kernel sees: per peer handshake age and transfer counters, which is what the dashboard and latticectl node display.

A restarted agent re-applies its configuration rather than trusting the version it cached, because the device may have disappeared with the process.

Addresses, routes and keys

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

Key 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 control plane

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.

Lattice control plane

The same state is available to a terminal, along with a policy check and the raw WireGuard view of any node:

latticectl

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.

Verified end to end

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 loss

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

Running it

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

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

Building and testing

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 tunnels

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

What is next

  • 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

License

MIT. Copyright (c) 2026 Can Sarıhan.

About

WireGuard mesh control plane: policy driven peer lists, address management and a live topology dashboard

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages