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>
1463 lines
42 KiB
Go
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
|
|
}
|