233 lines
6.8 KiB
Go
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"))
|
|
}
|