// Package preset holds the on-disk preset model and everything that reads or // writes it. It is deliberately free of Windows-specific code so the whole // model can be exercised by tests on any platform. package preset import ( "fmt" "net/netip" "strings" ) // SchemaVersion is written into every file we save and checked on every file we // load. Bump it only for a breaking change, and add a migration when you do. const SchemaVersion = 1 // Mode is how an adapter gets its addresses. type Mode string const ( ModeStatic Mode = "static" ModeDHCP Mode = "dhcp" ) // File is the top-level shape of presets.json. type File struct { Version int `json:"version"` Presets []Preset `json:"presets"` } // Preset is one saved adapter configuration. type Preset struct { ID string `json:"id"` Name string `json:"name"` // Group drives the tray submenus. Empty means "Ungrouped". Group string `json:"group,omitempty"` // Adapter is the Windows friendly name, e.g. "Ethernet" or "Wi-Fi 2". Adapter string `json:"adapter"` Mode Mode `json:"mode"` // Primary is required when Mode is static and ignored when it is dhcp. Primary *Address `json:"primary,omitempty"` // Secondary addresses are added after the primary is set. Secondary []Address `json:"secondary,omitempty"` DNS DNS `json:"dns"` Notes string `json:"notes,omitempty"` } // Address is a single IPv4 address with its prefix length, and optionally the // default gateway reached through it. type Address struct { Address string `json:"address"` // Prefix is the mask stored as a prefix length. The editor accepts // dotted-quad on input and converts here; see ParsePrefix. Prefix int `json:"prefix"` Gateway string `json:"gateway,omitempty"` // GatewayMetric 0 means automatic, which is what netsh wants for "let // Windows decide". GatewayMetric int `json:"gateway_metric,omitempty"` } // DNS is the resolver configuration for a preset. type DNS struct { Mode Mode `json:"mode"` Servers []string `json:"servers,omitempty"` } // GroupOrUngrouped is the submenu a preset belongs under. func (p Preset) GroupOrUngrouped() string { if strings.TrimSpace(p.Group) == "" { return "Ungrouped" } return p.Group } // Validate reports the first structural problem with a preset. Import rejects // a whole file rather than partially applying it, so this needs to be strict. func (p Preset) Validate() error { if strings.TrimSpace(p.ID) == "" { return fmt.Errorf("preset has no id") } if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("preset %s has no name", p.ID) } if strings.TrimSpace(p.Adapter) == "" { return fmt.Errorf("preset %q has no adapter", p.Name) } switch p.Mode { case ModeDHCP: // Nothing else to check: any addresses present are ignored on apply. case ModeStatic: if p.Primary == nil { return fmt.Errorf("preset %q is static but has no primary address", p.Name) } if err := p.Primary.validate(); err != nil { return fmt.Errorf("preset %q primary: %w", p.Name, err) } for i, s := range p.Secondary { if err := s.validate(); err != nil { return fmt.Errorf("preset %q secondary %d: %w", p.Name, i+1, err) } // A gateway on a secondary address is silently dropped by // `netsh add address`, so reject it rather than pretend. if s.Gateway != "" { return fmt.Errorf("preset %q secondary %d: secondary addresses cannot carry a gateway", p.Name, i+1) } } default: return fmt.Errorf("preset %q has unknown mode %q", p.Name, p.Mode) } switch p.DNS.Mode { case ModeDHCP: case ModeStatic: if len(p.DNS.Servers) == 0 { return fmt.Errorf("preset %q has static DNS but no servers", p.Name) } for _, s := range p.DNS.Servers { if _, err := parseV4(s); err != nil { return fmt.Errorf("preset %q dns server %q: %w", p.Name, s, err) } } default: return fmt.Errorf("preset %q has unknown dns mode %q", p.Name, p.DNS.Mode) } return nil } func (a Address) validate() error { if _, err := parseV4(a.Address); err != nil { return fmt.Errorf("address %q: %w", a.Address, err) } if a.Prefix < 0 || a.Prefix > 32 { return fmt.Errorf("prefix /%d out of range", a.Prefix) } if a.Gateway != "" { if _, err := parseV4(a.Gateway); err != nil { return fmt.Errorf("gateway %q: %w", a.Gateway, err) } } if a.GatewayMetric < 0 { return fmt.Errorf("gateway metric %d is negative", a.GatewayMetric) } return nil } // String renders an address the way the confirmation dialog shows it. func (a Address) String() string { return fmt.Sprintf("%s/%d", a.Address, a.Prefix) } func parseV4(s string) (netip.Addr, error) { addr, err := netip.ParseAddr(strings.TrimSpace(s)) if err != nil { return netip.Addr{}, fmt.Errorf("not an IP address") } if !addr.Is4() { // v1 is IPv4 only; see "Out of scope" in claude.md. return netip.Addr{}, fmt.Errorf("not an IPv4 address") } return addr, nil }