Proxy DHCP for networks whose DHCP server stays in charge
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 36s

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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 09:04:10 +01:00
1 parent 56eae695d9
commit 7a007ef048
14 files changed
+266 -37

No files matched your search

+10
View File
@@ -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
+1 -1
View File
@@ -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
+25
View File
@@ -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
+1 -1
View File
@@ -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)"
}
+17 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+15 -4
View File
@@ -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)
+94 -14
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+9 -2
View File
@@ -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 != "":
+1
View File
@@ -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,
+3 -3
View File
@@ -696,7 +696,7 @@ function pageNetwork() {
const sizeTxt = s.size ? s.size : '∞';
const ranges = (s.ranges || []).map(r => `<span class="pill tag" title="${esc(r.comment || '')}">${esc(r.kind)} ${esc(r.start)}–${esc(r.end.split('.').pop())} <a href="#" data-rmrange="${r.id}" title="remove">×</a></span>`).join(' ');
return [`<b class="mono">${esc(s.cidr)}</b>${s.name ? `<br><span class="muted">${esc(s.name)}</span>` : ''}`, esc(s.purpose), `${esc(s.fabric || '—')} / ${s.vid || 'untagged'}${s.mtu && s.mtu !== 1500 ? ` · mtu ${s.mtu}` : ''}`,
s.dhcp ? `<span class="ok">on</span> · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : '<span class="muted">off</span>', `<span class="mono">${esc(s.gateway || '—')}</span>`,
s.dhcp ? `<span class="ok">${s.dhcp_mode === 'proxy' ? 'proxy' : 'on'}</span> · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : '<span class="muted">off</span>', `<span class="mono">${esc(s.gateway || '—')}</span>`,
`<span class="mono">${esc((s.dns || []).join(', '))}</span>${s.domain ? `<br><span class="muted">${esc(s.domain)}</span>` : ''}`, `<div class="bar" title="${s.used}/${s.size}"><span style="width:${pct}%"></span></div><span class="muted">${s.used} / ${sizeTxt}</span>${s.family === 6 ? ' <span class="pill tag">IPv6</span>' : ''}`,
ranges + ` <button class="btn sm" data-addrange="${esc(s.cidr)}">+ range</button>`, `<button class="btn sm" data-ips="${esc(s.cidr)}">addresses</button> <button class="btn sm" data-editsubnet="${esc(s.cidr)}">edit</button> <button class="btn sm danger" data-rmsubnet="${esc(s.cidr)}">delete</button>`];
}));
@@ -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); }
}
+1 -1
View File
@@ -130,7 +130,7 @@ function topoSVG(opt = {}) {
o.push(`<line x1="${M + 250}" y1="${y}" x2="${W - M}" y2="${y}"/>`);
o.push(`<text class="t1" x="${M}" y="${y - 4}">${esc((g.fabric || 'no fabric') + ' · ' + (g.vid ? 'vlan ' + g.vid : 'untagged'))}</text>`);
if (!compact) o.push(`<text class="t2" x="${M}" y="${y + 11}">${esc([...v4, ...v6].map(s => s.cidr).join(' '))}</text>`);
o.push(`<text class="t3" x="${W - M}" y="${y - 5}" text-anchor="end">${esc(g.purpose)}${g.dhcp ? ' · DHCP/PXE' : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr</text>`);
o.push(`<text class="t3" x="${W - M}" y="${y - 5}" text-anchor="end">${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</text>`);
o.push('</g>');
});
// region + external IPAM
+22 -8
View File
@@ -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