Data plane¶
The data plane is microVM-first. Every host workload — tenant or
infra — is a weft microvm instance unless the workload genuinely
needs a classic VM (Windows / BSD guest, network appliance distributed
as a VM image, custom kernel). The classic-VM path (weft instance)
shares the same drivers, scheduler, flavors, volumes and shares ; only
the boot model differs.
WireGuard mesh¶
Every microVM peers with every other microVM across the WAN via WireGuard.
Tenant workloads, infrastructure microVMs, and storage nodes share one
flat address space regardless of which AZ they land in. weft-network
owns the desired state ; weft-microvm-agent inside each guest applies it
via netlink.
L4 / L7 — Caddy in weft-agent¶
The L4/L7 data plane is Go-native by default. L7 (HTTP) uses Caddy (Apache 2.0, pure-Go, ACME-driven auto-HTTPS) ; L4 (TCP/UDP) uses the same Caddy through the caddy-l4 plugin — one binary, no separate Envoy microVM.
weft-agent embeds a supervised Caddy subprocess on every host. The
proxy lives at weft/agent/proxy/. Why a subprocess rather than
importing Caddy as a library :
- Vendor weight. caddy/v2 pulls ~30 transitive modules (certmagic, acmez, libdns, quic-go, …) ; weft's vendor tree would roughly double.
- Crash isolation. A panic in Caddy's TLS handshake or ACME challenge shouldn't take down weft-agent (which owns the mesh, microVM lifecycle, and host registration).
- Operational consistency. weft-agent already supervises subprocesses
(
weft-driver-vz/-qemuvia go-plugin).
weft-network is the
sibling control-plane daemon that owns the LoadBalancer / Router /
DNS / SchedulingRule catalogues. It exposes the
NetworkControlPlane
gRPC service ; weft-agent dials it to fetch desired state, watches
etcd events for deltas, and POSTs JSON to each Caddy's admin socket
(POST /load on a unix socket owned by weft-agent). Sub-second
config apply ; ACME owned entirely by Caddy. weft-network exposes
/metrics on a separate port (default :9100) for Prometheus
scraping ; build_info + RPC counters + latency histograms +
etcd-connected gauge.
Cert sharing¶
By default each host's Caddy mints its own certs against its filesystem
storage. For multi-host clusters, set WEFT_PROXY_STORAGE_ETCD_ENDPOINTS
on the weft-agent ; Caddy then uses the caddy-storage-etcd adapter
to share issued certs across the cluster, avoiding per-host ACME bursts.
Routers and egress¶
Routers and load balancers are first-class resources : declare them in HCL or via the dashboard, the data plane reconciles. The default implementations are all Go-native.
- L7 / L4 — Caddy + caddy-l4 in
weft-agent(above). - Mesh peering — WireGuard in-kernel, between cluster overlays.
- BGP egress — when a tenant needs a public ASN, the platform
schedules a
weft-routermicroVM running GoBGP (Apache 2.0, pure-Go, BGP-4 + EVPN + flowspec — the same engine Calico and Cilium ship). It programs the kernel FIB via netlink, boots in a few hundred milliseconds, footprint ~tens of MB. - NAT egress — for tenants without an ASN, plain netfilter / nft
pushed by
weft-networkdirectly onto the hosts. No VM at all.
Router orchestration pipeline¶
weft-router micro-VMs and their controller in weft-network talk
over four NATS subjects plus a small lifecycle seam.
End-to-end shape for one egress Router :
operator upstream
│ │
▼ ▼
weft-network.CreateRouter (backend=gobgp) BGP peer
│ ▲
├── store.Create (etcd / memory ; Router resource is now durable) │
├── publisher ──── weft.router.<uuid>.config ──► subscriber ─────┤
│ (peers + prefixes) (ApplyPeers │
│ + ApplyPaths) │
└── lifecycle.Ensure ──[orchestrator]──► weft-router micro-VM │
(spawn from │
ghcr.io/openweft/weft-router:vX.Y) │
│
◄── weft.router.<uuid>.status ──── emitter ────┘
store.UpdateStatus (peer state / route count)
via statusreceiver, surfaced on the dashboard
Components, by package :
weft-network/internal/publisher— pushes the desired state (peers + prefixes) onweft.router.<uuid>.configwhenever the Router resource is created or its config changes. Idempotent ; a reboot ofweft-networktriggers aResyncRouterssweep that re-publishes for every router in the store.weft-router/internal/subscriber— listens on the matching subject, decodes, callsbgp.Server.ApplyPeersandbgp.Server.ApplyPrefixeson its in-process GoBGP instance.weft-router/internal/statusemitter— every--status-interval(10 s default), polls GoBGP for live peer states and route counts and publishesRouterStatusonweft.router.<uuid>.status.weft-network/internal/statusreceiver— wildcard-subscribes toweft.router.*.status, decodes, callsstore.UpdateStatuswith a rolled-upStatus("active" / "configuring" / "down") and a printablePeerState. The dashboard reads from the same store.weft-network/internal/lifecycle— the seam that asks "an orchestrator" to ensure / destroy the matching micro-VM. The defaultNoopimplementation just logs the intent ; operators hand-spawnweft microvm run ghcr.io/openweft/weft-router:<tag>while the realWeftClientimplementation matures. When wired, it'll go through the sameweft API → weft-agentpath everyone else uses to schedule micro-VMs.
Every leg is idempotent and re-runs on weft-network restart, so a transient outage on any subject self-heals on the next reconcile.
Escape hatch — VyOS / OPNsense / FRR¶
When a tenant needs a complex multi-protocol setup (OSPF / IS-IS /
RSVP-TE) or wants to bring their own router config, run it as a
classic VM via weft instance — same deal as for Windows guests and
other VM-image appliances. This is the only path on which VyOS /
OPNsense / FRR are deployed by the platform ; the Go-native router /
LB stack covers everything else.
Stateful firewall — per-VM nftables¶
Security Groups created via the weft control plane (Network /
SecurityGroup / Port RPCs) are enforced inside each micro-VM by
weft-microvm-agent's
firewall subscriber, which converges a kernel nftables table
named weft-fw against the per-VM effective ruleset on every
desired-state push.
+---------------------+ +-------------------+ +------------------+
| weft control plane | NATS | weft-microvm- | nft | Linux kernel |
| (SG, Network, | ────► | agent (in-VM) | ───► | table inet |
| Port registries) | | firewall sub | | weft-fw |
+---------┬-----------+ +---------┬---------+ +--------┬---------+
│ │ │
│ EffectiveFirewall │ every 10 s : │
│ (vmUUID) → │ ReadFirewallStatus │
│ pod.Firewall │ (kernel netlink) │
│ ▼ │
▼ pod.FirewallStatus │
weft.firewall.<vm-uuid> {Overall, RulesInstalled, ...} │
(desired ruleset) │
weft.firewall.<vm-uuid>.status │
─────────────────────────────────── │
↑ │
└───────────────┘
(status subject — UI
+ control-plane consume)
The host-side publisher
(weft/firewallpub)
reacts to the existing event bus :
security_group.{rules_updated,deleted,…} republishes every port
that references the SG (directly or via the network's defaults) ;
port.{created,security_groups_updated,deleted} republishes that
port's VM ; network.default_security_groups_updated republishes
every port that inherits ; vm.created seeds the new VM with an
initial state. Every publish is whole-state ; a missed message
self-heals on the next one.
The reconciler uses google/nftables
(pure-Go netlink, no iptables/nft fork-exec), and runs only on
Linux ; the darwin host build falls back to a no-op stub so the
agent stays cross-platform for dev. Default chain policy is
input → drop (with ct state established,related accept and
iifname "lo" accept always at the top of the chain so reply
traffic and loopback work without a mirrored rule) and
output → accept ; tenants opt into ingress allow-rules through
Security Groups.
A reverse-direction status emitter inside the same agent polls the
kernel table every 10 s (--firewall-status-every) and publishes
pod.FirewallStatus{Overall, TableInstalled, RulesInstalled,
LastError, PublishedAtUnix} on weft.firewall.<vm-uuid>.status,
so the UI can show per-VM enforcement health without a per-VM
gRPC fanout.
Floating IPs — host-side nftables NAT¶
Public-routable addresses ("floating IPs") allocated from an edge network can be bound to a private VM and back, the OpenStack- familiar way :
weft floating-ip allocate --project p --network public
→ 203.0.113.42 (status: available)
weft floating-ip map 203.0.113.42 --vm web-1
→ 203.0.113.42 → vm "web-1" (status: active)
Control-plane lives in weft :
the floating_ips registry
walks the chosen network's CIDR for the next free address (skipping
the network/broadcast addresses, every port-occupied IP, and the
network's reserved gateway), enforces the lifecycle invariants
(Allocate → Map ⇄ Unmap → Release, idempotent on same-target
Map, refuses Release on an active FIP), and publishes
floating_ip.{allocated,released,mapped,unmapped} events on the
existing bus.
Data plane lives in
weft/floatingipnat :
a per-host Watcher subscribes to those events, recomputes the
local VM → FIP mappings on every relevant kind, and calls the
nftables Reconciler.Apply :
table ip weft-fip-nat {
chain prerouting { type nat hook prerouting priority dstnat ;
ip daddr <public-ip> dnat to <private-ip> # per active mapping
}
chain postrouting { type nat hook postrouting priority srcnat ;
ip saddr <private-ip> snat to <public-ip> # per active mapping
}
}
Same google/nftables (pure-Go netlink) backend the firewall
reconciler uses, replace-set per netlink batch so an external
observer never sees a half-applied policy, IPv4-only for now.
Each rule carries a nft -a list ruleset-readable comment with
the VM name so an operator can map a rule to its tenant intent
at a glance.
The Watcher's ComputeLocalMappings is pure (no IO) : it walks
every FIP in the registry, drops the ones not bound to a local
VM, joins on the VM's first port that has an IP. Multi-NIC VMs
get their lowest-UUID port deterministically picked in v0 ; a
future revision will let MapFloatingIP carry an explicit port
UUID so the operator targets a specific NIC.
BGP-announced /32 prefixes (Internet-routable)¶
Host-side NAT alone makes a FIP reachable on the LAN ; for traffic from the public Internet to actually arrive on the openweft host, the upstream router/ISP needs a route. For tenants with their own public ASN, openweft closes that loop via the existing weft-router microVM :
weft (control plane) ─ "floating_ip.{allocated,mapped,unmapped,released}"
▼ NATS "weft.events.floating_ip.>"
weft-network ── fips.Subscriber → fips.Index
▼ ActiveFIPsInNetworks(r.Networks)
weft-network.publisher.StateFor ─ DesiredState.Prefixes += <addr>/32
▼ NATS "weft.router.<uuid>.config"
weft-router ── bgp.ApplyPrefixes → GoBGP AddPath → upstream ISP
Implementation lives in
weft-network/internal/fips/
(NATS subscriber + thread-safe per-network index) +
weft-network/internal/publisher
(FIPLookup interface, StateFor appends /32 v4 + /128 v6 single-
host prefixes to the operator-typed announce list). Every relevant
FIP mutation triggers a re-Publish of every router stitching the
affected network, so weft-router picks up the new announce set
within one publish round-trip. weft-router itself needed zero
changes — GoBGP accepts /32 + /128 via the same bgp.ApplyPrefixes
path that's been live since v0.1.
Without --nats (single-host dev), the BGP layer is skipped and
FIPs stay host-NAT only — valid for development, just not
externally reachable.
Block volumes — Longhorn default¶
The default backend for weft volume create --type block is
Longhorn (CNCF graduated,
Apache 2.0) — replicated block storage with snapshots and backups
spread across the host pool, so losing a host doesn't lose the
volume. Two escape hatches stay available for specialised workloads :
--source /dev/nvmeXn1 passes a host device straight through (raw
bandwidth, no replication), and --type file is a host-side image.
All three surface as virtio-blk via the weft Volume driver. See
Storage for the symmetric pattern on shares (CubeFS).