package peer import ( "fmt" "log" "net/netip" "os" "sort" "strings" "syscall" "git.crumpington.com/lib/go/flock" ) const ( hostsFile = "/etc/hosts" hostsBegin = "# BEGIN vppn" hostsEnd = "# END vppn" ) // hostMarkers returns the begin/end marker lines that delimit the managed // section for localDomain. The domain is wrapped in parentheses so one domain's // marker can never be a prefix of another's (e.g. "net" vs "net2") when // multiple vppn instances share /etc/hosts. func hostMarkers(localDomain string) (begin, end string) { return hostsBegin + "(" + localDomain + ")", hostsEnd + "(" + localDomain + ")" } // updateHosts rewrites the managed vppn section in /etc/hosts using the // current peersByIP map. Peers without a Name are skipped. func (a *App) updateHosts() { if a.localDomain == "" { return } if err := updateHosts(hostsFile, a.localDomain, a.peersByIP); err != nil { log.Printf("Failed to update hosts file: %v", err) } } func updateHosts(hostsPath, localDomain string, peers map[netip.Addr]*Peer) error { lockFile, err := flock.Lock(hostsPath + ".vppn.lock") if err != nil { return err } defer lockFile.Close() begin, end := hostMarkers(localDomain) info, err := os.Stat(hostsPath) if err != nil { return err } raw, err := os.ReadFile(hostsPath) if err != nil { return err } data := string(raw) before := strings.TrimSpace(data) after := "" if idxBegin := strings.Index(data, begin); idxBegin != -1 { idxEnd := strings.Index(data[idxBegin:], end) if idxEnd != -1 { after = strings.TrimSpace(data[idxBegin+idxEnd+len(end):]) } before = strings.TrimSpace(data[:idxBegin]) } b := strings.Builder{} b.WriteString(before) b.WriteRune('\n') b.WriteString(after) b.WriteRune('\n') b.WriteRune('\n') b.WriteString(begin) b.WriteRune('\n') // Collect entries so we can sort by IP for stable output. Pad the IP // column to the width of the widest possible address ("255.255.255.255") // for readability. type entry struct { ip netip.Addr host string } var entries []entry for ip, p := range peers { if p.Name == "" { continue } entries = append(entries, entry{ip: ip, host: p.Name + "." + localDomain}) } sort.Slice(entries, func(i, j int) bool { return entries[i].ip.Less(entries[j].ip) }) for _, e := range entries { b.WriteString(fmt.Sprintf("%-15s %s\n", e.ip.String(), e.host)) } b.WriteString(end) b.WriteRune('\n') // Write to a temp file in the same directory, then rename over the // original so readers never observe a partial file. Preserve the // original's mode and ownership, since rename replaces the inode. tmpPath := hostsPath + ".vppn.tmp" if err := os.WriteFile(tmpPath, []byte(b.String()), info.Mode().Perm()); err != nil { return err } if st, ok := info.Sys().(*syscall.Stat_t); ok { if err := os.Chown(tmpPath, int(st.Uid), int(st.Gid)); err != nil { os.Remove(tmpPath) return err } } if err := os.Rename(tmpPath, hostsPath); err != nil { os.Remove(tmpPath) return err } return nil }