A Go daemon that turns a BananaPi R1 (Lamobo R1) into a home router, designed to run on gokrazy.
- VLAN support — Per-port network isolation using separate bridges (one bridge per VLAN)
- DHCP server — Per-VLAN DHCP servers, each with its own address range
- NAT/masquerade — nftables-based masquerade for outbound traffic via
wan - Inter-VLAN isolation — VLANs marked
isolatedare firewalled from all other VLANs (internet-only) - WiFi access point — Runs the onboard RTL8192CU as an AP via a bundled
hostapdbinary, with automatic restart on crash (exponential backoff) - WiFi + LAN shared subnet — WiFi and a LAN port can share a subnet (split into two /25 ranges), with the router forwarding between them
- Per-client traffic monitoring — nftables counters with live throughput rates, session and historical counters, exposed via an HTTP status API on the admin listener (loopback
127.0.0.1:8080by default) - PXE/TFTP boot server — Serve boot images to PXE clients with a single TFTP server. DHCP automatically injects options 66/67 and selects the boot file by client type: legacy BIOS, UEFI, or iPXE (via DHCP option 77). Serve kernels, initrds, and iPXE binaries/scripts from a configurable TFTP root.
- Port speed detection — Reads negotiated link speed and duplex from sysfs
- Status CLI —
router-cliqueries the API and prints port/client tables - Disk mount — Mount a block device via environment variables for use by SMB/PXE/extras config
- SMB server — Share the mounted disk via an externally bundled
smbd, with user/password from environment variables - PXE/TFTP server — Serve boot images to PXE clients, with per-MAC image selection and a default fallback
- Static DHCP reservations — Pin a MAC address to a fixed IP address
- Runtime extras config — Place an editable TOML file on the mounted disk; changes take effect on the next gokrazy restart
BananaPi R1 with a Broadcom BCM53125 5-port Gigabit switch. The Linux kernel's b53 DSA driver exposes each port as a separate interface (wan, lan1–lan4). The onboard WiFi is a Realtek RTL8192CU (USB, soldered on-board).
┌─────────────────────────────────────┐
Internet ──┤ wan (DHCP) │
│ │
│ VLAN 1 (trusted) │
│ lan1 ─── br-vlan1 10.0.1.1/24 │
│ │
│ VLAN 20 (iot, isolated) │
│ lan2 ─── br-vlan20 10.0.20.1/24 │
│ │
│ VLAN 30 (open) │
│ lan3 ─── br-vlan30 10.0.30.1/24 │
│ │
│ VLAN 31 (shared with WiFi) │
│ lan4 ─── br-vlan31 10.0.31.129/25 │
│ wlan0 ──────────── 10.0.31.1/25 │
└─────────────────────────────────────┘
- lan4 and WiFi share the 10.0.31.0/24 range, split into two /25 subnets. WiFi clients get 10.0.31.100-126, lan4 clients get 10.0.31.150-250. The router forwards between them.
- VLAN 20 is isolated — devices on lan2 can reach the internet but cannot communicate with any other VLAN.
- No 802.1Q tags on the wire — each port is on its own bridge. The VLAN numbering is logical, used for bridge naming and isolation rules. The BCM53125 DSA driver does not support the Linux bridge VLAN filtering API.
All settings are driven by a JSON file (default /etc/gokrazy-router.json). If no config is provided, a flat bridge mode with sensible defaults is used.
The router uses up to four configuration files:
| File | Format | Location on router | Purpose |
|---|---|---|---|
| Router config | JSON | /etc/gokrazy-router.json |
Main config: WAN, LAN, VLANs, NAT, WiFi, PXE |
| Runtime extras | TOML | Path set in services.extrasFile (e.g. /mnt/data/router-extras.toml) |
PXE boot files, per-MAC images, reservations, SMB users |
| MAC map | TOML | Path set in wifi.macMapFile (e.g. /etc/gokrazy-router-macmap.toml) |
MAC-to-VLAN assignment, static IP reservations, per-client PXE images |
| Boot artifacts | Directory | Path set in services.pxe.tftpRoot (e.g. /mnt/data/tftpboot) |
Kernels, initrds, iPXE binaries/scripts, pxelinux configs |
On a gokrazy device, the JSON config and MAC map are deployed via ExtraFileContents in the gokrazy instance config. Boot artifacts (kernels, initrds, iPXE binaries, scripts) are placed on persistent storage mounted at /data/ or another path configured in services.pxe.tftpRoot.
Example files are provided in the netboot/ directory:
gokrazy-router.json— Full router config with PXE enabledrouter-extras.toml— Runtime PXE/reservation configuration for the mounted diskmacmap.toml— MAC-to-VLAN mapping with static IPsboot.ipxe— Example iPXE boot scriptnetboot.xyz.ipxe— netboot.xyz iPXE chain scriptpxelinux.cfg.default— Example pxelinux config for legacy PXE
{
"wan": {"interface": "wan", "mode": "dhcp"},
"lan": {"bridge": "br-lan", "interfaces": [], "address": "10.0.0.1/24", "dhcp": {"enabled": false}},
"vlans": [
{
"id": 1, "name": "trusted", "ports": ["lan1"],
"address": "10.0.1.1/24",
"dhcp": {"enabled": true, "rangeStart": "10.0.1.100", "rangeEnd": "10.0.1.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"nat": true
},
{
"id": 20, "name": "iot", "ports": ["lan2"],
"address": "10.0.20.1/24",
"dhcp": {"enabled": true, "rangeStart": "10.0.20.100", "rangeEnd": "10.0.20.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"nat": true, "isolated": true
},
{
"id": 30, "name": "open", "ports": ["lan3"],
"address": "10.0.30.1/24",
"dhcp": {"enabled": true, "rangeStart": "10.0.30.100", "rangeEnd": "10.0.30.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"nat": true
},
{
"id": 31, "name": "shared", "ports": ["lan4"],
"address": "10.0.31.129/25",
"dhcp": {"enabled": true, "rangeStart": "10.0.31.150", "rangeEnd": "10.0.31.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"nat": true
}
],
"nat": {"enabled": true, "outInterface": "wan"},
"wifi": {
"enabled": true, "interface": "wlan0",
"address": "10.0.31.1/25",
"dhcp": {"enabled": true, "rangeStart": "10.0.31.100", "rangeEnd": "10.0.31.126", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"ssid": "gokrazy", "passphrase": "${WIFI_PASSPHRASE}",
"channel": 6, "hwMode": "g", "countryCode": "DE", "wpa": 2
}
}When the vlans array is empty or omitted, all LAN ports are bridged into a single br-lan:
{
"wan": {"interface": "wan", "mode": "dhcp"},
"lan": {
"bridge": "br-lan",
"interfaces": ["lan1", "lan2", "lan3", "lan4"],
"address": "10.0.0.1/24",
"dhcp": {"enabled": true, "rangeStart": "10.0.0.100", "rangeEnd": "10.0.0.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]}
},
"nat": {"enabled": true, "outInterface": "wan"},
"wifi": {
"enabled": true, "interface": "wlan0",
"address": "10.0.1.1/24",
"dhcp": {"enabled": true, "rangeStart": "10.0.1.100", "rangeEnd": "10.0.1.250", "leaseDuration": "12h", "dns": ["1.1.1.1", "8.8.8.8"]},
"ssid": "gokrazy", "passphrase": "${WIFI_PASSPHRASE}",
"channel": 6, "hwMode": "g", "countryCode": "DE", "wpa": 2
}
}- Routed (default):
wlan0gets its own subnet. A separate DHCP server runs onwlan0. The RTL8192CU does not support bridged AP mode (data frames are not forwarded), so routed mode is required. - Shared subnet with LAN: Split a /24 into two /25 subnets — one for WiFi, one for a LAN port. The router forwards between them. See VLAN 31 in the example above.
The services section enables disk mounting, SMB, PXE/TFTP and static DHCP reservations.
Mount a block device before the optional services start. Values support ${ENV} expansion.
"services": {
"mount": {
"enabled": true,
"device": "${DISK_DEVICE}",
"fsType": "ext4",
"target": "${DISK_TARGET}",
"options": "${DISK_OPTS}"
}
}Set the environment variables in your config.json PackageConfig:
"CommandLineFlags": ["-config=/etc/gokrazy-router.json"],
"Environment": [
"DISK_DEVICE=/dev/sda1",
"DISK_TARGET=/mnt/data",
"DISK_OPTS=defaults,noatime"
]Share the mounted volume via SMB. The credentials use ${ENV} expansion so they are not stored in the JSON config or in router-extras.toml.
"smb": {
"enabled": true,
"binPath": "/usr/local/bin/smbd",
"listen": "0.0.0.0:445",
"shareName": "data",
"sharePath": "/mnt/data",
"user": "${SMB_USER}",
"password": "${SMB_PASSWORD}"
}A statically linked smbd (and smbpasswd) must be bundled via ExtraFilePaths, just like hostapd.
Serve boot images to PXE clients. The TFTP server listens on UDP/69. Its root directory defaults to <mountTarget>/tftpboot. The TFTP root is a strict jail: requests for absolute paths, .. traversal or symlinks that resolve outside the root are rejected with a TFTP error, and every path component is opened with no-follow semantics so a symlink swapped in between validation and open cannot escape the root — a client cannot read arbitrary files from the router. Each transfer runs on its own ephemeral UDP port (standard TFTP transfer ID), which keeps concurrent clients isolated.
For a working iPXE chain, place the iPXE UNDI binary (e.g. undionly.kpxe) and a real iPXE text script named boot.ipxe into the TFTP root. The DHCP server sends undionly.kpxe to the initial PXE ROM and boot.ipxe once iPXE identifies itself via DHCP option 77 (User Class). If both names point to the same file, iPXE will reload itself in a loop.
"services": {
"pxe": {
"enabled": true,
"listen": "0.0.0.0:69",
"tftpRoot": "/mnt/data/tftpboot",
"defaultImage": "undionly.kpxe",
"bootFile": "undionly.kpxe",
"legacyBootFile": "undionly.kpxe",
"uefiBootFile": "netboot.xyz.efi",
"ipxeScript": "boot.ipxe",
"macImages": {
"aa:bb:cc:dd:ee:ff": "ipxe-workstation.efi"
}
}
}Supported fields:
| Field | Purpose |
|---|---|
defaultImage |
File served by TFTP when no per-MAC mapping matches |
bootFile |
Default DHCP option 67 boot file |
legacyBootFile |
Option 67 for legacy BIOS PXE clients |
uefiBootFile |
Option 67 for UEFI PXE clients |
ipxeScript |
Option 67 once a client identifies itself as iPXE |
bindInterface |
Optional SO_BINDTODEVICE interface (e.g. br-vlan31). Leave empty to serve every scope that advertises option 66/67 — replies are routed out the correct bridge automatically |
DHCP replies automatically include option 66 (TFTP server) and option 67 (boot file) when PXE is enabled. Boot-file precedence is iPXE (ipxeScript) > per-MAC (macImages) > uefiBootFile/legacyBootFile > bootFile/defaultImage. If pxeBootServer/pxeBootFile are set on a dhcp block, those values override the global services.pxe defaults for that scope.
To load the public netboot.xyz menu on legacy BIOS clients:
-
Download
netboot.xyz.kpxeinto the TFTP root (e.g./mnt/data/tftpboot/netboot.xyz.kpxe). This is the iPXE UNDI binary. -
Create a real iPXE script named
netboot.xyz.ipxethat chainloads the netboot.xyz menu. Usemenu.ipxe(theipxe/netboot.xyz.ipxepath returns 404) and try HTTP first, because the legacyundionly.kpxebuild has no HTTPS support:#!ipxe chain --autofree http://boot.netboot.xyz/menu.ipxe || chain --autofree https://boot.netboot.xyz/menu.ipxe || shell -
Configure the router to hand out those files:
"services": { "pxe": { "enabled": true, "tftpRoot": "/mnt/data/tftpboot", "defaultImage": "netboot.xyz.kpxe", "bootFile": "netboot.xyz.kpxe", "legacyBootFile": "netboot.xyz.kpxe", "uefiBootFile": "netboot.xyz.efi", "ipxeScript": "netboot.xyz.ipxe" } }
Legacy PXE ROM receives netboot.xyz.kpxe (the iPXE binary), iPXE starts and then receives netboot.xyz.ipxe (the script) via DHCP option 77, and the script loads the live netboot.xyz menu.
For UEFI clients, provide netboot.xyz.efi as uefiBootFile.
Reserve fixed IPs by MAC address in any dhcp block:
"dhcp": {
"enabled": true,
"rangeStart": "10.0.1.100",
"rangeEnd": "10.0.1.250",
"reservations": {
"aa:bb:cc:dd:ee:ff": "10.0.1.10",
"11:22:33:44:55:66": "10.0.1.11"
}
}You can place a TOML file on the mounted disk and edit it with a text editor. The file is read on every gokrazy restart. If the file does not exist yet, a minimal initial version is created automatically from the JSON config (reservations, PXE images and VLAN addresses are seeded from router.json), so runtime editing always has a starting point. To apply edits without a reboot, POST /api/reload on the admin API (default 127.0.0.1:8080, see Admin API).
"services": {
"extrasFile": "/mnt/data/router-extras.toml"
}Supported keys:
| Key | Purpose |
|---|---|
[reservations] |
"mac" = "ip" fixed leases, merged into every DHCP scope (VLANs, LAN, WiFi) |
[macImages] |
"mac" = "file" per-MAC TFTP image (DHCP boot-file precedence: iPXE > per-MAC > UEFI/legacy) |
defaultImage |
TFTP fallback for per-MAC requests that have no macImages entry |
pxeBootFile |
DHCP option 67 boot file (applied to all subnets) |
legacyBootFile |
Option 67 for legacy BIOS PXE clients |
uefiBootFile |
Option 67 for UEFI PXE clients |
ipxeScript |
Option 67 once a client identifies itself as iPXE (option 77) |
[vlanAddresses] |
id = "cidr" address override per VLAN (e.g. for per-VLAN TFTP/DHCP scopes). Applying changes requires a router restart — /api/reload rejects them with HTTP 409 |
[[smbUsers]] |
Extra SMB users: name, password. Granted access at startup; adding/removing users requires a router restart — /api/reload rejects the change with HTTP 409 |
The PXE boot-file precedence is iPXE (ipxeScript) > per-MAC (macImages) > uefiBootFile/legacyBootFile > pxeBootFile/defaultImage. This is deliberate: once a client runs iPXE, its ipxeScript must win over the MAC mapping or the client would never leave the initial boot loader. TFTP images themselves are served directly from disk, so dropping a new file into the TFTP root is enough — no restart needed. Extras-driven reservations and PXE settings require a restart or /api/reload.
The HTTP admin API (default 127.0.0.1:8080) serves the JSON status endpoint at /status and the reload endpoint at /api/reload. Both share one listener, so the status page is no longer exposed on all interfaces by default — set services.adminAPI.listen to :8080 to restore the old behavior, or to another address/port to move the API elsewhere.
"services": {
"adminAPI": {
"listen": "127.0.0.1:8080",
"token": "CHANGE_ME",
"allowUnauthenticatedReload": false
}
}listen— bind address for the status + admin API (default127.0.0.1:8080).token— bearer token required forPOST /api/reload. Any header is compared in constant time; missing or wrong token yields401 Unauthorized.allowUnauthenticatedReload— settrueto allow reloads without a token. Only do this on a trusted network, and prefer atokenplus SSH or a host firewall to reach it.
POST /api/reload re-reads the extras file and applies reservations and PXE settings live, then answers 200 ok. Changes that require a full restart — [vlanAddresses] edits or adding/removing [[smbUsers]] — are rejected with 409 Conflict rather than silently half-applied; restart the router to pick those up. The admin listener is unauthenticated for /status by design; it binds to loopback so remote clients cannot read it. Always set a token (or keep the listener loopback-only) if the router is reachable from untrusted networks.
The extras file replaces (does not merge with) the base services.pxe overrides while it is present: macImages, defaultImage, pxeBootFile, legacyBootFile, uefiBootFile and ipxeScript from the JSON config are ignored once an extras file is in place. Values removed from the TOML file are removed at runtime as well — set defaultImage = "" to clear it. When the extras file does not exist yet, it is auto-created and seeded from router.json, so nothing is lost until the file is edited.
Example covering all interfaces (VLAN 1, 20, 31 and WiFi), a per-MAC PXE image and an extra SMB user:
[vlanAddresses]
1 = "10.0.1.1/24"
20 = "10.0.20.1/24"
31 = "10.0.31.1/24"
[reservations]
"5c:ff:35:0d:b3:49" = "10.0.31.150" # T410i on VLAN 31
"aa:bb:cc:dd:ee:01" = "10.0.1.10" # workstation on VLAN 1
"aa:bb:cc:dd:ee:02" = "10.0.20.20" # NAS on VLAN 20
"cc:dd:ee:ff:00:11" = "10.0.31.100" # WiFi client
[macImages]
"5c:ff:35:0d:b3:49" = "undionly.kpxe" # T410i: legacy iPXE UNDI
"aa:bb:cc:dd:ee:01" = "ipxe-workstation.efi" # UEFI workstation
pxeBootFile = "netboot.xyz.efi" # default for UEFI clients
legacyBootFile = "netboot.xyz.efi" # legacy clients default here too (only MAC-mapped clients get a working legacy chain)
uefiBootFile = "netboot.xyz.efi"
ipxeScript = "boot.ipxe" # iPXE clients get a script
[[smbUsers]]
name = "alice"
password = "hunter2"A startup log line prints all active services, including extras-driven reservations and PXE images. Putting PXE boot files here is recommended because they change often and do not require a gokrazy redeploy. Do not put SMB credentials or other secrets in the JSON config; smbUsers belongs in router-extras.toml on the mounted disk.
Add to your gokrazy instance config:
{
"Packages": [
"github.com/consolving/gokrazy-router/cmd/gokrazy-router"
],
"PackageConfig": {
"github.com/consolving/gokrazy-router/cmd/gokrazy-router": {
"ExtraFilePaths": {
"/usr/local/bin/hostapd": "hostapd-armv7-static",
"/usr/local/bin/smbd": "smbd-armv7-static",
"/usr/local/bin/smbpasswd": "smbpasswd-armv7-static"
},
"ExtraFileContents": {
"/etc/gokrazy-router.json": "<your JSON config here>"
},
"Environment": [
"DISK_DEVICE=/dev/sda1",
"DISK_TARGET=/mnt/data"
]
}
}
}The hostapd and smbd binaries must be statically compiled for ARMv7. Use build-hostapd.sh and/or build-samba.sh to cross-compile them via Docker. Omit the Samba binaries if you do not enable SMB.
If PXE is enabled, mount a disk and place boot artifacts in the configured TFTP root (default /mnt/data/tftpboot). For netboot.xyz:
mkdir -p /mnt/data/tftpboot
curl -L -o /mnt/data/tftpboot/undionly.kpxe \
https://boot.ipxe.org/undionly.kpxe
curl -L -o /mnt/data/tftpboot/netboot.xyz.efi \
https://github.com/netbootxyz/netboot.xyz/releases/latest/download/netboot.xyz.efi
cp netboot/netboot.xyz.ipxe /mnt/data/tftpboot/netboot.xyz.ipxe
cp netboot/boot.ipxe /mnt/data/tftpboot/boot.ipxe
cp netboot/router-extras.toml /mnt/data/router-extras.tomlThe shipped netboot/router-extras.toml sends the T410i (legacy BIOS) through the iPXE chain (undionly.kpxe → boot.ipxe → netboot.xyz) and defaults every other client to the UEFI bootloader netboot.xyz.efi (see Runtime extras config). Do not put SMB credentials or other secrets in this file.
See netboot/gokrazy-router.json for a complete config example.
# Router daemon
go build ./cmd/gokrazy-router/
# Status CLI
go build ./cmd/router-cli/
# Cross-compile for BananaPi R1 (ARMv7)
GOOS=linux GOARCH=arm GOARM=7 go build ./cmd/gokrazy-router/
GOOS=linux GOARCH=arm GOARM=7 go build ./cmd/router-cli/
# Build for amd64 (e.g. x86 router or VM)
GOOS=linux GOARCH=amd64 go build ./cmd/gokrazy-router/
GOOS=linux GOARCH=amd64 go build ./cmd/router-cli/
# Cross-compile hostapd
./build-hostapd.shBoth linux/arm (ARMv7) and linux/amd64 are supported build targets.
The daemon serves a JSON status endpoint at http://127.0.0.1:8080/status (on the router itself) with port statistics and per-client traffic counters. Configure services.adminAPI.listen to expose it on another interface.
Use the CLI tool to query it:
router-cli --host 10.0.31.1
router-cli --host 10.0.31.1 --jsonExample output:
IFACE MAC SPEED RX TX RX PKTS TX PKTS
wan a2:b4:c6:d8:e0:12 100 Mbps/full 8.2 MiB 1.6 MiB 11764 7190
lan - 0 B 0 B 0 0
wifi f0:e1:d2:c3:b4:a5 - 1.2 MiB 4.5 MiB 5443 5879
CONNECTED CLIENTS
VIA IP MAC UL RATE DL RATE LINK SIGNAL UL DL TOTAL UL TOTAL DL
V1 10.0.1.100 00:11:22:33:44:55 1.3 KiB/s 3.4 KiB/s - - 156.4 KiB 3.3 MiB 156.4 KiB 3.3 MiB
V31 10.0.31.150 66:77:88:99:aa:bb 0 B/s 0 B/s - - 90.2 KiB 1.1 MiB 90.2 KiB 1.1 MiB
W 10.0.31.100 cc:dd:ee:ff:00:11 0 B/s 0 B/s - -42 dBm 601.5 KiB 2.2 MiB 601.5 KiB 2.2 MiB
The columns show:
- VIA —
V<id>for VLAN clients,Wfor WiFi,Lfor flat-mode LAN - UL RATE / DL RATE — Current throughput (sampled every 5 seconds)
- LINK — WiFi link rate (from hostapd control socket)
- SIGNAL — WiFi signal strength in dBm (from hostapd control socket)
- UL / DL — Current session traffic (reset on reconnect)
- TOTAL UL / TOTAL DL — Accumulated traffic across all sessions since boot
- SPEED — Negotiated port link speed and duplex
| Library | Purpose |
|---|---|
github.com/vishvananda/netlink |
Bridge, interface, address management |
github.com/google/nftables |
NAT masquerade + isolation rules + traffic counters |
github.com/insomniacslk/dhcp |
DHCPv4 server |
github.com/pelletier/go-toml/v2 |
MAC-to-VLAN mapping and runtime extras config parsing |
External: hostapd and optionally smbd/smbpasswd (statically compiled, bundled via gokrazy ExtraFilePaths)