Files
nadir-agent/internal/modules/networking/hosts.go
T
2026-06-24 17:29:45 +02:00

233 lines
6.8 KiB
Go

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"))
}