package networking import ( "context" "net/netip" "os" "regexp" "strings" "sync" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" ) // /etc/hosts management ("Host Addresses"). Edits are surgical — upsert/delete a // single IP's line — so existing comments, ordering and the localhost entries // are preserved rather than rewritten away. var hostsFile = "/etc/hosts" // hostsMu serialises writes to /etc/hosts so concurrent requests don't // clobber each other's read-modify-write. var hostsMu sync.Mutex // hostnameRe matches a single hostname/alias. It forbids whitespace and '#' (so // an entry can't inject extra fields or a comment) and a leading dash. var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) // HostEntry is one /etc/hosts mapping: an IP and the names that resolve to it. type HostEntry struct { IP string `json:"ip" example:"192.168.1.10" doc:"IPv4 or IPv6 address"` Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names mapped to the address"` } type ListHostsOutput struct { Body struct { Entries []HostEntry `json:"entries"` } } type HostUpsertInput struct { IP string `path:"ip" example:"192.168.1.10" doc:"IP address to add or update"` Body struct { Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names to map to the IP"` } } type HostDeleteInput struct { IP string `path:"ip" example:"192.168.1.10" doc:"IP address whose entry to remove"` } func registerHosts(api huma.API) { huma.Register(api, huma.Operation{ OperationID: "networking-list-hosts", Method: "GET", Path: "/api/networking/hosts", Summary: "List /etc/hosts entries", Description: "Returns the static host-to-address mappings from /etc/hosts.", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListHostsOutput, error) { data, err := os.ReadFile(hostsFile) if err != nil { return nil, huma.Error500InternalServerError("read hosts failed", err) } res := &ListHostsOutput{} res.Body.Entries = parseHosts(string(data)) return res, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-upsert-host", Method: "PUT", Path: "/api/networking/hosts/{ip}", Summary: "Add or update a /etc/hosts entry", Description: "Sets the hostnames for an IP. Replaces the existing line for that IP, " + "or appends a new one; all other lines (comments included) are left untouched.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: writeErrors, }, func(ctx context.Context, in *HostUpsertInput) (*oscmd.StatusOutput, error) { if err := validateHost(in.IP, in.Body.Hostnames); err != nil { return nil, err } if err := upsertHost(in.IP, in.Body.Hostnames); err != nil { return nil, huma.Error500InternalServerError("write hosts failed", err) } return oscmd.OK(), nil }) huma.Register(api, huma.Operation{ OperationID: "networking-delete-host", Method: "DELETE", Path: "/api/networking/hosts/{ip}", Summary: "Remove a /etc/hosts entry", Description: "Removes the line(s) mapping the given IP. 404 if no entry exists for it.", Tags: []string{tagNetworking}, Metadata: op("write"), Errors: []int{400, 401, 403, 404, 500}, }, func(ctx context.Context, in *HostDeleteInput) (*oscmd.StatusOutput, error) { if _, err := netip.ParseAddr(in.IP); err != nil { return nil, huma.Error400BadRequest("invalid IP: " + in.IP) } removed, err := deleteHost(in.IP) if err != nil { return nil, huma.Error500InternalServerError("write hosts failed", err) } if !removed { return nil, huma.Error404NotFound("no hosts entry for " + in.IP) } return oscmd.OK(), nil }) } func validateHost(ip string, hostnames []string) error { if _, err := netip.ParseAddr(ip); err != nil { return huma.Error400BadRequest("invalid IP: " + ip) } if len(hostnames) == 0 { return huma.Error400BadRequest("at least one hostname is required") } for _, h := range hostnames { if !hostnameRe.MatchString(h) { return huma.Error400BadRequest("invalid hostname: " + h) } } return nil } // --- parse / render (pure, tested) ------------------------------------------- // hostFields returns the IP and hostnames on a hosts line, or ok=false for a // blank or comment-only line. An inline "# comment" tail is stripped. func hostFields(line string) (ip string, names []string, ok bool) { if i := strings.IndexByte(line, '#'); i >= 0 { line = line[:i] } f := strings.Fields(line) if len(f) < 2 { return "", nil, false } return f[0], f[1:], true } func parseHosts(text string) []HostEntry { entries := []HostEntry{} for line := range strings.SplitSeq(text, "\n") { if ip, names, ok := hostFields(line); ok { entries = append(entries, HostEntry{IP: ip, Hostnames: names}) } } return entries } // renderHostLine formats one entry as a hosts line. func renderHostLine(ip string, hostnames []string) string { return ip + "\t" + strings.Join(hostnames, " ") } // --- writes ------------------------------------------------------------------ // writeHostsAtomically writes content to /etc/hosts via a temp file + rename, // so a crash mid-write leaves the original file intact. func writeHostsAtomically(content string) error { tmp := hostsFile + ".nadir.tmp" if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { return err } if err := os.Rename(tmp, hostsFile); err != nil { os.Remove(tmp) return err } return nil } // upsertHost replaces the line for ip (matched on the address field) or appends // a new one, preserving every other line. func upsertHost(ip string, hostnames []string) error { hostsMu.Lock() defer hostsMu.Unlock() data, err := os.ReadFile(hostsFile) if err != nil { return err } lines := strings.Split(string(data), "\n") newLine := renderHostLine(ip, hostnames) replaced := false for i, line := range lines { if got, _, ok := hostFields(line); ok && got == ip { lines[i] = newLine replaced = true break } } if !replaced { if n := len(lines); n > 0 && strings.TrimSpace(lines[n-1]) == "" { lines[n-1] = newLine } else { lines = append(lines, newLine) } lines = append(lines, "") } return writeHostsAtomically(strings.Join(lines, "\n")) } // deleteHost removes every line mapping ip and reports whether any were removed. func deleteHost(ip string) (bool, error) { hostsMu.Lock() defer hostsMu.Unlock() data, err := os.ReadFile(hostsFile) if err != nil { return false, err } lines := strings.Split(string(data), "\n") kept := make([]string, 0, len(lines)) removed := false for _, line := range lines { if got, _, ok := hostFields(line); ok && got == ip { removed = true continue } kept = append(kept, line) } if !removed { return false, nil } return true, writeHostsAtomically(strings.Join(kept, "\n")) }