From 7a007ef0480bdf2eb874d0d998a6f1a0b857e8eb Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Thu, 29 Oct 2026 09:04:10 +0100 Subject: [PATCH] Proxy DHCP for networks whose DHCP server stays in charge A VLAN in proxy mode leaves addresses to the site's DHCP server; the rack only answers PXE (dnsmasq proxy range + pxe-service), machines keep DHCP unless a static range is set aside. CLI -proxy-dhcp, web UI mode select, bootstrap --proxy-dhcp, doctor/discover aware. dnsmasq 2.91 (and 2.93) loops forever in its proxy path when the proxy range carries a set: tag: option_filter appends a request's tags to the context's persistent list, so the next request's stack interface tag points at itself (found with gdb on the lab rack). Proxy ranges are untagged and PXE services limited by interface; dhcp-boot/dhcp-match only for served subnets; no dhcp-host lines for proxied ones. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++ INSTALL.md | 2 +- README.md | 25 ++++++++ cmd/rollcall/discover.go | 2 +- cmd/rollcall/doctor.go | 19 +++++- cmd/rollcall/main.go | 2 +- cmd/rollcall/netapi.go | 19 ++++-- cmd/rollcall/network.go | 108 ++++++++++++++++++++++++++++++----- cmd/rollcall/network_test.go | 66 +++++++++++++++++++++ cmd/rollcall/rackctl.go | 11 +++- cmd/rollcall/store.go | 1 + cmd/rollcall/web/app.js | 6 +- cmd/rollcall/web/topo.js | 2 +- deploy/bootstrap.sh | 30 +++++++--- 14 files changed, 266 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f313d..78bc53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ publish a tag that has no section here. ## [Unreleased] +### Added +- Proxy DHCP: a VLAN in `proxy` mode (`rollcall vlan add ... -proxy-dhcp`, web UI, `bootstrap.sh + --proxy-dhcp`) leaves addresses to the site's DHCP server and only answers PXE; machines keep DHCP + unless a static range is set aside; doctor and discover know the mode. + +### Fixed +- dnsmasq busy loop in proxy mode (a dnsmasq bug with tagged proxy ranges, found with gdb): proxy + ranges are untagged, PXE services limited by interface; `dhcp-boot`/`dhcp-match` only for served + subnets, no `dhcp-host` lines for proxied ones. + ## [0.2.1] - 2026-10-28 ### Fixed diff --git a/INSTALL.md b/INSTALL.md index 0673bc8..6d9cf89 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -74,7 +74,7 @@ competes with Rollcall for the machines' PXE boot. |---|---| | An interface with an address and no DHCP | the provisioning network: next step | | Link up, no address, LLDP shows the VLAN | give the interface its address on that VLAN first | -| Another DHCP server on the provisioning VLAN | the network team turns it off for that VLAN, or keeps it and points it at this host (next-server, or a DHCP relay), then `--no-dhcp` | +| Another DHCP server on the provisioning VLAN | keep it: `--proxy-dhcp` makes this host answer only PXE, the site's server keeps the addresses (or the network team turns it off for that VLAN) | | No link | cable it | ## Add the provisioning network diff --git a/README.md b/README.md index 2ffad6b..02b4d4c 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,31 @@ The lab runs both: `pxe-server` (embedded, 10.213.77.0/24) and `pxe-rack2` pxe-rack2 and its virtual BMC on pxe-rack2. Rack ↔ region traffic is mutual TLS (next section), so the BMC passwords of queued commands are encrypted. +### DHCP already on the network: proxy DHCP + +Where the site's DHCP server keeps handing out addresses (a corporate DHCP, +Infoblox, a router), a VLAN can be served in **proxy** mode: the rack +controller does not hand out addresses, it only answers PXE clients with where +to boot from (dnsmasq proxy DHCP), and TFTP and HTTP stay on the rack. + +``` +rollcall vlan add FABRIC VID -proxy-dhcp -rack RACK # web UI: subnet, DHCP / PXE: proxy +deploy/bootstrap.sh --iface eth1 --proxy-dhcp # the same at install time +``` + +- The subnet needs no ranges. Without a static range, deployed machines keep + getting their addresses from the site's server (`dhcp` links); set a static + range aside, outside the site's pool, to give them fixed ones. +- No `dhcp-host` lines are written for proxy subnets, and the router and DNS + options come from the site's server. +- `rollcall doctor` expects the foreign DHCP server here, and fails if none answers. +- dnsmasq (2.91, still in 2.93) loops forever in its proxy code when a proxy + range carries a `set:` tag: Rollcall's generated configuration leaves the tag + out and limits the PXE services by interface instead. + +Verified in the lab with a separate DHCP server on the provisioning network: +commissioning and three deployments in a row through the proxy. + ## Certificate authority and TLS The region is the installation's certificate authority, as the llmgw control diff --git a/cmd/rollcall/discover.go b/cmd/rollcall/discover.go index 10a2999..eb4d7a6 100644 --- a/cmd/rollcall/discover.go +++ b/cmd/rollcall/discover.go @@ -195,7 +195,7 @@ func discoSuggest(d discoIface) string { case v4 == "": return "link up, no IPv4 address" + vlan + ": give it its address on the provisioning network, then bootstrap.sh --iface " + d.Name case foreign > 0: - return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): either the network team turns it off for this VLAN, or keep it and point it at this host (next-server " + strings.Split(d.Addrs[0], "/")[0] + ", or a DHCP relay); then bootstrap.sh --iface " + d.Name + " --no-dhcp" + return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): keep it and answer only PXE from here - bootstrap.sh --iface " + d.Name + " --proxy-dhcp - or the network team turns it off for this VLAN" default: return "candidate provisioning network " + v4 + vlan + ": bootstrap.sh --iface " + d.Name + " (or Network -> New subnet in the web UI)" } diff --git a/cmd/rollcall/doctor.go b/cmd/rollcall/doctor.go index c63ec41..6485862 100644 --- a/cmd/rollcall/doctor.go +++ b/cmd/rollcall/doctor.go @@ -212,7 +212,18 @@ func doctorCmd(args []string) error { } } - // other DHCP servers on the provisioning network + // other DHCP servers on the provisioning network (expected when this rack proxies) + proxying := false + if c, err := newClient(); err == nil { + var subs []Subnet + if c.do("GET", "/api/v1/subnets", nil, &subs) == nil { + for _, s := range subs { + if s.DHCPMode == "proxy" && (s.PrimaryRack == cfg.RackName || s.SecondaryRack == cfg.RackName) { + proxying = true + } + } + } + } if iface != "" && !*noProbe && !ports["udp/67"] { d.ok("dhcp probe", "skipped: this host serves no DHCP yet") } else if iface != "" && !*noProbe { @@ -228,9 +239,13 @@ func doctorCmd(args []string) error { } } switch { + case proxying && len(foreign) > 0: + d.ok("dhcp probe", "the site's DHCP server "+strings.Join(foreign, ", ")+" hands out addresses; this host answers PXE (proxy)") + case proxying: + d.fail("dhcp probe", "proxy DHCP, but no DHCP server answers on "+iface, "machines would get no address: the site's DHCP server must serve this VLAN (or a relay must reach it)") case len(foreign) > 0: d.fail("dhcp probe", "another DHCP server answers on "+iface+": "+strings.Join(foreign, ", "), - "machines may PXE-boot from it; switch it off for this VLAN, or use --no-dhcp and let it hand out next-server "+srvHost) + "machines may PXE-boot from it: switch it off for this VLAN, or keep it and proxy (bootstrap.sh --iface IFACE --proxy-dhcp, or DHCP mode proxy in the web UI)") case len(servers) == 0: d.warn("dhcp probe", "no DHCP server answered on "+iface, "is a VLAN with dhcp assigned to this rack? (rollcall vlan add ... -dhcp)") default: diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index 9c253eb..094829a 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -60,7 +60,7 @@ func usage() { rollcall location racks | rack NAME -building B -room R [-height 42] rollcall list [-tag T] [-building B] [-room R] [-rack K] rollcall fabric list | add NAME [-kind ethernet|infiniband] | rm NAME - rollcall vlan list | add FABRIC VID [-dhcp] [-rack R] [-secondary R] [-mtu N] [-name N] | rm FABRIC VID + rollcall vlan list | add FABRIC VID [-dhcp | -proxy-dhcp] [-rack R] [-secondary R] [-mtu N] [-name N] | rm FABRIC VID rollcall subnet list | show CIDR | add CIDR -fabric F [-vid N] [-purpose P] [-gateway G] [-dns a,b] [-domain D] | rm CIDR [-force] rollcall range add CIDR dynamic|reserved|static START END | rm ID rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP diff --git a/cmd/rollcall/netapi.go b/cmd/rollcall/netapi.go index ee9dd8e..f09efae 100644 --- a/cmd/rollcall/netapi.go +++ b/cmd/rollcall/netapi.go @@ -71,6 +71,12 @@ func (a *App) handleVLANs(w http.ResponseWriter, r *http.Request) { fail(w, 400, errors.New("fabric and vid (0 untagged, 1-4094, or an IB pkey) are required")) return } + if v.DHCPMode == "proxy" { + v.DHCP = true + } else if v.DHCPMode != "" && v.DHCPMode != "serve" { + fail(w, 400, fmt.Errorf("dhcp_mode %q: serve or proxy", v.DHCPMode)) + return + } if v.DHCP && v.PrimaryRack == "" { v.PrimaryRack = a.cfg.RackName } @@ -511,9 +517,9 @@ func (c *client) netCmd(cmd string, args []string) error { if err := c.do("GET", "/api/v1/vlans", nil, &vs); err != nil { return err } - fmt.Printf("%-14s %5s %-14s %5s %-5s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY") + fmt.Printf("%-14s %5s %-14s %5s %-6s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY") for _, v := range vs { - fmt.Printf("%-14s %5d %-14s %5d %-5v %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack) + fmt.Printf("%-14s %5d %-14s %5d %-6s %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, orDash(v.DHCPMode), v.PrimaryRack, v.SecondaryRack) } case "add", "set": need(3) @@ -521,15 +527,20 @@ func (c *client) netCmd(cmd string, args []string) error { name := fs.String("name", "", "name") mtu := fs.Int("mtu", 1500, "MTU") dhcp := fs.Bool("dhcp", false, "serve DHCP/PXE on this VLAN") + proxy := fs.Bool("proxy-dhcp", false, "another DHCP server hands out addresses here; only answer PXE (proxy DHCP)") rack := fs.String("rack", "", "rack controller serving it (default: the region's embedded one)") sec := fs.String("secondary", "", "second rack controller (the dynamic range is split)") _ = fs.Parse(args[3:]) vid, _ := strconv.Atoi(args[2]) var v VLAN - if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil { + mode := "" + if *proxy { + mode = "proxy" + } + if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp || *proxy, DHCPMode: mode, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil { return err } - fmt.Printf("vlan %s/%d: dhcp=%v rack=%s\n", v.Fabric, v.VID, v.DHCP, v.PrimaryRack) + fmt.Printf("vlan %s/%d: dhcp=%s rack=%s\n", v.Fabric, v.VID, orDash(v.DHCPMode), v.PrimaryRack) case "rm": need(3) return c.do("DELETE", "/api/v1/vlans/"+args[1]+"/"+args[2], nil, nil) diff --git a/cmd/rollcall/network.go b/cmd/rollcall/network.go index bcc52af..7e5247c 100644 --- a/cmd/rollcall/network.go +++ b/cmd/rollcall/network.go @@ -33,7 +33,8 @@ type VLAN struct { 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"` // rollcall serves DHCP/PXE on it + 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 } @@ -55,6 +56,7 @@ type Subnet struct { 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 @@ -140,7 +142,7 @@ func (s *Store) DeleteFabric(ctx context.Context, name string) error { } func (s *Store) VLANs(ctx context.Context) ([]VLAN, error) { - rows, err := s.pool.Query(ctx, `SELECT id, fabric, vid, coalesce(name,''), mtu, dhcp, coalesce(primary_rack,''), coalesce(secondary_rack,'') FROM vlans ORDER BY fabric, vid`) + 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 } @@ -148,23 +150,35 @@ func (s *Store) VLANs(ctx context.Context) ([]VLAN, error) { 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.PrimaryRack, &v.SecondaryRack); err != nil { + 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) VALUES ($1,$2,$3,$4,$5,nullif($6,''),nullif($7,'')) - ON CONFLICT (fabric, vid) DO UPDATE SET name=$3, mtu=$4, dhcp=$5, primary_rack=nullif($6,''), secondary_rack=nullif($7,'') RETURNING id`, - v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack).Scan(&id) + 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 } @@ -175,7 +189,7 @@ func (s *Store) DeleteVLAN(ctx context.Context, fabric string, vid int) error { 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.primary_rack,''), coalesce(v.secondary_rack,''), s.purpose, coalesce(s.gateway,''), s.dns, + 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 { @@ -186,11 +200,12 @@ func (s *Store) Subnets(ctx context.Context) ([]Subnet, error) { 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.PrimaryRack, &sn.SecondaryRack, + 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{} } @@ -719,6 +734,11 @@ type NetPlan struct { // 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 { @@ -1201,11 +1221,6 @@ expand-hosts log-dhcp enable-tftp tftp-root=%s -dhcp-match=set:efi64,option:client-arch,7 -dhcp-match=set:efi64,option:client-arch,9 -dhcp-match=set:bios,option:client-arch,0 -dhcp-boot=tag:bios,pxelinux.0 -dhcp-boot=tag:efi64,efi64/syslinux.efi dhcp-hostsfile=%s/hosts.dhcp addn-hosts=%s/hosts ` @@ -1259,7 +1274,7 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, shared := false domains := []string{} for _, sn := range served { - shared = shared || sn.SecondaryRack != "" + shared = shared || sn.SecondaryRack != "" || sn.DHCPMode == "proxy" if sn.Domain != "" && !hasTag(domains, sn.Domain) { domains = append(domains, sn.Domain) } @@ -1297,10 +1312,66 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, 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) } @@ -1378,3 +1449,12 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string, } 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 +} diff --git a/cmd/rollcall/network_test.go b/cmd/rollcall/network_test.go index 4da5246..86e26e5 100644 --- a/cmd/rollcall/network_test.go +++ b/cmd/rollcall/network_test.go @@ -289,3 +289,69 @@ func TestStaleJobs(t *testing.T) { t.Errorf("stale jobs: %+v", got) } } + +func TestProxyDHCP(t *testing.T) { + sn := labSubnet() + sn.DHCPMode = "proxy" + sn.Ranges = nil + rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}}} + conf, ok := dnsmasqConfig(rc, []Subnet{sn}, "/d") + if !ok { + t.Fatal("no config") + } + for _, want := range []string{"interface=eth1", "dhcp-range=10.0.0.0,proxy,255.255.255.0", `pxe-service=tag:eth1,x86PC,"Rollcall",pxelinux`, `pxe-service=tag:eth1,X86-64_EFI,"Rollcall",efi64/syslinux.efi`} { + if !strings.Contains(conf, want) { + t.Errorf("proxy config lacks %s:\n%s", want, conf) + } + } + for _, bad := range []string{"option:router", "option:dns-server", "dhcp-authoritative", ",12h", "set:s0,10.0.0.0,proxy"} { + if strings.Contains(conf, bad) { + t.Errorf("proxy config has %q (the site's server owns that):\n%s", bad, conf) + } + } + // machines keep DHCP from the site's server unless a static range is set aside + if l := defaultLayout(&sn, []Subnet{sn}); l.Interfaces[0].Links[0].Mode != "dhcp" { + t.Errorf("proxy subnet without a static range: %+v", l.Interfaces[0].Links) + } + sn.Ranges = []IPRange{{Kind: "static", Start: "10.0.0.50", End: "10.0.0.99"}} + if l := defaultLayout(&sn, []Subnet{sn}); l.Interfaces[0].Links[0].Mode != "auto" { + t.Errorf("proxy subnet with a static range: %+v", l.Interfaces[0].Links) + } + if dhcpMode(true, "") != "serve" || dhcpMode(false, "proxy") != "" || dhcpMode(true, "proxy") != "proxy" { + t.Error("dhcpMode") + } +} + +func TestDhcpBootOnlyForServedSubnets(t *testing.T) { + serve := labSubnet() + proxy := labSubnet() + proxy.CIDR, proxy.Gateway, proxy.DHCPMode, proxy.Ranges = "10.9.0.0/24", "", "proxy", nil + rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}, {Name: "eth2", Addrs: []string{"10.9.0.2/24"}}}} + conf, _ := dnsmasqConfig(rc, []Subnet{serve, proxy}, "/d") + if !strings.Contains(conf, "dhcp-boot=tag:s0,tag:bios,pxelinux.0") || !strings.Contains(conf, "dhcp-boot=tag:s0,tag:efi64,efi64/syslinux.efi") { + t.Errorf("served subnet lacks its boot files:\n%s", conf) + } + for _, l := range strings.Split(conf, "\n") { + if strings.HasPrefix(l, "dhcp-boot=") && !strings.HasPrefix(l, "dhcp-boot=tag:s0,") { + t.Errorf("dhcp-boot not limited to the served subnet: %s", l) + } + } + if !strings.Contains(conf, "pxe-service=tag:eth2,x86PC") || strings.Contains(conf, "set:s1,10.9.0.0,proxy") { + t.Errorf("proxy subnet lacks pxe-service:\n%s", conf) + } +} + +func TestProxyOnlyConfigHasNoArchTags(t *testing.T) { + sn := labSubnet() + sn.DHCPMode, sn.Ranges = "proxy", nil + rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}}} + conf, _ := dnsmasqConfig(rc, []Subnet{sn}, "/d") + if strings.Contains(conf, "dhcp-match=") || strings.Contains(conf, "dhcp-boot=") { + t.Errorf("proxy-only config has arch tags or dhcp-boot:\n%s", conf) + } + serve := labSubnet() + conf, _ = dnsmasqConfig(rc, []Subnet{serve}, "/d") + if !strings.Contains(conf, "dhcp-match=set:bios,option:client-arch,0") { + t.Errorf("served subnet lacks arch tags:\n%s", conf) + } +} diff --git a/cmd/rollcall/rackctl.go b/cmd/rollcall/rackctl.go index c94b15a..1a9746e 100644 --- a/cmd/rollcall/rackctl.go +++ b/cmd/rollcall/rackctl.go @@ -507,6 +507,13 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st fmt.Fprintf(&hosts, "%s %s\n", ip, name) } } + // proxy subnets: the site's DHCP server hands out the addresses, so no dhcp-host + // lines for them (they would do nothing, and the "known" tag they give a client + // helps dnsmasq 2.91 build a cyclic tag list in its PXE proxy code: busy loop) + proxied := func(ip string) bool { + sn := subnetFor(subnets, ip) + return sn != nil && sn.DHCPMode == "proxy" + } sort.Slice(ms, func(i, j int) bool { return deref(ms[i].NodeName) < deref(ms[j].NodeName) }) for _, m := range ms { if m.NodeName == nil || len(m.MACs) == 0 { @@ -522,7 +529,7 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st } if mgmt != "" { line(mgmt, name) - if mine && familyOf(mgmt+"/32") == 4 { + if mine && familyOf(mgmt+"/32") == 4 && !proxied(mgmt) { fmt.Fprintf(&dhcp, "%s,%s,%s\n", m.MACs[0], mgmt, name) } } @@ -534,7 +541,7 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st switch { case x.Iface == mgmtIface && mgmtIface != "": line(x.IP, name) - if v6 && mine { + if v6 && mine && !proxied(x.IP) { fmt.Fprintf(&dhcp, "%s,[%s],%s\n", m.MACs[0], x.IP, name) } case x.Iface != "": diff --git a/cmd/rollcall/store.go b/cmd/rollcall/store.go index cde694a..02d4029 100644 --- a/cmd/rollcall/store.go +++ b/cmd/rollcall/store.go @@ -166,6 +166,7 @@ CREATE TABLE IF NOT EXISTS rack_controllers ( ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_serial text; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_expires timestamptz; ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS arch text; +ALTER TABLE vlans ADD COLUMN IF NOT EXISTS dhcp_mode text NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS join_tokens ( hash text PRIMARY KEY, name text, diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index 8a71b5f..de6c20d 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -696,7 +696,7 @@ function pageNetwork() { const sizeTxt = s.size ? s.size : '∞'; const ranges = (s.ranges || []).map(r => `${esc(r.kind)} ${esc(r.start)}–${esc(r.end.split('.').pop())} ×`).join(' '); return [`${esc(s.cidr)}${s.name ? `
${esc(s.name)}` : ''}`, esc(s.purpose), `${esc(s.fabric || '—')} / ${s.vid || 'untagged'}${s.mtu && s.mtu !== 1500 ? ` · mtu ${s.mtu}` : ''}`, - s.dhcp ? `on · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : 'off', `${esc(s.gateway || '—')}`, + s.dhcp ? `${s.dhcp_mode === 'proxy' ? 'proxy' : 'on'} · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : 'off', `${esc(s.gateway || '—')}`, `${esc((s.dns || []).join(', '))}${s.domain ? `
${esc(s.domain)}` : ''}`, `
${s.used} / ${sizeTxt}${s.family === 6 ? ' IPv6' : ''}`, ranges + ` `, ` `]; })); @@ -723,7 +723,7 @@ async function editSubnet(s) { { name: 'purpose', label: 'Purpose', type: 'select', value: s ? s.purpose : 'provisioning', options: ['provisioning', 'mgmt', 'bmc', 'storage', 'ib', 'other'].map(x => [x, x]) }, { name: 'gateway', label: 'Gateway', value: s ? s.gateway : '' }, { name: 'dns', label: 'DNS servers', value: s ? (s.dns || []).join(',') : '', placeholder: 'comma separated' }, { name: 'domain', label: 'Domain', value: s ? s.domain : '' }, { name: 'mtu', label: 'VLAN MTU', value: s ? String(s.mtu || 1500) : '1500' }, - { name: 'dhcp', label: 'Serve DHCP/PXE', type: 'checkbox', value: s ? s.dhcp : false }, + { name: 'dhcp', label: 'DHCP / PXE', type: 'select', value: s ? (s.dhcp ? (s.dhcp_mode || 'serve') : 'off') : 'off', options: [['off', 'off: another network, or later'], ['serve', 'serve: this rack hands out addresses and PXE'], ['proxy', 'proxy: the site\'s DHCP server hands out addresses; this rack only answers PXE']] }, { name: 'rack', label: 'Rack controller', type: 'select', value: s ? s.primary_rack : '', options: S.controllers.map(c => [c.name, `${c.name}${c.online ? '' : ' (offline)'}`]) }, ], 'Ranges are added afterwards: dynamic (PXE pool), reserved (never handed out), static (where machines get addresses).', 'Save'); if (!v) return; @@ -732,7 +732,7 @@ async function editSubnet(s) { if (fabric && !S.fabrics.some(f => f.name === fabric)) await api('POST', '/api/v1/fabrics', { name: fabric }); const vid = Number(v.vid) || 0; await api('POST', '/api/v1/subnets', { cidr: v.cidr.trim(), name: v.name, fabric, vid, purpose: v.purpose, gateway: v.gateway.trim(), dns: v.dns.split(',').map(x => x.trim()).filter(Boolean), domain: v.domain.trim() }); - if (fabric) await api('POST', '/api/v1/vlans', { fabric, vid, mtu: Number(v.mtu) || 1500, dhcp: v.dhcp, primary_rack: v.dhcp ? v.rack : '' }); + if (fabric) await api('POST', '/api/v1/vlans', { fabric, vid, mtu: Number(v.mtu) || 1500, dhcp: v.dhcp !== 'off', dhcp_mode: v.dhcp === 'proxy' ? 'proxy' : '', primary_rack: v.dhcp !== 'off' ? v.rack : '' }); toast(`subnet ${v.cidr} saved`); refresh(true); } catch (e) { toast(e.message, true); } } diff --git a/cmd/rollcall/web/topo.js b/cmd/rollcall/web/topo.js index ba2cb7c..1e9ebad 100644 --- a/cmd/rollcall/web/topo.js +++ b/cmd/rollcall/web/topo.js @@ -130,7 +130,7 @@ function topoSVG(opt = {}) { o.push(``); o.push(`${esc((g.fabric || 'no fabric') + ' · ' + (g.vid ? 'vlan ' + g.vid : 'untagged'))}`); if (!compact) o.push(`${esc([...v4, ...v6].map(s => s.cidr).join(' '))}`); - o.push(`${esc(g.purpose)}${g.dhcp ? ' · DHCP/PXE' : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr`); + o.push(`${esc(g.purpose)}${g.dhcp ? (g.subnets.some(s => s.dhcp_mode === 'proxy') ? ' · proxy DHCP (PXE only)' : ' · DHCP/PXE') : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr`); o.push(''); }); // region + external IPAM diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 7e58f02..5096449 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -25,7 +25,9 @@ # --nat UPLINK this host routes for them: forward + masquerade out of UPLINK # (default with --iface and no --gateway: the default route's interface) # --domain D DNS domain handed to machines (default rollcall.internal) -# --no-dhcp model the network but leave DHCP to the existing server +# --no-dhcp model the network but leave DHCP to the existing server (no PXE from here) +# --proxy-dhcp the site's DHCP server keeps handing out addresses; this host only answers +# PXE clients with where to boot from (no ranges, no NAT) # --ssh-key K SSH public key (or a file of them) for root on deployed machines; repeatable. # Default: the keys of the user running sudo, then root's. # --os NAME import one OS image now (e.g. debian-13); a nightly timer syncs the catalog @@ -46,6 +48,7 @@ while [ $# -gt 0 ]; do --os) OS=$2; shift ;; --ssh-key) SSHKEYS+=("$2"); shift ;; --no-dhcp) DHCP=0 ;; + --proxy-dhcp) DHCP=2 ;; --yes|-y) YES=1 ;; -h|--help) sed -n '2,31p' "$0"; exit 0 ;; *) echo "unknown option $1 (--help)" >&2; exit 2 ;; @@ -70,9 +73,10 @@ if [ -n "$IFACE" ]; then [ -n "$CIDR_ADDR" ] || die "$IFACE has no IPv4 address; give it its address on the provisioning network first" read -r ADDR NET PREFIX FIRST <<<"$(python3 -c 'import ipaddress,sys;i=ipaddress.ip_interface(sys.argv[1]);print(i.ip,i.network,i.network.prefixlen,i.network.network_address)' "$CIDR_ADDR")" [ "$PREFIX" -le 24 ] || die "$NET is smaller than a /24; model it by hand (rollcall subnet add / range add)" - if [ -z "$GATEWAY" ]; then GATEWAY=$ADDR; NAT=${NAT:-$UPLINK}; fi + case $DHCP in 1) DHCPTXT="served here" ;; 2) DHCPTXT="proxy (the site's server hands out addresses)" ;; *) DHCPTXT=off ;; esac + if [ -z "$GATEWAY" ] && [ $DHCP != 2 ]; then GATEWAY=$ADDR; NAT=${NAT:-$UPLINK}; fi [ -n "$NAT" ] && [ "$NAT" = "$IFACE" ] && die "--nat must be the uplink, not $IFACE" - echo " provisioning $IFACE $CIDR_ADDR (subnet $NET), machines' gateway $GATEWAY${NAT:+ via NAT out of $NAT}, DHCP $([ $DHCP = 1 ] && echo on || echo off)" + echo " provisioning $IFACE $CIDR_ADDR (subnet $NET), machines' gateway ${GATEWAY:-from the site DHCP server}${NAT:+ via NAT out of $NAT}, DHCP $DHCPTXT" else [ -n "$UPLINK" ] || die "no default route: give this host network access first" ADDR=$(addr_of "$UPLINK" | cut -d/ -f1) @@ -271,17 +275,27 @@ else FAB=provisioning rollcall fabric list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$FAB" || rollcall fabric add "$FAB" >/dev/null if ! rollcall subnet list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$NET"; then - rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning -gateway "$GATEWAY" -dns "$ADDR" -domain "$DOMAIN" -name provisioning >/dev/null - rollcall range add "$NET" reserved "$(off 1)" "$(off 20)" >/dev/null - rollcall range add "$NET" static "$(off 50)" "$(off 99)" >/dev/null - rollcall range add "$NET" dynamic "$(off 100)" "$(off 200)" >/dev/null - ok "subnet $NET: reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)" + if [ $DHCP = 2 ]; then + # the site's DHCP server owns the addresses: no ranges (machines keep DHCP); + # set a static range aside later (rollcall range add) to give them fixed ones + rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning ${GATEWAY:+-gateway "$GATEWAY"} -domain "$DOMAIN" -name provisioning >/dev/null + ok "subnet $NET (addresses from the site's DHCP server)" + else + rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning -gateway "$GATEWAY" -dns "$ADDR" -domain "$DOMAIN" -name provisioning >/dev/null + rollcall range add "$NET" reserved "$(off 1)" "$(off 20)" >/dev/null + rollcall range add "$NET" static "$(off 50)" "$(off 99)" >/dev/null + rollcall range add "$NET" dynamic "$(off 100)" "$(off 200)" >/dev/null + ok "subnet $NET: reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)" + fi else ok "subnet $NET exists, kept" fi if [ $DHCP = 1 ]; then rollcall vlan add "$FAB" 0 -dhcp -rack "$RACK" >/dev/null ok "DHCP/PXE on $IFACE" + elif [ $DHCP = 2 ]; then + rollcall vlan add "$FAB" 0 -proxy-dhcp -rack "$RACK" >/dev/null + ok "proxy DHCP on $IFACE: PXE answers only; addresses from the site's DHCP server" fi if [ -n "$NAT" ]; then echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/60-rollcall-forward.conf