Files
rollcall/cmd/rollcall/network.go
T
Ilker ManapandClaude Opus 5.5 a549f01ef5
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 34s
Security review: fix traversal, identity, injection and takeover paths
Findings from a full review (auth/API, CA and rack channel, config
generation, integrations, UI/agents/scripts), each verified in code:

- rack binary endpoint read any region file via arch=../.. (CA key)
- render endpoint traversal; machine-reported MACs used as file paths
- delegated intermediates could mint admin/rack identities and IPs;
  identities now require root-issued certs, intermediates are
  ServerAuth-only with both name types constrained
- rack certs were ServerAuth for rack-chosen SANs: now client-only;
  racks accept only the region's own cert; join checks the CA
  fingerprint; unbound join codes cannot take an existing name; job
  results bound to the rack that took them
- rack self-update: region signs binaries with the CA key, the root
  unit verifies against the join-time CA in a root-owned dir
- dnsmasq reads a checked, root-owned copy limited to generated options
- commissioning could overwrite deployed machines; MAC takeover of PXE
- injection into dnsmasq/cloud-init/PXE/udev/DNS via names and
  machine-reported strings; stored XSS in the UI; CSP and cross-origin
  protection; deploy agent shell injection and seed traversal
- Redfish requests confined to the BMC's /redfish/ tree
- admin_http defaults to loopback; body limits and timeouts; secrets
  off command lines

Also: IPAM claim/reconcile race, sync stopping on one error, watchdog
race, release wiping the wrong disk, SAN LUNs as auto targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:58:42 +01:00

1463 lines
42 KiB
Go

