better/compute
VPS and sandboxes

Networking

How VPS instances get addresses today, from one IPv4 range per hosted pool to what runtimes and providers report, and what is not built yet.

A VM is not much use until you know its address. Better Compute keeps networking deliberately small: an address plan per hosted pool, and whatever the runtime or the provider reports. Software-defined networking beyond bridges, VLANs and IPAM is a non-goal for v1, as are volumes and live migration.

Where an instance's addresses come from depends on its pool:

PoolAddresses
Hosted (vps-host)One IPv4 lease from the pool's range, taken at placement, plus any addresses the host's runtime reports.
Hosted, no range configuredOnly what the runtime reports.
Provider (provider)What the provider reports for the machine, replaced on every poll.

Give a hosted pool a range

Pool IPAM is one IPv4 range per hosted pool. You configure it with setIpPool, a function exported by @better-compute/vps. There is no API method, CLI command or FleetConfig field for it in this release, so run it from your own setup code against the compute database:

setup-ipam.ts
import { setIpPool } from '@better-compute/vps';

const range = await compute.context.sql.transaction((tx) =>
  setIpPool(tx, { poolId: 'pool_01jz…', cidr: '10.0.0.0/24', gateway: '10.0.0.1' }),
);
// → { poolId: 'pool_01jz…', cidr: '10.0.0.0/24', gateway: '10.0.0.1',
//     firstHost: '10.0.0.2', lastHost: '10.0.0.254' }

Prop

Type

firstHost and lastHost must both lie inside cidr, with firstHost not after lastHost. Anything else, including a malformed address, is INVALID_INPUT naming the pool. Because the default firstHost is the address after the gateway, set it explicitly when the gateway sits at the end of the network (10.0.0.254 in a /24).

Calling setIpPool again for the same pool replaces the range. Existing leases are kept as they are, even if they now fall outside it.

How leases work

A hosted instance takes its lease in the same transaction that places it, right after capacity claims its resources on a host (see instances):

  • The lease is the lowest free address from firstHost to lastHost, skipping the gateway.
  • An instance holds at most one lease. Placing an instance that already holds one returns the same address.
  • (pool, address) is the primary key of ip_leases, so two instances can never hold the same address. Concurrent placements that race for one address retry, up to 8 times, on the next free one.
  • When the range is exhausted, placement fails with CAPACITY_UNAVAILABLE ("Pool … has no free IP address"). In instances.create the whole transaction rolls back, host claims included, and the caller gets the error. When the reconciler places an instance later (after a host loss), the instance moves to error with an Unschedulable condition.
  • A pool without a range leases nothing, and placement succeeds without an address.

The lease is returned when the instance is deleted. After a host loss with recreateOnHostLoss, the instance returns its lease with its allocation and takes a new one, again the lowest free address, when it is placed on another host.

For example, with cidr: '10.0.0.0/29' and gateway: '10.0.0.1', the range is 10.0.0.2 to 10.0.0.6: five instances get .2 to .6 in order, and a sixth create fails with CAPACITY_UNAVAILABLE until one of them is deleted.

Operators can see the plan and the leases in the database:

SELECT cidr, host(gateway) AS gateway, host(first_host) AS first, host(last_host) AS last
  FROM ip_pools WHERE pool_id = $1;

SELECT host(address) AS address, instance_id
  FROM ip_leases WHERE pool_id = $1 ORDER BY address;

The v0.3 acceptance run (tests/vps-churn.test.ts) checks that after 200 instances churn on 3 hosts there is exactly one lease per placed instance, none leaked and none shared.

Addresses on an instance

Instance.addresses starts with the lease, if any. After that:

  • Hosted pools. Addresses in the host's StatusReport.instances (from the guest agent or DHCP leases, CIDR form on the wire) are added to the list. Reports only add; the lease stays first.
  • Provider pools. Each poll replaces the list with the machine's addresses. Hetzner Cloud reports the public IPv4 address, the public IPv6 network as Hetzner gives it (a /64), and private network addresses. Proxmox VE reports the guest agent's addresses while the VM runs, without loopback and link-local ones, and nothing without an agent. Proxmox clones are configured with ipconfig0=ip=dhcp, so the guest takes its address from your cluster network's DHCP.

