This commit is contained in:
jdl
2026-06-10 18:52:11 +02:00
parent bb28b8f340
commit db78964033
13 changed files with 373 additions and 62 deletions

View File

@@ -44,7 +44,8 @@ func main() {
log.Fatalf("init: %v", err)
}
app, err := peer.New(state, *hub, apiKey, *name)
ifaceName := strings.TrimSuffix(state.LocalDomain, ".local")
app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain)
if err != nil {
log.Fatalf("start: %v", err)
}

View File

@@ -95,6 +95,9 @@ func Peer_Validate(p *Peer) error {
return ErrInvalidPort
}
if len(p.Name) == 0 {
return ErrInvalidPeerName
}
for _, c := range p.Name {
if c >= 'a' && c <= 'z' {
continue
@@ -102,7 +105,7 @@ func Peer_Validate(p *Peer) error {
if c >= '0' && c <= '9' {
continue
}
if c == '.' || c == '-' || c == '_' {
if c == '-' {
continue
}
return ErrInvalidPeerName

View File

@@ -332,8 +332,9 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request)
}
resp := m.PeerInitResp{
PeerIP: peer.PeerIP,
Network: net.Network,
PeerIP: peer.PeerIP,
Network: net.Network,
LocalDomain: net.LocalDomain,
}
resp.NetworkState.Peers, err = a.peersArray(net.NetworkID)
@@ -345,19 +346,11 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request)
}
func (a *App) _peerFetchState(peer *api.Peer, w http.ResponseWriter, r *http.Request) error {
net, err := a.api.Network_Get(peer.NetworkID)
if err != nil {
return err
}
peers, err := a.peersArray(peer.NetworkID)
if err != nil {
return err
}
return a.sendJSON(w, m.NetworkState{
LocalDomain: net.LocalDomain,
Peers: peers,
})
return a.sendJSON(w, m.NetworkState{Peers: peers})
}
func (a *App) peersArray(networkID int64) (peers [256]*m.Peer, err error) {

View File

@@ -9,6 +9,7 @@ type PeerInitArgs struct {
type PeerInitResp struct {
PeerIP byte
Network []byte
LocalDomain string
NetworkState NetworkState
}
@@ -25,6 +26,5 @@ type Peer struct {
}
type NetworkState struct {
LocalDomain string
Peers [256]*Peer
Peers [256]*Peer
}

View File

@@ -25,6 +25,7 @@ const (
type HubPeer struct {
PubKey wgtypes.Key
VPNIP netip.Addr
Name string
IsRelay bool
IsPublic bool
EndpointV4 netip.AddrPort // zero if none
@@ -47,12 +48,13 @@ type MulticastEvent struct {
// accessed only from the Run goroutine.
type App struct {
// Identity
vpnIP netip.Addr
vpnNet netip.Prefix
privKey wgtypes.Key
pubKey wgtypes.Key
isRelay bool
isPublic bool
vpnIP netip.Addr
vpnNet netip.Prefix
privKey wgtypes.Key
pubKey wgtypes.Key
isRelay bool
isPublic bool
localDomain string
// Infrastructure
dev WGDevice
@@ -76,6 +78,11 @@ type App struct {
// Run is the main event loop. It runs until SIGTERM/SIGINT.
func (a *App) Run() error {
// Establish a clean hosts section before the first poll lands, clearing
// any stale entries left by a prior run (e.g. crash, or peers removed
// while we were down).
a.updateHosts()
ticker := time.NewTicker(PingInterval)
defer ticker.Stop()
@@ -102,5 +109,6 @@ func (a *App) Run() error {
}
func (a *App) onShutdown() error {
// TODO: removeHosts() ?
return wginterface.Delete(a.dev.Name())
}

View File

@@ -38,30 +38,36 @@ func (a *App) devPeers() []wgtypes.Peer {
}
func (a *App) devAddPeer(p *Peer) {
log.Printf("RELAYED: %s - %s ", p.Name, p.VPNIP.String())
devRetry(p.VPNIP, "AddPeer", func() error { return a.dev.AddPeer(p.PubKey()) })
p.State = StateRelayed
}
func (a *App) devAddDirect(p *Peer, endpoint netip.AddrPort) {
log.Printf("DIRECT: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String())
devRetry(p.VPNIP, "AddDirect", func() error { return a.dev.AddDirect(p.PubKey(), endpoint, p.VPNIP) })
p.State = StateDirect
}
func (a *App) devSetRelay(p *Peer, endpoint netip.AddrPort) {
log.Printf("RELAY: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String())
devRetry(p.VPNIP, "SetRelay", func() error { return a.dev.SetRelay(p.PubKey(), endpoint, a.vpnNet) })
p.State = StateDirect
}
func (a *App) devPromote(p *Peer) {
log.Printf("PROMOTED: %s - %s @ %s", p.Name, p.VPNIP.String(), p.WGEndpoint().String())
devRetry(p.VPNIP, "Promote", func() error { return a.dev.Promote(p.PubKey(), p.VPNIP) })
p.State = StateDirect
}
func (a *App) devAddProbe(p *Peer, endpoint netip.AddrPort) {
log.Printf("PROBE: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String())
devRetry(p.VPNIP, "AddProbe", func() error { return a.dev.AddProbe(p.PubKey(), endpoint) })
p.State = StateProbing
}
func (a *App) devRemove(p *Peer) {
log.Printf("REMOVED: %s - %s", p.Name, p.VPNIP.String())
devRetry(p.VPNIP, "RemovePeer", func() error { return a.dev.RemovePeer(p.PubKey()) })
}

121
peer/hosts.go Normal file
View File

@@ -0,0 +1,121 @@
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 "
)
// 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 := hostsBegin + localDomain
end := hostsEnd + 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, end)
if idxEnd != -1 {
after = strings.TrimSpace(data[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
}

167
peer/hosts_test.go Normal file
View File

@@ -0,0 +1,167 @@
package peer
import (
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// writeTempHosts creates a temp hosts file with the given content and returns
// its path.
func writeTempHosts(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "hosts")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
return path
}
// readManagedSection returns the lines between the begin/end markers for the
// given localDomain, plus everything outside the section ("outside").
func readManagedSection(t *testing.T, path, localDomain string) (inside, outside []string) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
begin := hostsBegin + localDomain
end := hostsEnd + localDomain
inSection := false
for _, line := range strings.Split(string(raw), "\n") {
switch {
case strings.HasPrefix(line, begin):
inSection = true
case strings.HasPrefix(line, end):
inSection = false
case inSection:
if f := strings.Join(strings.Fields(line), " "); f != "" {
inside = append(inside, f)
}
default:
if f := strings.Join(strings.Fields(line), " "); f != "" {
outside = append(outside, f)
}
}
}
return inside, outside
}
func peer(name string) *Peer {
return &Peer{Name: name}
}
func TestUpdateHosts_AddsSection(t *testing.T) {
path := writeTempHosts(t, "127.0.0.1 localhost\n")
peers := map[netip.Addr]*Peer{
netip.MustParseAddr("10.11.12.1"): peer("hub"),
netip.MustParseAddr("10.11.12.10"): peer("laptop"),
}
if err := updateHosts(path, "mynet.local", peers); err != nil {
t.Fatal(err)
}
inside, outside := readManagedSection(t, path, "mynet.local")
sort.Strings(inside)
want := []string{
"10.11.12.1 hub.mynet.local",
"10.11.12.10 laptop.mynet.local",
}
if strings.Join(inside, "\n") != strings.Join(want, "\n") {
t.Errorf("managed section = %v, want %v", inside, want)
}
if !contains(outside, "127.0.0.1 localhost") {
t.Errorf("original content lost; outside = %v", outside)
}
}
func TestUpdateHosts_ReplacesExistingSection(t *testing.T) {
path := writeTempHosts(t, "127.0.0.1 localhost\n")
// First write.
first := map[netip.Addr]*Peer{
netip.MustParseAddr("10.11.12.1"): peer("hub"),
}
if err := updateHosts(path, "mynet.local", first); err != nil {
t.Fatal(err)
}
// Second write with a different set of peers.
second := map[netip.Addr]*Peer{
netip.MustParseAddr("10.11.12.20"): peer("phone"),
}
if err := updateHosts(path, "mynet.local", second); err != nil {
t.Fatal(err)
}
inside, outside := readManagedSection(t, path, "mynet.local")
if len(inside) != 1 || inside[0] != "10.11.12.20 phone.mynet.local" {
t.Errorf("section not replaced; inside = %v", inside)
}
if contains(inside, "10.11.12.1 hub.mynet.local") {
t.Errorf("stale entry remained; inside = %v", inside)
}
if !contains(outside, "127.0.0.1 localhost") {
t.Errorf("original content lost; outside = %v", outside)
}
}
func TestUpdateHosts_SkipsEmptyNames(t *testing.T) {
path := writeTempHosts(t, "127.0.0.1 localhost\n")
peers := map[netip.Addr]*Peer{
netip.MustParseAddr("10.11.12.1"): peer("hub"),
netip.MustParseAddr("10.11.12.99"): peer(""), // no name
}
if err := updateHosts(path, "mynet.local", peers); err != nil {
t.Fatal(err)
}
inside, _ := readManagedSection(t, path, "mynet.local")
if len(inside) != 1 || inside[0] != "10.11.12.1 hub.mynet.local" {
t.Errorf("expected only named peer; inside = %v", inside)
}
}
func TestUpdateHosts_Idempotent(t *testing.T) {
path := writeTempHosts(t, "127.0.0.1 localhost\n")
peers := map[netip.Addr]*Peer{
netip.MustParseAddr("10.11.12.1"): peer("hub"),
}
if err := updateHosts(path, "mynet.local", peers); err != nil {
t.Fatal(err)
}
first, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := updateHosts(path, "mynet.local", peers); err != nil {
t.Fatal(err)
}
second, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(first) != string(second) {
t.Errorf("repeated update changed file:\nfirst:\n%s\nsecond:\n%s", first, second)
}
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

View File

@@ -149,6 +149,7 @@ func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer {
return HubPeer{
PubKey: pubKey,
VPNIP: vpnIP,
Name: p.Name,
IsRelay: p.Relay,
IsPublic: ep4.IsValid() || ep6.IsValid(),
EndpointV4: ep4,

View File

@@ -20,24 +20,26 @@ import (
// LocalState is the persisted identity for this peer, written on first run and
// loaded on every subsequent run.
type LocalState struct {
PrivKey wgtypes.Key
SignKey [64]byte // nacl/sign Ed25519 private key
VPNIP netip.Addr
VPNNet netip.Prefix
WGPort uint16
IsRelay bool
IsPublic bool
PrivKey wgtypes.Key
SignKey [64]byte // nacl/sign Ed25519 private key
VPNIP netip.Addr
VPNNet netip.Prefix
WGPort uint16
IsRelay bool
IsPublic bool
LocalDomain string
}
// localStateJSON is the on-disk representation.
type localStateJSON struct {
PrivKey string `json:"priv_key"` // standard base64
SignKey string `json:"sign_key"` // standard base64
VPNIP netip.Addr `json:"vpn_ip"`
VPNNet netip.Prefix `json:"vpn_net"`
WGPort uint16 `json:"wg_port"`
IsRelay bool `json:"is_relay"`
IsPublic bool `json:"is_public"`
PrivKey string `json:"priv_key"` // standard base64
SignKey string `json:"sign_key"` // standard base64
VPNIP netip.Addr `json:"vpn_ip"`
VPNNet netip.Prefix `json:"vpn_net"`
WGPort uint16 `json:"wg_port"`
IsRelay bool `json:"is_relay"`
IsPublic bool `json:"is_public"`
LocalDomain string `json:"local_domain"`
}
// LoadOrInit loads LocalState from path, or registers with the hub and creates
@@ -117,13 +119,14 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error)
}
return LocalState{
PrivKey: privKey,
SignKey: *signPrivKey,
VPNIP: vpnIP,
VPNNet: vpnNet,
WGPort: wgPort,
IsRelay: isRelay,
IsPublic: isPublic,
PrivKey: privKey,
SignKey: *signPrivKey,
VPNIP: vpnIP,
VPNNet: vpnNet,
WGPort: wgPort,
IsRelay: isRelay,
IsPublic: isPublic,
LocalDomain: r.LocalDomain,
}, nil
}
@@ -150,25 +153,27 @@ func parseLocalState(data []byte) (LocalState, error) {
var signKey [64]byte
copy(signKey[:], signKeyBytes)
return LocalState{
PrivKey: key,
SignKey: signKey,
VPNIP: j.VPNIP,
VPNNet: j.VPNNet,
WGPort: j.WGPort,
IsRelay: j.IsRelay,
IsPublic: j.IsPublic,
PrivKey: key,
SignKey: signKey,
VPNIP: j.VPNIP,
VPNNet: j.VPNNet,
WGPort: j.WGPort,
IsRelay: j.IsRelay,
IsPublic: j.IsPublic,
LocalDomain: j.LocalDomain,
}, nil
}
func saveLocalState(path string, s LocalState) error {
j := localStateJSON{
PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]),
SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]),
VPNIP: s.VPNIP,
VPNNet: s.VPNNet,
WGPort: s.WGPort,
IsRelay: s.IsRelay,
IsPublic: s.IsPublic,
PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]),
SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]),
VPNIP: s.VPNIP,
VPNNet: s.VPNNet,
WGPort: s.WGPort,
IsRelay: s.IsRelay,
IsPublic: s.IsPublic,
LocalDomain: s.LocalDomain,
}
data, err := json.MarshalIndent(j, "", " ")
if err != nil {

View File

@@ -16,6 +16,7 @@ func New(
state LocalState,
hubURL, apiKey string,
ifaceName string,
localDomain string,
) (*App, error) {
a4 := state.VPNIP.As4()
@@ -65,12 +66,13 @@ func New(
}
return &App{
vpnIP: state.VPNIP,
vpnNet: state.VPNNet,
privKey: state.PrivKey,
pubKey: state.PrivKey.PublicKey(),
isRelay: state.IsRelay,
isPublic: state.IsPublic,
vpnIP: state.VPNIP,
vpnNet: state.VPNNet,
privKey: state.PrivKey,
pubKey: state.PrivKey.PublicKey(),
isRelay: state.IsRelay,
isPublic: state.IsPublic,
localDomain: localDomain,
dev: dev,
controlConn: cc,

View File

@@ -17,6 +17,7 @@ func (a *App) onAddPeer(p HubPeer) {
peer := &Peer{
wgPeer: wgtypes.Peer{PublicKey: p.PubKey},
VPNIP: p.VPNIP,
Name: p.Name,
IsRelay: p.IsRelay,
IsPublic: p.IsPublic,
Endpoint4: p.EndpointV4,
@@ -35,6 +36,7 @@ func (a *App) onAddPeer(p HubPeer) {
a.peersByKey[p.PubKey] = peer
a.peersByIP[peer.VPNIP] = peer
defer a.updateHosts()
if !peer.IsPublic {
if a.isPublic {
@@ -59,6 +61,7 @@ func (a *App) onRemovePeer(key wgtypes.Key) {
a.devRemove(peer)
delete(a.peersByKey, key)
delete(a.peersByIP, peer.VPNIP)
a.updateHosts()
if peer == a.relay {
a.relay = nil

View File

@@ -21,6 +21,7 @@ const (
type Peer struct {
wgPeer wgtypes.Peer
VPNIP netip.Addr // VPN IP address.
Name string // Human-readable DNS label.
IsRelay bool // Peer is a relay.
IsPublic bool // Peer has a public IP.
Endpoint4 netip.AddrPort // Reported IPv4 endpoint.