package main
// Network model, after MAAS: fabric → VLAN → subnet → IP ranges, plus the
// addresses handed out (IPAM), interface layouts for machines and the two
// things rendered from it all: the cloud-init network-config a deployed
// machine boots with, and the dnsmasq configuration of the rack controller
// that serves each VLAN.
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
type Fabric struct {
Name string `json:"name"`
Kind string `json:"kind"` // ethernet | infiniband
Comment string `json:"comment"`
}
type VLAN struct {
ID int64 `json:"id"`
Fabric string `json:"fabric"`
VID int `json:"vid"` // 0 = untagged; an InfiniBand partition key on IB fabrics
Name string `json:"name"`
MTU int `json:"mtu"`
DHCP bool `json:"dhcp"` // a rack controller answers DHCP/PXE on it
DHCPMode string `json:"dhcp_mode"` // serve (addresses + PXE) | proxy (PXE only; another server hands out addresses)
PrimaryRack string `json:"primary_rack"` // rack controller that serves it
SecondaryRack string `json:"secondary_rack"` // second one; the dynamic range is split between them
}
type IPRange struct {
ID int64 `json:"id"`
Subnet string `json:"subnet"`
Kind string `json:"kind"` // dynamic (DHCP pool for PXE) | reserved (never handed out) | static (where machines get addresses)
Start string `json:"start"`
End string `json:"end"`
Comment string `json:"comment"`
}
type Subnet struct {
CIDR string `json:"cidr"`
Name string `json:"name"`
VLANID int64 `json:"vlan_id"`
Fabric string `json:"fabric"`
VID int `json:"vid"`
MTU int `json:"mtu"`
DHCP bool `json:"dhcp"`
DHCPMode string `json:"dhcp_mode"` // serve | proxy (from the VLAN)
PrimaryRack string `json:"primary_rack"`
SecondaryRack string `json:"secondary_rack"`
Purpose string `json:"purpose"` // provisioning | mgmt | bmc | storage | ib | other
Gateway string `json:"gateway"`
DNS []string `json:"dns"`
Domain string `json:"domain"`
Comment string `json:"comment"`
Ranges []IPRange `json:"ranges"`
Used int `json:"used"`
Size int `json:"size"` // host addresses; 0 when too many to count (IPv6)
Family int `json:"family"` // 4 or 6
}
type IPAddr struct {
IP string `json:"ip"`
Subnet string `json:"subnet"`
MachineID string `json:"machine_id"`
Iface string `json:"iface"`
Kind string `json:"kind"` // auto (allocated) | static (pinned) | reserved
Hostname string `json:"hostname"`
Created time.Time `json:"created"`
}
// NetLayout describes a machine's interfaces in terms that fit many machines:
// NICs are picked by selectors, not by MAC.
type NetLayout struct {
Name string `json:"name"`
Comment string `json:"comment"`
Interfaces []LayoutIface `json:"interfaces"`
Machines int `json:"machines,omitempty"`
Raw map[string]any `json:"-"`
}
type LayoutIface struct {
Name string `json:"name,omitempty"` // bond0, bond0.200; physical: the NIC's own name when empty
Type string `json:"type"` // physical | bond | vlan
Select string `json:"select,omitempty"` // physical: boot | name:X | mac:X | fastest:N | ib:N
Members []string `json:"members,omitempty"` // bond: selectors, each may match several NICs
BondMode string `json:"bond_mode,omitempty"` // 802.3ad (default) | active-backup | balance-xor | balance-alb
Parent string `json:"parent,omitempty"` // vlan: the interface it sits on
VID int `json:"vid,omitempty"`
MTU int `json:"mtu,omitempty"`
Links []LayoutLink `json:"links,omitempty"`
}
type LayoutLink struct {
Subnet string `json:"subnet"` // CIDR, subnet name, or "boot" (where the machine PXE-booted)
Mode string `json:"mode"` // auto | static | dhcp | link_up
IP string `json:"ip,omitempty"` // static
Gateway bool `json:"default_gateway,omitempty"` // this link carries the default route
}
// ---------------------------------------------------------------- store
func (s *Store) Fabrics(ctx context.Context) ([]Fabric, error) {
rows, err := s.pool.Query(ctx, `SELECT name, kind, coalesce(comment,'') FROM fabrics ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Fabric{}
for rows.Next() {
var f Fabric
if err := rows.Scan(&f.Name, &f.Kind, &f.Comment); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) PutFabric(ctx context.Context, f Fabric) error {
if f.Kind == "" {
f.Kind = "ethernet"
}
_, err := s.pool.Exec(ctx, `INSERT INTO fabrics(name, kind, comment) VALUES ($1,$2,$3) ON CONFLICT (name) DO UPDATE SET kind=$2, comment=$3`, f.Name, f.Kind, f.Comment)
return err
}
func (s *Store) DeleteFabric(ctx context.Context, name string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM fabrics WHERE name=$1`, name)
return err
}
func (s *Store) VLANs(ctx context.Context) ([]VLAN, error) {
rows, err := s.pool.Query(ctx, `SELECT id, fabric, vid, coalesce(name,''), mtu, dhcp, dhcp_mode, coalesce(primary_rack,''), coalesce(secondary_rack,'') FROM vlans ORDER BY fabric, vid`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []VLAN{}
for rows.Next() {
var v VLAN
if err := rows.Scan(&v.ID, &v.Fabric, &v.VID, &v.Name, &v.MTU, &v.DHCP, &v.DHCPMode, &v.PrimaryRack, &v.SecondaryRack); err != nil {
return nil, err
}
v.DHCPMode = dhcpMode(v.DHCP, v.DHCPMode)
out = append(out, v)
}
return out, rows.Err()
}
// dhcpMode normalises: no DHCP -> "", DHCP without a mode -> serve.
func dhcpMode(dhcp bool, mode string) string {
switch {
case !dhcp:
return ""
case mode == "proxy":
return "proxy"
}
return "serve"
}
// PutVLAN upserts by (fabric, vid) and returns the id.
func (s *Store) PutVLAN(ctx context.Context, v VLAN) (int64, error) {
if v.MTU <= 0 {
v.MTU = 1500
}
var id int64
err := s.pool.QueryRow(ctx, `INSERT INTO vlans(fabric, vid, name, mtu, dhcp, primary_rack, secondary_rack, dhcp_mode) VALUES ($1,$2,$3,$4,$5,nullif($6,''),nullif($7,''),$8)
ON CONFLICT (fabric, vid) DO UPDATE SET name=$3, mtu=$4, dhcp=$5, primary_rack=nullif($6,''), secondary_rack=nullif($7,''), dhcp_mode=$8 RETURNING id`,
v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack, dhcpMode(v.DHCP, v.DHCPMode)).Scan(&id)
return id, err
}
func (s *Store) DeleteVLAN(ctx context.Context, fabric string, vid int) error {
_, err := s.pool.Exec(ctx, `DELETE FROM vlans WHERE fabric=$1 AND vid=$2`, fabric, vid)
return err
}
func (s *Store) Subnets(ctx context.Context) ([]Subnet, error) {
rows, err := s.pool.Query(ctx, `SELECT s.cidr, coalesce(s.name,''), coalesce(s.vlan_id,0), coalesce(v.fabric,''), coalesce(v.vid,0), coalesce(v.mtu,1500),
coalesce(v.dhcp,false), coalesce(v.dhcp_mode,''), coalesce(v.primary_rack,''), coalesce(v.secondary_rack,''), s.purpose, coalesce(s.gateway,''), s.dns,
coalesce(s.domain,''), coalesce(s.comment,''), (SELECT count(*) FROM ip_addresses a WHERE a.subnet=s.cidr)
FROM subnets s LEFT JOIN vlans v ON v.id = s.vlan_id ORDER BY s.cidr`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Subnet{}
for rows.Next() {
var sn Subnet
var dns []byte
if err := rows.Scan(&sn.CIDR, &sn.Name, &sn.VLANID, &sn.Fabric, &sn.VID, &sn.MTU, &sn.DHCP, &sn.DHCPMode, &sn.PrimaryRack, &sn.SecondaryRack,
&sn.Purpose, &sn.Gateway, &dns, &sn.Domain, &sn.Comment, &sn.Used); err != nil {
return nil, err
}
_ = json.Unmarshal(dns, &sn.DNS)
sn.DHCPMode = dhcpMode(sn.DHCP, sn.DHCPMode)
if sn.DNS == nil {
sn.DNS = []string{}
}
sn.Family = familyOf(sn.CIDR)
if _, n, err := net.ParseCIDR(sn.CIDR); err == nil {
ones, bits := n.Mask.Size()
switch {
case bits-ones > 30:
sn.Size = 0
case sn.Family == 6:
sn.Size = 1<<(bits-ones) - 1
default:
sn.Size = max(0, 1<<(bits-ones)-2)
}
}
sn.Ranges = []IPRange{}
out = append(out, sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
rr, err := s.pool.Query(ctx, `SELECT id, subnet, kind, start_ip, end_ip, coalesce(comment,'') FROM ip_ranges ORDER BY subnet, start_ip`)
if err != nil {
return nil, err
}
defer rr.Close()
for rr.Next() {
var r IPRange
if err := rr.Scan(&r.ID, &r.Subnet, &r.Kind, &r.Start, &r.End, &r.Comment); err != nil {
return nil, err
}
for i := range out {
if out[i].CIDR == r.Subnet {
out[i].Ranges = append(out[i].Ranges, r)
}
}
}
return out, rr.Err()
}
func (s *Store) PutSubnet(ctx context.Context, sn Subnet) error {
if sn.Purpose == "" {
sn.Purpose = "provisioning"
}
if sn.DNS == nil {
sn.DNS = []string{}
}
dns, _ := json.Marshal(sn.DNS)
var vlan any
if sn.VLANID > 0 {
vlan = sn.VLANID
}
_, err := s.pool.Exec(ctx, `INSERT INTO subnets(cidr, name, vlan_id, purpose, gateway, dns, domain, comment) VALUES ($1,$2,$3,$4,nullif($5,''),$6,nullif($7,''),$8)
ON CONFLICT (cidr) DO UPDATE SET name=$2, vlan_id=$3, purpose=$4, gateway=nullif($5,''), dns=$6, domain=nullif($7,''), comment=$8`,
sn.CIDR, sn.Name, vlan, sn.Purpose, sn.Gateway, dns, sn.Domain, sn.Comment)
return err
}
func (s *Store) DeleteSubnet(ctx context.Context, cidr string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM subnets WHERE cidr=$1`, cidr)
if err == nil && tag.RowsAffected() == 0 {
return errNotFound
}
return err
}
func (s *Store) AddRange(ctx context.Context, r IPRange) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `INSERT INTO ip_ranges(subnet, kind, start_ip, end_ip, comment) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
r.Subnet, r.Kind, r.Start, r.End, r.Comment).Scan(&id)
return id, err
}
func (s *Store) DeleteRange(ctx context.Context, id int64) error {
_, err := s.pool.Exec(ctx, `DELETE FROM ip_ranges WHERE id=$1`, id)
return err
}
func (s *Store) IPs(ctx context.Context, subnet, machineID string) ([]IPAddr, error) {
q := `SELECT ip, subnet, coalesce(machine_id,''), coalesce(iface,''), kind, coalesce(hostname,''), created FROM ip_addresses WHERE true`
var args []any
if subnet != "" {
args = append(args, subnet)
q += fmt.Sprintf(" AND subnet=$%d", len(args))
}
if machineID != "" {
args = append(args, machineID)
q += fmt.Sprintf(" AND machine_id=$%d", len(args))
}
rows, err := s.pool.Query(ctx, q+` ORDER BY subnet, ip`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []IPAddr{}
for rows.Next() {
var a IPAddr
if err := rows.Scan(&a.IP, &a.Subnet, &a.MachineID, &a.Iface, &a.Kind, &a.Hostname, &a.Created); err != nil {
return nil, err
}
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return ipLess(out[i].IP, out[j].IP) })
return out, rows.Err()
}
// ClaimIP records an address; false when it is taken.
func (s *Store) ClaimIP(ctx context.Context, a IPAddr) (bool, error) {
tag, err := s.pool.Exec(ctx, `INSERT INTO ip_addresses(ip, subnet, machine_id, iface, kind, hostname) VALUES ($1,$2,nullif($3,''),nullif($4,''),$5,nullif($6,'')) ON CONFLICT (ip) DO NOTHING`,
a.IP, a.Subnet, a.MachineID, a.Iface, a.Kind, a.Hostname)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *Store) ReleaseIP(ctx context.Context, ip string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM ip_addresses WHERE ip=$1`, ip)
return err
}
// FreeMachineIPs releases a machine's addresses of the given kinds (all when none given).
func (s *Store) FreeMachineIPs(ctx context.Context, machineID string, kinds ...string) error {
if len(kinds) == 0 {
_, err := s.pool.Exec(ctx, `DELETE FROM ip_addresses WHERE machine_id=$1`, machineID)
return err
}
_, err := s.pool.Exec(ctx, `DELETE FROM ip_addresses WHERE machine_id=$1 AND kind = ANY($2)`, machineID, kinds)
return err
}
func (s *Store) Layouts(ctx context.Context) ([]NetLayout, error) {
rows, err := s.pool.Query(ctx, `SELECT name, spec, coalesce(comment,'') FROM net_layouts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []NetLayout{}
for rows.Next() {
var l NetLayout
var spec []byte
if err := rows.Scan(&l.Name, &spec, &l.Comment); err != nil {
return nil, err
}
var body struct {
Interfaces []LayoutIface `json:"interfaces"`
}
_ = json.Unmarshal(spec, &body)
l.Interfaces = body.Interfaces
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) PutLayout(ctx context.Context, l NetLayout) error {
spec, _ := json.Marshal(struct {
Interfaces []LayoutIface `json:"interfaces"`
}{l.Interfaces})
_, err := s.pool.Exec(ctx, `INSERT INTO net_layouts(name, spec, comment) VALUES ($1,$2,$3) ON CONFLICT (name) DO UPDATE SET spec=$2, comment=$3`, l.Name, spec, l.Comment)
return err
}
func (s *Store) DeleteLayout(ctx context.Context, name string) error {
var n int
_ = s.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM machines WHERE net_layout=$1) + (SELECT count(*) FROM tags WHERE net_layout=$1)`, name).Scan(&n)
if n > 0 {
return fmt.Errorf("layout %s is used by %d machine(s)/tag(s)", name, n)
}
_, err := s.pool.Exec(ctx, `DELETE FROM net_layouts WHERE name=$1`, name)
return err
}
func (s *Store) SetMachineLayout(ctx context.Context, id, layout string) error {
_, err := s.pool.Exec(ctx, `UPDATE machines SET net_layout=nullif($2,'') WHERE id=$1`, id, layout)
return err
}
// ---------------------------------------------------------------- IPv4 helpers
func ip4(s string) (uint32, bool) {
ip := net.ParseIP(strings.TrimSpace(s)).To4()
if ip == nil {
return 0, false
}
return binary.BigEndian.Uint32(ip), true
}
func u2ip(u uint32) string {
b := make(net.IP, 4)
binary.BigEndian.PutUint32(b, u)
return b.String()
}
// parseAddr reads an IPv4 or IPv6 address (brackets and a /prefix are ignored).
func parseAddr(s string) (netip.Addr, bool) {
a, err := netip.ParseAddr(strings.Trim(stripPrefix(strings.TrimSpace(s)), "[]"))
if err != nil {
return netip.Addr{}, false
}
return a.Unmap(), true
}
func ipLess(a, b string) bool {
x, ok1 := parseAddr(a)
y, ok2 := parseAddr(b)
if ok1 && ok2 {
return x.Less(y)
}
return a < b
}
func familyOf(cidr string) int {
if p, err := netip.ParsePrefix(cidr); err == nil && p.Addr().Is6() && !p.Addr().Is4In6() {
return 6
}
return 4
}
func stripPrefix(s string) string { ip, _, _ := strings.Cut(s, "/"); return ip }
func (sn Subnet) contains(ip string) bool {
_, n, err := net.ParseCIDR(sn.CIDR)
p := net.ParseIP(stripPrefix(ip))
return err == nil && p != nil && n.Contains(p)
}
func (sn Subnet) bits() int {
if _, n, err := net.ParseCIDR(sn.CIDR); err == nil {
ones, _ := n.Mask.Size()
return ones
}
return 24
}
func (sn Subnet) netmask() string {
if _, n, err := net.ParseCIDR(sn.CIDR); err == nil {
return net.IP(n.Mask).String()
}
return "255.255.255.0"
}
func subnetFor(subnets []Subnet, ip string) *Subnet {
for i := range subnets {
if subnets[i].contains(ip) {
return &subnets[i]
}
}
return nil
}
func subnetByRef(subnets []Subnet, ref string) *Subnet {
for i := range subnets {
if subnets[i].CIDR == ref || (subnets[i].Name != "" && subnets[i].Name == ref) {
return &subnets[i]
}
}
return nil
}
// freeCandidates lists the addresses IPAM may hand out in a subnet, in order:
// inside static ranges when the subnet has any, never in dynamic or reserved
// ranges, never the network (or IPv4 broadcast), the gateway, a rack
// controller or a used one. IPv6 subnets are walked from the start of the
// static ranges (or the subnet) for a bounded number of steps.
func freeCandidates(sn Subnet, used map[string]bool, avoid []string, limit int) []string {
pfx, err := netip.ParsePrefix(sn.CIDR)
if err != nil {
return nil
}
pfx = pfx.Masked()
base := pfx.Addr()
type span struct{ lo, hi netip.Addr }
var static, blocked []span
for _, r := range sn.Ranges {
lo, ok1 := parseAddr(r.Start)
hi, ok2 := parseAddr(r.End)
if !ok1 || !ok2 || hi.Less(lo) {
continue
}
if r.Kind == "static" {
static = append(static, span{lo, hi})
} else {
blocked = append(blocked, span{lo, hi})
}
}
skip := map[netip.Addr]bool{base: true}
if base.Is4() {
last := base.As4()
bits := pfx.Bits()
v := binary.BigEndian.Uint32(last[:]) | (1<<(32-bits) - 1)
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
skip[netip.AddrFrom4(b)] = true
}
if g, ok := parseAddr(sn.Gateway); ok {
skip[g] = true
}
for _, a := range avoid {
if u, ok := parseAddr(a); ok {
skip[u] = true
}
}
in := func(x netip.Addr, ss []span) bool {
for _, s := range ss {
if !x.Less(s.lo) && !s.hi.Less(x) {
return true
}
}
return false
}
var out []string
try := func(x netip.Addr) bool {
if skip[x] || in(x, blocked) || used[x.String()] {
return false
}
out = append(out, x.String())
return limit > 0 && len(out) >= limit
}
const maxSteps = 1 << 16
if len(static) > 0 {
sort.Slice(static, func(i, j int) bool { return static[i].lo.Less(static[j].lo) })
for _, s := range static {
steps := 0
for x := s.lo; pfx.Contains(x) && !s.hi.Less(x) && steps < maxSteps; x = x.Next() {
steps++
if try(x) {
return out
}
}
}
return out
}
steps := 0
for x := base.Next(); x.IsValid() && pfx.Contains(x) && steps < maxSteps; x = x.Next() {
steps++
if try(x) {
return out
}
}
return out
}
// allocIP hands out the first free address of a subnet.
func (a *App) allocIP(ctx context.Context, sn Subnet, machineID, iface, hostname string) (string, error) {
existing, err := a.store.IPs(ctx, sn.CIDR, "")
if err != nil {
return "", err
}
used := map[string]bool{}
for _, x := range existing {
used[x.IP] = true
}
for _, ip := range freeCandidates(sn, used, a.rackAddresses(), 256) {
ok, err := a.claimIP(ctx, IPAddr{IP: ip, Subnet: sn.CIDR, MachineID: machineID, Iface: iface, Kind: "auto", Hostname: hostname})
if err != nil {
return "", err
}
if ok {
return ip, nil
}
}
return "", fmt.Errorf("subnet %s is full", sn.CIDR)
}
// ---------------------------------------------------------------- machine NICs from the inventory
type invNIC struct {
Name string
MAC string
IB bool
Speed int // Mb/s, 0 unknown
Link bool
IPv4 []string
}
var speedRe = regexp.MustCompile(`(\d+)`)
var ifnameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,14}$`)
func machineNICs(m *Machine) []invNIC {
var inv obj
_ = json.Unmarshal(m.Inventory, &inv)
var out []invNIC
for _, nv := range toList(inv["network"]) {
n, _ := nv.(obj)
nic := invNIC{Name: str(n["name"]), MAC: normMAC(str(n["mac"])), IB: str(n["link_type"]) == "infiniband", Link: n["link"] == true}
if s := speedRe.FindString(str(n["speed"])); s != "" {
nic.Speed, _ = strconv.Atoi(s)
}
for _, ip := range toList(n["ipv4"]) {
nic.IPv4 = append(nic.IPv4, str(ip))
}
if ifnameRe.MatchString(nic.Name) { // the machine reports it; it lands in udev rules and dnsmasq files
out = append(out, nic)
}
}
return out
}
func bootNIC(nics []invNIC, macs []string) *invNIC {
if len(macs) > 0 {
for i := range nics {
if nics[i].MAC == normMAC(macs[0]) {
return &nics[i]
}
}
}
for i := range nics {
if len(nics[i].IPv4) > 0 {
return &nics[i]
}
}
if len(nics) > 0 {
return &nics[0]
}
return nil
}
// selectNICs resolves one selector against the inventory.
func selectNICs(nics []invNIC, boot *invNIC, sel string) []invNIC {
sel = strings.TrimSpace(sel)
kind, arg, _ := strings.Cut(sel, ":")
switch kind {
case "", "boot":
if boot != nil {
return []invNIC{*boot}
}
case "name":
for _, n := range nics {
if n.Name == arg {
return []invNIC{n}
}
}
case "mac":
for _, n := range nics {
if n.MAC == normMAC(arg) {
return []invNIC{n}
}
}
case "fastest", "all":
var eth []invNIC
for _, n := range nics {
if !n.IB {
eth = append(eth, n)
}
}
sort.SliceStable(eth, func(i, j int) bool {
if eth[i].Link != eth[j].Link {
return eth[i].Link
}
if eth[i].Speed != eth[j].Speed {
return eth[i].Speed > eth[j].Speed
}
return eth[i].Name < eth[j].Name
})
if kind == "all" {
var up []invNIC
for _, n := range eth {
if n.Link {
up = append(up, n)
}
}
return up
}
k, _ := strconv.Atoi(arg)
if k <= 0 {
k = 1
}
return eth[:min(k, len(eth))]
case "ib":
var ib []invNIC
for _, n := range nics {
if n.IB {
ib = append(ib, n)
}
}
sort.Slice(ib, func(i, j int) bool { return ib[i].Name < ib[j].Name })
k, err := strconv.Atoi(arg)
if err == nil && k >= 0 && k < len(ib) {
return []invNIC{ib[k]}
}
default:
for _, n := range nics {
if n.Name == sel {
return []invNIC{n}
}
}
}
return nil
}
// ---------------------------------------------------------------- plan
type PlanLink struct {
Family int `json:"family"`
Subnet string `json:"subnet"`
Mode string `json:"mode"`
IP string `json:"ip"`
Prefix int `json:"prefix"`
Gateway string `json:"gateway"`
Default bool `json:"default_gateway"`
DNS []string `json:"dns"`
Domain string `json:"domain"`
}
type PlanIface struct {
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac,omitempty"`
IB bool `json:"ib,omitempty"`
Members []string `json:"members,omitempty"`
BondMode string `json:"bond_mode,omitempty"`
Parent string `json:"parent,omitempty"`
VID int `json:"vid,omitempty"`
MTU int `json:"mtu,omitempty"`
Links []PlanLink `json:"links"`
nics []invNIC
}
type NetPlan struct {
Layout string `json:"layout"`
Ifaces []PlanIface `json:"interfaces"`
Warnings []string `json:"warnings"`
member map[string]invNIC
}
// defaultLayout: the boot NIC on its subnet, plus every other subnet of the same
// VLAN (an IPv6 prefix next to the IPv4 one makes the machine dual-stack).
func defaultLayout(bootSubnet *Subnet, subnets []Subnet) *NetLayout {
links := []LayoutLink{{Subnet: "boot", Mode: "auto", Gateway: true}}
if bootSubnet != nil && bootSubnet.DHCPMode == "proxy" && !hasStaticRange(*bootSubnet) {
// the site's DHCP server owns the addresses; without a static range set aside
// for Rollcall, machines keep getting theirs from it
links = []LayoutLink{{Subnet: "boot", Mode: "dhcp"}}
}
if bootSubnet == nil {
links = []LayoutLink{{Subnet: "boot", Mode: "dhcp"}}
} else if bootSubnet.VLANID != 0 {
for _, s := range subnets {
if s.VLANID == bootSubnet.VLANID && s.CIDR != bootSubnet.CIDR {
links = append(links, LayoutLink{Subnet: s.CIDR, Mode: "auto"})
}
}
}
return &NetLayout{Name: "(default)", Interfaces: []LayoutIface{{Type: "physical", Select: "boot", Links: links}}}
}
// resolvePlan turns a layout into concrete interfaces for one machine. alloc is
// called for auto links that have no address yet (nil: show them as unallocated).
func resolvePlan(l *NetLayout, nics []invNIC, boot *invNIC, subnets []Subnet, have []IPAddr,
alloc func(sn Subnet, iface string) (string, error)) *NetPlan {
var bootSubnet *Subnet
if boot != nil {
for _, ip := range boot.IPv4 {
if s := subnetFor(subnets, ip); s != nil {
bootSubnet = s
break
}
}
}
if l == nil {
l = defaultLayout(bootSubnet, subnets)
}
p := &NetPlan{Layout: l.Name, Warnings: []string{}, member: map[string]invNIC{}}
used := map[string]bool{}
byName := map[string]bool{}
haveFor := func(iface, subnet string) (string, string) {
for _, kind := range []string{"static", "auto"} {
for _, a := range have {
if a.Subnet == subnet && a.Kind == kind && (a.Iface == iface || a.Iface == "") {
return a.IP, kind
}
}
}
return "", ""
}
// selectors pick among the NICs no earlier interface took ("boot" always means the boot NIC)
avail := func() []invNIC {
var out []invNIC
for _, n := range nics {
if !used[n.MAC] {
out = append(out, n)
}
}
return out
}
for _, li := range l.Interfaces {
pi := PlanIface{Type: li.Type, MTU: li.MTU, Links: []PlanLink{}}
switch li.Type {
case "", "physical":
pi.Type = "physical"
pool := avail()
if li.Select == "" || li.Select == "boot" {
pool = nics
}
ns := selectNICs(pool, boot, li.Select)
var nic *invNIC
for i := range ns {
if !used[ns[i].MAC] {
nic = &ns[i]
break
}
}
if nic == nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("no NIC matches %q", li.Select))
continue
}
used[nic.MAC] = true
pi.Name, pi.MAC, pi.IB, pi.nics = nic.Name, nic.MAC, nic.IB, []invNIC{*nic}
if li.Name != "" {
pi.Name = li.Name
}
case "bond":
pi.Name = li.Name
if pi.Name == "" {
pi.Name = "bond0"
}
pi.BondMode = li.BondMode
if pi.BondMode == "" {
pi.BondMode = "802.3ad"
}
for _, sel := range li.Members {
pool := avail()
if sel == "boot" {
pool = nics
}
for _, n := range selectNICs(pool, boot, sel) {
if !used[n.MAC] {
used[n.MAC] = true
pi.Members = append(pi.Members, n.Name)
pi.nics = append(pi.nics, n)
p.member[n.Name] = n
}
}
}
if len(pi.Members) == 0 {
p.Warnings = append(p.Warnings, fmt.Sprintf("bond %s: no member NICs match %v", pi.Name, li.Members))
continue
}
case "vlan":
if !byName[li.Parent] {
p.Warnings = append(p.Warnings, fmt.Sprintf("vlan %d: parent %q is not defined above it", li.VID, li.Parent))
continue
}
pi.Parent, pi.VID = li.Parent, li.VID
pi.Name = li.Name
if pi.Name == "" {
pi.Name = fmt.Sprintf("%s.%d", li.Parent, li.VID)
}
default:
p.Warnings = append(p.Warnings, "unknown interface type "+li.Type)
continue
}
byName[pi.Name] = true
for _, lk := range li.Links {
mode := lk.Mode
if mode == "" {
mode = "auto"
}
var sn *Subnet
switch {
case lk.Subnet == "boot", lk.Subnet == "" && mode != "dhcp" && mode != "link_up":
sn = bootSubnet
case lk.Subnet != "":
sn = subnetByRef(subnets, lk.Subnet)
}
if sn == nil {
if mode == "dhcp" || mode == "link_up" {
pi.Links = append(pi.Links, PlanLink{Mode: mode, Family: 4})
continue
}
p.Warnings = append(p.Warnings, fmt.Sprintf("%s: subnet %q is not defined", pi.Name, lk.Subnet))
continue
}
pl := PlanLink{Family: familyOf(sn.CIDR), Subnet: sn.CIDR, Mode: mode, Prefix: sn.bits(), Gateway: sn.Gateway, Default: lk.Gateway, DNS: sn.DNS, Domain: sn.Domain}
if pi.MTU == 0 && sn.MTU > 0 && sn.MTU != 1500 {
pi.MTU = sn.MTU
}
if ip, kind := haveFor(pi.Name, sn.CIDR); kind == "static" && mode != "dhcp" && mode != "link_up" {
pl.Mode, pl.IP = "static", ip
} else {
switch mode {
case "static":
pl.IP = lk.IP
if pl.IP == "" {
p.Warnings = append(p.Warnings, fmt.Sprintf("%s: static link on %s without an address", pi.Name, sn.CIDR))
}
case "auto":
pl.IP = ip
if pl.IP == "" && alloc != nil {
got, err := alloc(*sn, pi.Name)
if err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("%s: %v", pi.Name, err))
}
pl.IP = got
}
}
}
pi.Links = append(pi.Links, pl)
}
p.Ifaces = append(p.Ifaces, pi)
}
// one default route per family: the flagged link, else the first addressed
// link with a gateway on the boot NIC, else anywhere
for _, fam := range []int{4, 6} {
hasDefault := false
for _, pi := range p.Ifaces {
for _, pl := range pi.Links {
hasDefault = hasDefault || (pl.Default && pl.Family == fam)
}
}
if hasDefault {
continue
}
pickDefault := func(onBoot bool) bool {
for i := range p.Ifaces {
carriesBoot := false
for _, n := range p.Ifaces[i].nics {
carriesBoot = carriesBoot || (boot != nil && n.MAC == boot.MAC)
}
if onBoot && !carriesBoot {
continue
}
for j := range p.Ifaces[i].Links {
pl := &p.Ifaces[i].Links[j]
if pl.Family == fam && pl.Gateway != "" && (pl.Mode == "auto" || pl.Mode == "static") {
pl.Default = true
return true
}
}
}
return false
}
if !pickDefault(true) {
pickDefault(false)
}
}
return p
}
// primaryIPs returns the address carrying the default route (IPv4 first) and the first IB address.
func (p *NetPlan) primaryIPs() (mgmt, ib string) {
for _, fam := range []int{4, 6} {
for _, pi := range p.Ifaces {
for _, pl := range pi.Links {
if pl.Default && pl.IP != "" && mgmt == "" && pl.Family == fam {
mgmt = pl.IP
}
}
}
}
for _, pi := range p.Ifaces {
for _, pl := range pi.Links {
if pi.IB && pl.IP != "" && ib == "" {
ib = pl.IP
}
}
}
if mgmt == "" {
for _, pi := range p.Ifaces {
for _, pl := range pi.Links {
if pl.IP != "" && !pi.IB && mgmt == "" {
mgmt = pl.IP
}
}
}
}
return
}
// networkConfig renders the plan as cloud-init network-config version 2
// (netplan syntax; cloud-init converts it for NetworkManager, networkd or ENI).
func networkConfig(p *NetPlan) map[string]any {
eth, bonds, vlans := map[string]any{}, map[string]any{}, map[string]any{}
// Addresses on subnets other than the default route's answer through their own
// subnet's gateway: one routing table per such address and a rule "from it".
// Without this a reply to an off-subnet host leaves through the default
// interface with the wrong source and is dropped (reverse-path filtering).
nextTable := 100
fill := func(e map[string]any, pi PlanIface) {
if pi.MTU > 0 {
e["mtu"] = pi.MTU
}
var addrs, dns, search []string
var routes, policy []map[string]any
dhcp4, dhcp6 := false, false
for _, pl := range pi.Links {
switch pl.Mode {
case "dhcp":
if pl.Family == 6 {
dhcp6 = true
} else {
dhcp4 = true
}
case "auto", "static":
if pl.IP == "" {
continue
}
addrs = append(addrs, fmt.Sprintf("%s/%d", pl.IP, pl.Prefix))
if pl.Default && pl.Gateway != "" {
to := "0.0.0.0/0"
if pl.Family == 6 {
to = "::/0"
}
routes = append(routes, map[string]any{"to": to, "via": pl.Gateway})
} else if pl.Gateway != "" {
to, host := "0.0.0.0/0", "/32"
if pl.Family == 6 {
to, host = "::/0", "/128"
}
t := nextTable
nextTable++
routes = append(routes,
map[string]any{"to": pl.Subnet, "scope": "link", "table": t},
map[string]any{"to": to, "via": pl.Gateway, "table": t})
policy = append(policy, map[string]any{"from": pl.IP + host, "table": t})
}
for _, d := range pl.DNS {
if !hasTag(dns, d) {
dns = append(dns, d)
}
}
if pl.Domain != "" && !hasTag(search, pl.Domain) {
search = append(search, pl.Domain)
}
}
}
e["dhcp4"] = dhcp4
if dhcp6 {
e["dhcp6"] = true
} else {
// addresses come from the plan (or none at all): router advertisements must not add
// SLAAC/DHCPv6 addresses or a second default route
e["accept-ra"] = false
}
if len(addrs) > 0 {
e["addresses"] = addrs
}
if len(routes) > 0 {
e["routes"] = routes
}
if len(policy) > 0 {
e["routing-policy"] = policy
}
if len(dns) > 0 || len(search) > 0 {
ns := map[string]any{}
if len(dns) > 0 {
ns["addresses"] = dns
}
if len(search) > 0 {
ns["search"] = search
}
e["nameservers"] = ns
}
}
physical := func(n invNIC, pi *PlanIface) map[string]any {
e := map[string]any{}
if n.IB {
e["match"] = map[string]any{"name": n.Name}
} else {
e["match"] = map[string]any{"macaddress": n.MAC}
e["set-name"] = n.Name
if pi != nil && pi.Name != "" {
e["set-name"] = pi.Name
}
}
return e
}
for _, pi := range p.Ifaces {
switch pi.Type {
case "physical":
if len(pi.nics) == 0 {
continue
}
e := physical(pi.nics[0], &pi)
fill(e, pi)
eth[pi.Name] = e
case "bond":
for _, n := range pi.nics {
e := physical(n, nil)
e["dhcp4"] = false
e["accept-ra"] = false
if pi.MTU > 0 {
e["mtu"] = pi.MTU
}
eth[n.Name] = e
}
params := map[string]any{"mode": pi.BondMode, "mii-monitor-interval": 100}
if pi.BondMode == "802.3ad" {
params["lacp-rate"] = "fast"
params["transmit-hash-policy"] = "layer3+4"
}
e := map[string]any{"interfaces": pi.Members, "parameters": params}
fill(e, pi)
bonds[pi.Name] = e
case "vlan":
e := map[string]any{"id": pi.VID, "link": pi.Parent}
fill(e, pi)
vlans[pi.Name] = e
}
}
nw := map[string]any{"version": 2}
if len(eth) > 0 {
nw["ethernets"] = eth
}
if len(bonds) > 0 {
nw["bonds"] = bonds
}
if len(vlans) > 0 {
nw["vlans"] = vlans
}
return map[string]any{"network": nw}
}
// linkRules pins every physical NIC of the plan to its name with a udev rule.
// Cloud images of the RHEL family boot with net.ifnames=0 (eth0, eth1, …)
// and cloud-init cannot rename a NIC to a name the kernel already gave it as
// an alternative name; a NAME= rule applies at boot, before any of that.
func linkRules(p *NetPlan) string {
var b strings.Builder
seen := map[string]bool{}
add := func(n invNIC, name string) {
if n.IB || n.MAC == "" || seen[n.MAC] {
return
}
seen[n.MAC] = true
fmt.Fprintf(&b, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n", n.MAC, name)
}
for _, pi := range p.Ifaces {
switch pi.Type {
case "physical":
if len(pi.nics) > 0 {
add(pi.nics[0], pi.Name)
}
case "bond":
for _, n := range pi.nics {
add(n, n.Name)
}
}
}
if b.Len() == 0 {
return ""
}
return "# written by rollcall at deploy time: NIC names as the network configuration expects them\n" + b.String()
}
// layoutFor picks a machine's layout: its own, else the first of its tags that names one.
func (a *App) layoutFor(ctx context.Context, m *Machine) (*NetLayout, error) {
layouts, err := a.store.Layouts(ctx)
if err != nil {
return nil, err
}
find := func(name string) *NetLayout {
for i := range layouts {
if layouts[i].Name == name {
return &layouts[i]
}
}
return nil
}
if m.NetLayout != "" {
if l := find(m.NetLayout); l != nil {
return l, nil
}
return nil, fmt.Errorf("layout %q does not exist", m.NetLayout)
}
tags, err := a.store.Tags(ctx)
if err != nil {
return nil, err
}
eff := effectiveTags(m)
for _, t := range tags {
if t.NetLayout != "" && hasTag(eff, t.Name) {
if l := find(t.NetLayout); l != nil {
cp := *l
cp.Name = l.Name + " (tag " + t.Name + ")"
return &cp, nil
}
}
}
return nil, nil
}
// netPlan resolves a machine's interfaces; with allocate it hands out the
// addresses its auto links still lack.
func (a *App) netPlan(ctx context.Context, m *Machine, allocate bool) (*NetPlan, error) {
l, err := a.layoutFor(ctx, m)
if err != nil {
return nil, err
}
subnets, err := a.store.Subnets(ctx)
if err != nil {
return nil, err
}
have, err := a.store.IPs(ctx, "", m.ID)
if err != nil {
return nil, err
}
nics := machineNICs(m)
var alloc func(Subnet, string) (string, error)
if allocate {
host := deref(m.NodeName)
alloc = func(sn Subnet, iface string) (string, error) { return a.allocIP(ctx, sn, m.ID, iface, host) }
}
return resolvePlan(l, nics, bootNIC(nics, m.MACs), subnets, have, alloc), nil
}
// ---------------------------------------------------------------- dnsmasq
const dnsmasqBase = `# generated by rollcall for rack controller %s; do not edit (rollcall subnet/vlan commands change it)
bind-dynamic
%sdomain-needed
bogus-priv
expand-hosts
log-dhcp
enable-tftp
tftp-root=%s
dhcp-hostsfile=%s/hosts.dhcp
addn-hosts=%s/hosts
`
// dnsmasqConfig renders a rack controller's dnsmasq configuration from the
// VLANs it serves; ok is false when it serves none (the file is then left out
// and dnsmasq keeps whatever configuration it has).
func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, bool) {
var served []Subnet
for _, sn := range subnets {
if sn.DHCP && (sn.PrimaryRack == rc.Name || sn.SecondaryRack == rc.Name) {
served = append(served, sn)
}
}
if len(served) == 0 {
return "", false
}
ifaces := rc.DHCPInterfaces
var relayed []string
if len(ifaces) == 0 {
for _, sn := range served {
local := false
for _, ri := range rc.Interfaces {
for _, ad := range ri.Addrs {
if sn.contains(ad) {
local = true
if !hasTag(ifaces, ri.Name) {
ifaces = append(ifaces, ri.Name)
}
}
}
}
if !local {
relayed = append(relayed, sn.CIDR)
}
}
}
var lines strings.Builder
for _, i := range ifaces {
lines.WriteString("interface=" + i + "\n")
}
tftp := rc.TFTPRoot
if tftp == "" {
tftp = "/srv/tftp"
}
var b strings.Builder
fmt.Fprintf(&b, dnsmasqBase, rc.Name, lines.String(), tftp, dnsDir, dnsDir)
for _, cidr := range relayed {
fmt.Fprintf(&b, "# %s has no local interface here: it is served through a DHCP relay; list the interface\n# facing the relay in the rack's dhcp_interfaces\n", cidr)
}
shared := false
domains := []string{}
for _, sn := range served {
shared = shared || sn.SecondaryRack != "" || sn.DHCPMode == "proxy"
if sn.Domain != "" && !hasTag(domains, sn.Domain) {
domains = append(domains, sn.Domain)
}
}
if !shared {
b.WriteString("dhcp-authoritative\n")
}
// IPv6: router advertisements carry the prefix; the rack only offers
// itself as the default router when it is the subnet's gateway
v6ifaces := map[string]bool{}
routerOn := map[string]bool{}
for _, sn := range served {
if familyOf(sn.CIDR) != 6 {
continue
}
for _, ri := range rc.Interfaces {
for _, ad := range ri.Addrs {
if sn.contains(ad) {
v6ifaces[ri.Name] = true
if stripPrefix(ad) == sn.Gateway {
routerOn[ri.Name] = true
}
}
}
}
}
if len(v6ifaces) > 0 {
b.WriteString("enable-ra\n")
for _, i := range sortedKeys(v6ifaces) {
if !routerOn[i] {
fmt.Fprintf(&b, "ra-param=%s,10,0\n", i)
}
}
}
for _, d := range domains {
fmt.Fprintf(&b, "local=/%s/\n", d)
}
// architecture tags for the boot files of served subnets; left out when every
// subnet is proxied (pxe-service picks by architecture itself, and these shared
// tags are part of the cyclic tag list dnsmasq 2.91 builds in proxy mode)
for _, sn := range served {
if sn.DHCPMode != "proxy" && familyOf(sn.CIDR) == 4 {
b.WriteString("dhcp-match=set:efi64,option:client-arch,7\ndhcp-match=set:efi64,option:client-arch,9\ndhcp-match=set:bios,option:client-arch,0\n")
break
}
}
pxeDone := map[string]bool{}
for i, sn := range served {
tag := fmt.Sprintf("s%d", i)
v6 := familyOf(sn.CIDR) == 6
if sn.DHCPMode == "proxy" {
// another DHCP server hands out addresses (and router, DNS); this rack only
// answers PXE clients with where to boot from. Proxy DHCP is IPv4 (PXE is).
if v6 {
continue
}
_, n, _ := net.ParseCIDR(sn.CIDR)
fmt.Fprintf(&b, "\n# %s · %s vlan %d · %s · proxy DHCP (addresses from the site's DHCP server)\n", sn.CIDR, orDash(sn.Fabric), sn.VID, sn.Purpose)
// No set: tag on a proxy range. dnsmasq (2.91, still in 2.93) appends a request's
// tags to the context's persistent tag list in its proxy path without resetting it:
// the next request's stack-allocated interface tag ends up pointing at itself and
// dnsmasq loops forever (option_filter, rfc2131.c proxy branch). The PXE services
// are limited by the interface tag instead, which is rebuilt for every request.
fmt.Fprintf(&b, "dhcp-range=%s,proxy,%s\n", n.IP.String(), sn.netmask())
var on []string
for _, ri := range rc.Interfaces {
for _, ad := range ri.Addrs {
if sn.contains(ad) && !hasTag(on, ri.Name) {
on = append(on, ri.Name)
}
}
}
if len(on) == 0 {
on = []string{""} // relayed: untagged
}
for _, i := range on {
if pxeDone[i] {
continue
}
pxeDone[i] = true
t := ""
if i != "" {
t = "tag:" + i + ","
}
fmt.Fprintf(&b, "pxe-service=%sx86PC,\"Rollcall\",pxelinux\n", t)
fmt.Fprintf(&b, "pxe-service=%sBC_EFI,\"Rollcall\",efi64/syslinux.efi\n", t)
fmt.Fprintf(&b, "pxe-service=%sX86-64_EFI,\"Rollcall\",efi64/syslinux.efi\n", t)
}
continue
}
fmt.Fprintf(&b, "\n# %s · %s vlan %d · %s\n", sn.CIDR, orDash(sn.Fabric), sn.VID, sn.Purpose)
if !v6 {
// boot files per served subnet: dhcp-boot matching a proxy subnet's clients as
// well as its pxe-service sends dnsmasq 2.91 into a busy loop (lab, 2026-10-29)
fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:bios,pxelinux.0\n", tag)
fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:efi64,efi64/syslinux.efi\n", tag)
}
if sn.Domain != "" {
fmt.Fprintf(&b, "domain=%s,%s\n", sn.Domain, sn.CIDR)
}
mask := sn.netmask()
if v6 {
mask = strconv.Itoa(sn.bits())
}
dyn := 0
for _, r := range sn.Ranges {
if r.Kind != "dynamic" {
continue
}
lo, ok1 := parseAddr(r.Start)
hi, ok2 := parseAddr(r.End)
if !ok1 || !ok2 || hi.Less(lo) {
continue
}
// two racks on one VLAN: each serves its own half of the pool
if sn.SecondaryRack != "" && lo.Is4() {
l4, h4 := lo.As4(), hi.As4()
a, z := binary.BigEndian.Uint32(l4[:]), binary.BigEndian.Uint32(h4[:])
mid := a + (z-a)/2
if rc.Name == sn.PrimaryRack {
z = mid
} else {
a = mid + 1
}
var bl, bh [4]byte
binary.BigEndian.PutUint32(bl[:], a)
binary.BigEndian.PutUint32(bh[:], z)
lo, hi = netip.AddrFrom4(bl), netip.AddrFrom4(bh)
}
fmt.Fprintf(&b, "dhcp-range=set:%s,%s,%s,%s,12h\n", tag, lo, hi, mask)
dyn++
}
if dyn == 0 {
// static leases only (known machines), no pool for strangers
_, n, _ := net.ParseCIDR(sn.CIDR)
fmt.Fprintf(&b, "dhcp-range=set:%s,%s,static,%s,12h\n", tag, n.IP.String(), mask)
}
if v6 {
if len(sn.DNS) > 0 {
var ds []string
for _, d := range sn.DNS {
if a, ok := parseAddr(d); ok && a.Is6() {
ds = append(ds, "["+a.String()+"]")
}
}
if len(ds) > 0 {
fmt.Fprintf(&b, "dhcp-option=tag:%s,option6:dns-server,%s\n", tag, strings.Join(ds, ","))
}
}
if sn.Domain != "" {
fmt.Fprintf(&b, "dhcp-option=tag:%s,option6:domain-search,%s\n", tag, sn.Domain)
}
continue
}
if sn.Gateway != "" {
fmt.Fprintf(&b, "dhcp-option=tag:%s,option:router,%s\n", tag, sn.Gateway)
}
var ds []string
for _, d := range sn.DNS {
if a, ok := parseAddr(d); ok && a.Is4() {
ds = append(ds, a.String())
}
}
if len(ds) > 0 {
fmt.Fprintf(&b, "dhcp-option=tag:%s,option:dns-server,%s\n", tag, strings.Join(ds, ","))
}
if sn.MTU > 0 && sn.MTU != 1500 {
fmt.Fprintf(&b, "dhcp-option=tag:%s,option:mtu,%d\n", tag, sn.MTU)
}
}
return b.String(), true
}
var errSubnetUnknown = errors.New("subnet not found")
func hasStaticRange(sn Subnet) bool {
for _, r := range sn.Ranges {
if r.Kind == "static" {
return true
}
}
return false
}