What reaches the host

A hosted instance's network reaches its host in InstanceSpec.nics, in the instances section of the . vps sends one NIC per instance:

FieldValue
networkThe host bridge: br0, or vps({ hostNetwork })
macA stable locally administered MAC: 02 followed by the first 10 hex digits of the SHA-256 of the instance id
addressesThe IPAM lease in CIDR form (the address with the range's prefix length), or none without a lease
gatewayThe range's gateway, for a NIC with a lease (387c22f)
nameserversThe range's dns, for a NIC with a lease, when it has any (387c22f)

The guest's network configuration. Since 0791fc9 bcd writes a cloud-init network-config (version 2) into the NoCloud seed of libvirt-kvm and cloud-hypervisor guests when a NIC has static configuration: each such NIC matched by its MAC, its addresses static, a default route (0.0.0.0/0, or ::/0 for an IPv6 gateway) through its gateway, and its nameservers. A NIC without addresses keeps DHCP, and with no static NIC at all bcd writes no network-config, so the image's own fallback (DHCP on the first NIC) stays. bcd parses every value and quotes it again, so a malformed lease fails the seed rather than adding YAML to it. A bcx runtime plugin gets each NIC's gateway and nameservers in its spec.

The NodeSpec encoder keeps NIC order, since it is the guest's interface order. The spec also carries the instance's allocationId, which the host names when it fetches the user data. The FakeDaemon's fake hypervisor creates a tap per NIC. On a host build with virsh, bcd's libvirt-kvm runtime (362317c) attaches each NIC as a virtio device on the named bridge with the spec's MAC, on a tap named runtime.TapName, so bcd's nftables rules for the NIC exist before the tap does. bcd's cloud-hypervisor runtime (2598f0a) creates a persistent tap of the same name (ip tuntap add), puts it on the named bridge and brings it up before the VMM starts, then gives it to the VM with the spec's MAC (933c51b). A guest reboot reopens that same tap, a tap that cannot be attached means the VM does not start, a NIC without a network is refused, and the taps are deleted when the VM stops or is destroyed. Both have been tested against fake tools only, not on a real host. A VPS profile can already declare network: { mode: 'bridge', ipam: 'pool' } (see steps and profiles), but vps() does not read profiles.

bcd's nftables tables

Every instance tap is named bc<10 hex digits>n<nic> (runtime.TapName), and bcd owns three nftables tables, which it replaces together in one transaction before any domain starts:

TableHolds
bridge bcd_instancesThe per-tap rules: anti-spoofing (the NIC's MAC and addresses), the private and none network policies, sandboxes' SSH-from-the-host-only rule, and the drops every tap gets (DHCP server replies, router advertisements and redirects, the host's own addresses). See sandbox networks
inet bcd_tapsA backstop (933c51b): iifname "bc*" drop on the input and forward hooks, and oifname "bc*" drop on forward and (since b12c217) output. Frames of a tap that is on its bridge reach these hooks as the bridge's, so only a tap off its bridge, which the bridge table cannot see, is dropped here. With no instance NIC on the host the table is deleted and not loaded again
arp bcd_tapsThe same backstop for ARP, which the inet hooks never see (b12c217): iifname "bc*" drop on input and oifname "bc*" drop on output. Present exactly when inet bcd_taps is

A NIC on a bridge whose name starts with bc would match every tap rule, so it fails its instance. bcd's 5-second check counts the rules of all three tables against what it loaded, and under privilege separation the root helper accepts a ruleset that names these tables and no other.

What is not there at all: IPv6 ranges, more than one range or lease per instance, and an API to manage ranges. Tunnels give you exec, SSH and single-port TCP access without routing to the instance network; see tunnels.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page