2 Commits

Author SHA1 Message Date
jdl
926e111c3f Cleanup 2026-06-12 18:22:25 +02:00
9a3cb2d1c2 Refactor - now wireguard based. (#7) 2026-06-12 15:11:01 +00:00
69 changed files with 3038 additions and 1291 deletions

View File

@@ -1,11 +1,5 @@
# vppn: Virtual Potentially Private Network # vppn: Virtual Potentially Private Network
## TO DO
* peer - write status to file instead of using sockets
* peer - improve relay selection
* Double buffering in IFReader and ConnReader ?
## Hub Server Configuration ## Hub Server Configuration
``` ```
@@ -59,15 +53,17 @@ Sign-in and configure.
Install the binary somewhere, for example `~/bin/vppn`. Install the binary somewhere, for example `~/bin/vppn`.
Add the API key for your network name in `~/.vppn/<netname>/apikey`.
Create systemd file in `/etc/systemd/system/vppn.service`. Create systemd file in `/etc/systemd/system/vppn.service`.
``` ```
[Service] [Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN AmbientCapabilities=AP_NET_ADMIN CAP_DAC_OVERRIDE CAP_CHOWN
Type=simple Type=simple
User=user User=user
WorkingDirectory=/home/user/ WorkingDirectory=/home/user/
ExecStart=/home/user/vppn run my_net_name https://my.hub my_api_key ExecStart=/home/user/bin/vppn -name my_net_name -hub https://my.hub
Restart=always Restart=always
RestartSec=8 RestartSec=8
TimeoutStopSec=24 TimeoutStopSec=24

View File

@@ -1,11 +1,72 @@
package main package main
import ( import (
"flag"
"log" "log"
"os"
"path/filepath"
"strings"
"vppn/peer" "vppn/peer"
"git.crumpington.com/lib/go/flock"
) )
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
peer.Main2()
name := flag.String("name", "", "network name (required)")
hub := flag.String("hub", "", "hub base URL (required)")
flag.Parse()
if *name == "" || *hub == "" {
flag.Usage()
os.Exit(1)
}
apiKey, err := loadAPIKey(*name)
if err != nil {
log.Fatalf("api key: %v", err)
}
// Directory existence is guaranteed by the apikey file read above.
lockFile, err := flock.TryLock(vppnPath(*name, "lock"))
if err != nil {
log.Fatalf("lock: %v", err)
}
if lockFile == nil {
log.Fatalf("already running for network %q", *name)
}
defer flock.Unlock(lockFile)
state, err := peer.LoadOrInit(vppnPath(*name, "state.json"), *hub, apiKey)
if err != nil {
log.Fatalf("init: %v", err)
}
ifaceName := strings.TrimSuffix(state.LocalDomain, ".local")
app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain, vppnPath(*name, "network.json"))
if err != nil {
log.Fatalf("start: %v", err)
}
if err := app.Run(); err != nil {
log.Fatalf("run: %v", err)
}
}
func loadAPIKey(name string) (string, error) {
data, err := os.ReadFile(vppnPath(name, "apikey"))
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func vppnPath(name, file string) string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".vppn", name, file)
}
return filepath.Join(home, ".vppn", name, file)
} }

4
go.mod
View File

@@ -6,6 +6,7 @@ require (
git.crumpington.com/lib/go v0.9.1 git.crumpington.com/lib/go v0.9.1
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/sys v0.36.0 golang.org/x/sys v0.36.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
) )
require ( require (
@@ -15,11 +16,8 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.29.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 // indirect
) )

8
go.sum
View File

@@ -12,18 +12,14 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=

View File

@@ -40,7 +40,13 @@ func New(dbPath string) (*API, error) {
sessions: make(map[string]*Session), sessions: make(map[string]*Session),
} }
return a, a.ensurePassword() if err := a.ensurePassword(); err != nil {
return nil, err
}
go a.sweepSessions()
return a, nil
} }
func (a *API) ensurePassword() error { func (a *API) ensurePassword() error {
@@ -65,12 +71,8 @@ func (a *API) ensurePassword() error {
return db.Config_Insert(a.db, conf) return db.Config_Insert(a.db, conf)
} }
func (a *API) Config_Get() *Config { func (a *API) Config_Get() (*Config, error) {
conf, err := db.Config_Get(a.db, 1) return db.Config_Get(a.db, 1)
if err != nil {
panic(err)
}
return conf
} }
func (a *API) Config_Update(conf *Config) error { func (a *API) Config_Update(conf *Config) error {
@@ -84,46 +86,72 @@ func (a *API) Session_Delete(sessionID string) error {
return nil return nil
} }
func (a *API) Session_Get(sessionID string) (*Session, error) { const (
sessionTTLSecs = 86400 * 21 // sessions expire 21 days after last use
sessionSweepEvery = time.Hour // cadence of expired-session eviction
)
// Session_Get returns a snapshot copy of the signed-in session for sessionID,
// or the zero Session if the cookie is missing/unknown/expired. It never
// creates a session, so anonymous requests cost no memory — a session is minted
// only by Session_SignIn. Returning a value (not the stored pointer) keeps
// callers from racing on the shared struct.
func (a *API) Session_Get(sessionID string) (Session, error) {
a.sessionsMu.Lock() a.sessionsMu.Lock()
defer a.sessionsMu.Unlock() defer a.sessionsMu.Unlock()
if sessionID != "" {
s, ok := a.sessions[sessionID] s, ok := a.sessions[sessionID]
if ok {
if timeSince(s.LastSeenAt) <= 86400*21 { if sessionID == "" || !ok {
if timeSince(s.LastSeenAt) > 86400*7 { return Session{}, nil
s.LastSeenAt = time.Now().Unix()
}
return s, nil
} }
if timeSince(s.LastSeenAt) > sessionTTLSecs {
delete(a.sessions, sessionID) delete(a.sessions, sessionID)
} return Session{}, nil
} }
return a.session_Create(), nil s.LastSeenAt = time.Now().Unix()
return *s, nil
} }
// caller must hold sessionsMu // Session_SignIn verifies pwd and, on success, mints a fresh signed-in session,
func (a *API) session_Create() *Session { // returning it so the caller can set the cookie. A new ID per sign-in rotates
// the session at the privilege boundary (session-fixation resistance).
func (a *API) Session_SignIn(pwd string) (Session, error) {
conf, err := a.Config_Get()
if err != nil {
return Session{}, err
}
if err := bcrypt.CompareHashAndPassword(conf.Password, []byte(pwd)); err != nil {
return Session{}, ErrNotAuthorized
}
a.sessionsMu.Lock()
defer a.sessionsMu.Unlock()
s := &Session{ s := &Session{
SessionID: idgen.NewToken(), SessionID: idgen.NewToken(),
SignedIn: true,
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
LastSeenAt: time.Now().Unix(), LastSeenAt: time.Now().Unix(),
} }
a.sessions[s.SessionID] = s a.sessions[s.SessionID] = s
return s return *s, nil
} }
func (a *API) Session_SignIn(s *Session, pwd string) error { // sweepSessions periodically evicts sessions past their TTL. Without it, a
conf := a.Config_Get() // signed-in session whose ID is never presented again would linger forever
if err := bcrypt.CompareHashAndPassword(conf.Password, []byte(pwd)); err != nil { // (Session_Get only evicts on a lookup of that same ID).
return ErrNotAuthorized func (a *API) sweepSessions() {
} for range time.Tick(sessionSweepEvery) {
a.sessionsMu.Lock() a.sessionsMu.Lock()
s.SignedIn = true for id, s := range a.sessions {
if timeSince(s.LastSeenAt) > sessionTTLSecs {
delete(a.sessions, id)
}
}
a.sessionsMu.Unlock() a.sessionsMu.Unlock()
return nil }
} }
func (a *API) Network_Create(n *Network) error { func (a *API) Network_Create(n *Network) error {
@@ -140,13 +168,13 @@ func (a *API) Network_Get(id int64) (*Network, error) {
} }
func (a *API) Network_List() ([]*Network, error) { func (a *API) Network_List() ([]*Network, error) {
const query = db.Network_SelectQuery + ` ORDER BY Name ASC` const query = db.Network_SelectQuery + ` ORDER BY LocalDomain ASC`
return db.Network_List(a.db, query) return db.Network_List(a.db, query)
} }
func (a *API) Peer_CreateNew(p *Peer) error { func (a *API) Peer_CreateNew(p *Peer) error {
p.Version = idgen.NextID(0)
p.WGPubKey = []byte{} p.WGPubKey = []byte{}
p.SignPubKey = []byte{}
p.APIKey = idgen.NewToken() p.APIKey = idgen.NewToken()
return db.Peer_Insert(a.db, p) return db.Peer_Insert(a.db, p)
@@ -156,18 +184,20 @@ func (a *API) Peer_Init(peer *Peer, args m.PeerInitArgs) error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
peer.Version = idgen.NextID(0) // Re-read from DB inside the lock — the caller's copy was fetched before
peer.WGPubKey = args.WGPubKey // we held the lock, so it may be stale under concurrent requests.
current, err := db.Peer_Get(a.db, peer.NetworkID, peer.PeerIP)
return db.Peer_UpdateFull(a.db, peer) if err != nil {
return err
}
if len(current.WGPubKey) != 0 {
return errors.New("peer already initialized")
} }
func (a *API) Peer_Update(p *Peer) error { peer.WGPubKey = args.WGPubKey
a.lock.Lock() peer.SignPubKey = args.SignPubKey
defer a.lock.Unlock()
p.Version = idgen.NextID(0) return db.Peer_UpdateFull(a.db, peer)
return db.Peer_Update(a.db, p)
} }
func (a *API) Peer_Delete(networkID int64, peerIP byte) error { func (a *API) Peer_Delete(networkID int64, peerIP byte) error {

View File

@@ -192,11 +192,11 @@ func Config_List(
type Network struct { type Network struct {
NetworkID int64 NetworkID int64
Name string LocalDomain string
Network []byte Network []byte
} }
const Network_SelectQuery = "SELECT NetworkID,Name,Network FROM networks" const Network_SelectQuery = "SELECT NetworkID,LocalDomain,Network FROM networks"
func Network_Insert( func Network_Insert(
tx TX, tx TX,
@@ -207,7 +207,7 @@ func Network_Insert(
return err return err
} }
_, err = tx.Exec("INSERT INTO networks(NetworkID,Name,Network) VALUES(?,?,?)", row.NetworkID, row.Name, row.Network) _, err = tx.Exec("INSERT INTO networks(NetworkID,LocalDomain,Network) VALUES(?,?,?)", row.NetworkID, row.LocalDomain, row.Network)
return err return err
} }
@@ -220,7 +220,7 @@ func Network_UpdateFull(
return err return err
} }
result, err := tx.Exec("UPDATE networks SET Name=?,Network=? WHERE NetworkID=?", row.Name, row.Network, row.NetworkID) result, err := tx.Exec("UPDATE networks SET LocalDomain=?,Network=? WHERE NetworkID=?", row.LocalDomain, row.Network, row.NetworkID)
if err != nil { if err != nil {
return err return err
} }
@@ -270,8 +270,8 @@ func Network_Get(
err error, err error,
) { ) {
row = &Network{} row = &Network{}
r := tx.QueryRow("SELECT NetworkID,Name,Network FROM networks WHERE NetworkID=?", NetworkID) r := tx.QueryRow("SELECT NetworkID,LocalDomain,Network FROM networks WHERE NetworkID=?", NetworkID)
if err = r.Scan(&row.NetworkID, &row.Name, &row.Network); err != nil { if err = r.Scan(&row.NetworkID, &row.LocalDomain, &row.Network); err != nil {
row = nil row = nil
} }
return return
@@ -287,7 +287,7 @@ func Network_GetWhere(
) { ) {
row = &Network{} row = &Network{}
r := tx.QueryRow(query, args...) r := tx.QueryRow(query, args...)
if err = r.Scan(&row.NetworkID, &row.Name, &row.Network); err != nil { if err = r.Scan(&row.NetworkID, &row.LocalDomain, &row.Network); err != nil {
row = nil row = nil
} }
return return
@@ -309,7 +309,7 @@ func Network_Iterate(
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
row := &Network{} row := &Network{}
err := rows.Scan(&row.NetworkID, &row.Name, &row.Network) err := rows.Scan(&row.NetworkID, &row.LocalDomain, &row.Network)
if !yield(row, err) { if !yield(row, err) {
return return
} }
@@ -341,18 +341,17 @@ func Network_List(
type Peer struct { type Peer struct {
NetworkID int64 NetworkID int64
PeerIP byte PeerIP byte
Version int64
APIKey string APIKey string
Name string Name string
PublicIP1 []byte Addr4 []byte
Port1 uint16 Addr6 []byte
PublicIP2 []byte Port uint16
Port2 uint16
Relay bool Relay bool
WGPubKey []byte WGPubKey []byte
SignPubKey []byte
} }
const Peer_SelectQuery = "SELECT NetworkID,PeerIP,Version,APIKey,Name,PublicIP1,Port1,PublicIP2,Port2,Relay,WGPubKey FROM peers" const Peer_SelectQuery = "SELECT NetworkID,PeerIP,APIKey,Name,Addr4,Addr6,Port,Relay,WGPubKey,SignPubKey FROM peers"
func Peer_Insert( func Peer_Insert(
tx TX, tx TX,
@@ -363,38 +362,10 @@ func Peer_Insert(
return err return err
} }
_, err = tx.Exec("INSERT INTO peers(NetworkID,PeerIP,Version,APIKey,Name,PublicIP1,Port1,PublicIP2,Port2,Relay,WGPubKey) VALUES(?,?,?,?,?,?,?,?,?,?,?)", row.NetworkID, row.PeerIP, row.Version, row.APIKey, row.Name, row.PublicIP1, row.Port1, row.PublicIP2, row.Port2, row.Relay, row.WGPubKey) _, err = tx.Exec("INSERT INTO peers(NetworkID,PeerIP,APIKey,Name,Addr4,Addr6,Port,Relay,WGPubKey,SignPubKey) VALUES(?,?,?,?,?,?,?,?,?,?)", row.NetworkID, row.PeerIP, row.APIKey, row.Name, row.Addr4, row.Addr6, row.Port, row.Relay, row.WGPubKey, row.SignPubKey)
return err return err
} }
func Peer_Update(
tx TX,
row *Peer,
) (err error) {
Peer_Sanitize(row)
if err = Peer_Validate(row); err != nil {
return err
}
result, err := tx.Exec("UPDATE peers SET Version=?,Name=?,PublicIP1=?,Port1=?,PublicIP2=?,Port2=?,Relay=? WHERE NetworkID=? AND PeerIP=?", row.Version, row.Name, row.PublicIP1, row.Port1, row.PublicIP2, row.Port2, row.Relay, row.NetworkID, row.PeerIP)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
panic(err)
}
switch n {
case 0:
return sql.ErrNoRows
case 1:
return nil
default:
panic("multiple rows updated")
}
}
func Peer_UpdateFull( func Peer_UpdateFull(
tx TX, tx TX,
row *Peer, row *Peer,
@@ -404,7 +375,7 @@ func Peer_UpdateFull(
return err return err
} }
result, err := tx.Exec("UPDATE peers SET Version=?,APIKey=?,Name=?,PublicIP1=?,Port1=?,PublicIP2=?,Port2=?,Relay=?,WGPubKey=? WHERE NetworkID=? AND PeerIP=?", row.Version, row.APIKey, row.Name, row.PublicIP1, row.Port1, row.PublicIP2, row.Port2, row.Relay, row.WGPubKey, row.NetworkID, row.PeerIP) result, err := tx.Exec("UPDATE peers SET APIKey=?,Name=?,Addr4=?,Addr6=?,Port=?,Relay=?,WGPubKey=?,SignPubKey=? WHERE NetworkID=? AND PeerIP=?", row.APIKey, row.Name, row.Addr4, row.Addr6, row.Port, row.Relay, row.WGPubKey, row.SignPubKey, row.NetworkID, row.PeerIP)
if err != nil { if err != nil {
return err return err
} }
@@ -456,8 +427,8 @@ func Peer_Get(
err error, err error,
) { ) {
row = &Peer{} row = &Peer{}
r := tx.QueryRow("SELECT NetworkID,PeerIP,Version,APIKey,Name,PublicIP1,Port1,PublicIP2,Port2,Relay,WGPubKey FROM peers WHERE NetworkID=? AND PeerIP=?", NetworkID, PeerIP) r := tx.QueryRow("SELECT NetworkID,PeerIP,APIKey,Name,Addr4,Addr6,Port,Relay,WGPubKey,SignPubKey FROM peers WHERE NetworkID=? AND PeerIP=?", NetworkID, PeerIP)
if err = r.Scan(&row.NetworkID, &row.PeerIP, &row.Version, &row.APIKey, &row.Name, &row.PublicIP1, &row.Port1, &row.PublicIP2, &row.Port2, &row.Relay, &row.WGPubKey); err != nil { if err = r.Scan(&row.NetworkID, &row.PeerIP, &row.APIKey, &row.Name, &row.Addr4, &row.Addr6, &row.Port, &row.Relay, &row.WGPubKey, &row.SignPubKey); err != nil {
row = nil row = nil
} }
return return
@@ -473,7 +444,7 @@ func Peer_GetWhere(
) { ) {
row = &Peer{} row = &Peer{}
r := tx.QueryRow(query, args...) r := tx.QueryRow(query, args...)
if err = r.Scan(&row.NetworkID, &row.PeerIP, &row.Version, &row.APIKey, &row.Name, &row.PublicIP1, &row.Port1, &row.PublicIP2, &row.Port2, &row.Relay, &row.WGPubKey); err != nil { if err = r.Scan(&row.NetworkID, &row.PeerIP, &row.APIKey, &row.Name, &row.Addr4, &row.Addr6, &row.Port, &row.Relay, &row.WGPubKey, &row.SignPubKey); err != nil {
row = nil row = nil
} }
return return
@@ -495,7 +466,7 @@ func Peer_Iterate(
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
row := &Peer{} row := &Peer{}
err := rows.Scan(&row.NetworkID, &row.PeerIP, &row.Version, &row.APIKey, &row.Name, &row.PublicIP1, &row.Port1, &row.PublicIP2, &row.Port2, &row.Relay, &row.WGPubKey) err := rows.Scan(&row.NetworkID, &row.PeerIP, &row.APIKey, &row.Name, &row.Addr4, &row.Addr6, &row.Port, &row.Relay, &row.WGPubKey, &row.SignPubKey)
if !yield(row, err) { if !yield(row, err) {
return return
} }

View File

@@ -8,9 +8,11 @@ import (
var ( var (
ErrInvalidIP = errors.New("invalid IP") ErrInvalidIP = errors.New("invalid IP")
ErrInvalidPeerIP = errors.New("invalid peer IP")
ErrNonPrivateIP = errors.New("non-private IP") ErrNonPrivateIP = errors.New("non-private IP")
ErrInvalidPort = errors.New("invalid port") ErrInvalidPort = errors.New("invalid port")
ErrInvalidNetName = errors.New("invalid network name") ErrInvalidNetName = errors.New("invalid network name")
ErrNetNameNotLocal = errors.New("network name must end with .local")
ErrInvalidPeerName = errors.New("invalid peer name") ErrInvalidPeerName = errors.New("invalid peer name")
) )
@@ -22,7 +24,7 @@ func Config_Validate(c *Config) error {
} }
func Network_Sanitize(n *Network) { func Network_Sanitize(n *Network) {
n.Name = strings.TrimSpace(n.Name) n.LocalDomain = strings.TrimSpace(n.LocalDomain)
if addr, ok := netip.AddrFromSlice(n.Network); ok { if addr, ok := netip.AddrFromSlice(n.Network); ok {
n.Network = addr.AsSlice() n.Network = addr.AsSlice()
@@ -30,12 +32,17 @@ func Network_Sanitize(n *Network) {
} }
func Network_Validate(c *Network) error { func Network_Validate(c *Network) error {
// 16 bytes is linux limit for network interface names. // 15 bytes is linux limit for network interface names. With ending .local,
if len(c.Name) == 0 || len(c.Name) > 16 { // max length is 21.
if len(c.LocalDomain) == 0 || len(c.LocalDomain) > 21 {
return ErrInvalidNetName return ErrInvalidNetName
} }
for _, c := range c.Name { if !strings.HasSuffix(c.LocalDomain, ".local") {
return ErrNetNameNotLocal
}
for _, c := range strings.TrimSuffix(c.LocalDomain, ".local") {
if c >= 'a' && c <= 'z' { if c >= 'a' && c <= 'z' {
continue continue
} }
@@ -59,42 +66,45 @@ func Network_Validate(c *Network) error {
func Peer_Sanitize(p *Peer) { func Peer_Sanitize(p *Peer) {
p.Name = strings.TrimSpace(p.Name) p.Name = strings.TrimSpace(p.Name)
if len(p.PublicIP1) != 0 { if len(p.Addr4) != 0 {
if addr, ok := netip.AddrFromSlice(p.PublicIP1); ok { if addr, ok := netip.AddrFromSlice(p.Addr4); ok {
p.PublicIP1 = addr.AsSlice() // Unmap so an IPv4-mapped form is stored canonically as 4 bytes.
p.Addr4 = addr.Unmap().AsSlice()
} }
} }
if len(p.PublicIP2) != 0 { if len(p.Addr6) != 0 {
if addr, ok := netip.AddrFromSlice(p.PublicIP2); ok { if addr, ok := netip.AddrFromSlice(p.Addr6); ok {
p.PublicIP2 = addr.AsSlice() p.Addr6 = addr.AsSlice()
} }
} }
if p.Port1 == 0 { if p.Port == 0 {
p.Port1 = 456 p.Port = 51820
}
if len(p.PublicIP2) != 0 && p.Port2 == 0 {
p.Port2 = 456
} }
} }
func Peer_Validate(p *Peer) error { func Peer_Validate(p *Peer) error {
if len(p.PublicIP1) > 0 { if p.PeerIP < 1 || p.PeerIP > 254 {
if _, ok := netip.AddrFromSlice(p.PublicIP1); !ok { return ErrInvalidPeerIP
}
if len(p.Addr4) > 0 {
// Must be a genuine IPv4 address (reject an IPv6 in the v4 field).
if addr, ok := netip.AddrFromSlice(p.Addr4); !ok || !addr.Is4() {
return ErrInvalidIP return ErrInvalidIP
} }
} }
if len(p.PublicIP2) > 0 { if len(p.Addr6) > 0 {
if _, ok := netip.AddrFromSlice(p.PublicIP2); !ok { // Must be a genuine IPv6 address (reject IPv4 / IPv4-mapped in the v6 field).
if addr, ok := netip.AddrFromSlice(p.Addr6); !ok || !addr.Is6() || addr.Is4In6() {
return ErrInvalidIP return ErrInvalidIP
} }
if p.Port2 == 0 {
return ErrInvalidPort
} }
} if p.Port == 0 {
if p.Port1 == 0 {
return ErrInvalidPort return ErrInvalidPort
} }
if len(p.Name) == 0 {
return ErrInvalidPeerName
}
for _, c := range p.Name { for _, c := range p.Name {
if c >= 'a' && c <= 'z' { if c >= 'a' && c <= 'z' {
continue continue
@@ -102,7 +112,7 @@ func Peer_Validate(p *Peer) error {
if c >= '0' && c <= '9' { if c >= '0' && c <= '9' {
continue continue
} }
if c == '.' || c == '-' || c == '_' { if c == '-' {
continue continue
} }
return ErrInvalidPeerName return ErrInvalidPeerName

View File

@@ -5,20 +5,19 @@ TABLE config OF Config (
TABLE networks OF Network ( TABLE networks OF Network (
NetworkID int64 PK, NetworkID int64 PK,
Name string NoUpdate, LocalDomain string NoUpdate,
Network []byte NoUpdate Network []byte NoUpdate
); );
TABLE peers OF Peer ( TABLE peers OF Peer (
NetworkID int64 PK, NetworkID int64 PK,
PeerIP byte PK, PeerIP byte PK,
Version int64,
APIKey string NoUpdate, APIKey string NoUpdate,
Name string, Name string NoUpdate,
PublicIP1 []byte, Addr4 []byte NoUpdate,
Port1 uint16, Addr6 []byte NoUpdate,
PublicIP2 []byte, Port uint16 NoUpdate,
Port2 uint16, Relay bool NoUpdate,
Relay bool, WGPubKey []byte NoUpdate,
WGPubKey []byte NoUpdate SignPubKey []byte NoUpdate
); );

View File

@@ -11,9 +11,3 @@ func Peer_GetByAPIKey(tx TX, apiKey string) (*Peer, error) {
Peer_SelectQuery+` WHERE APIKey=?`, Peer_SelectQuery+` WHERE APIKey=?`,
apiKey) apiKey)
} }
func Peer_Exists(tx TX, networkID int64, ip byte) (exists bool, err error) {
const query = `SELECT EXISTS(SELECT 1 FROM peers WHERE NetworkID=? AND PeerIP=?)`
err = tx.QueryRow(query, networkID, ip).Scan(&exists)
return
}

View File

@@ -7,7 +7,6 @@ import (
var ( var (
ErrNotAuthorized = errors.New("not authorized") ErrNotAuthorized = errors.New("not authorized")
ErrNoIPAvailable = errors.New("no IP address available")
ErrInvalidIP = db.ErrInvalidIP ErrInvalidIP = db.ErrInvalidIP
ErrInvalidPort = db.ErrInvalidPort ErrInvalidPort = db.ErrInvalidPort
) )

View File

@@ -5,21 +5,21 @@ CREATE TABLE config (
CREATE TABLE networks ( CREATE TABLE networks (
NetworkID INTEGER NOT NULL PRIMARY KEY, NetworkID INTEGER NOT NULL PRIMARY KEY,
Name TEXT NOT NULL UNIQUE, -- Network/interface name. LocalDomain TEXT NOT NULL UNIQUE, -- Network/interface name.
Network BLOB NOT NULL UNIQUE -- Network (/24), example 10.51.50.0 Network BLOB NOT NULL UNIQUE -- Network (/24), example 10.51.50.0
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE TABLE peers ( CREATE TABLE peers (
NetworkID INTEGER NOT NULL, NetworkID INTEGER NOT NULL,
PeerIP INTEGER NOT NULL, -- Final byte of IP. PeerIP INTEGER NOT NULL, -- Final byte of IP.
Version INTEGER NOT NULL, -- Changes when updated.
APIKey TEXT NOT NULL UNIQUE, -- Peer's secret API key. APIKey TEXT NOT NULL UNIQUE, -- Peer's secret API key.
Name TEXT NOT NULL UNIQUE, -- For humans. Name TEXT NOT NULL, -- For humans.
PublicIP1 BLOB NOT NULL, Addr4 BLOB NOT NULL,
Port1 INTEGER NOT NULL, Addr6 BLOB NOT NULL,
PublicIP2 BLOB NOT NULL, Port INTEGER NOT NULL,
Port2 INTEGER NOT NULL,
Relay INTEGER NOT NULL DEFAULT 0, -- Boolean if peer will forward packets. Relay INTEGER NOT NULL DEFAULT 0, -- Boolean if peer will forward packets.
WGPubKey BLOB NOT NULL, WGPubKey BLOB NOT NULL,
SignPubKey BLOB NOT NULL,
UNIQUE(NetworkID, Name),
PRIMARY KEY(NetworkID, PeerIP) PRIMARY KEY(NetworkID, PeerIP)
) WITHOUT ROWID; ) WITHOUT ROWID;

View File

@@ -2,7 +2,6 @@ package hub
import ( import (
"net/http" "net/http"
"time"
) )
func (a *App) getCookie(r *http.Request, name string) string { func (a *App) getCookie(r *http.Request, name string) string {
@@ -29,6 +28,9 @@ func (a *App) deleteCookie(w http.ResponseWriter, name string) {
Name: name, Name: name,
Value: "", Value: "",
Path: "/", Path: "/",
Expires: time.Unix(0, 0), Secure: !a.insecure,
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
MaxAge: -1, // delete now
}) })
} }

View File

@@ -1,5 +1,5 @@
package hub package hub
const ( const (
SESSION_ID_COOKIE_NAME = "SessionID" sessionIDCookieName = "SessionID"
) )

View File

@@ -12,7 +12,7 @@ type handlerFunc func(s *api.Session, w http.ResponseWriter, r *http.Request) er
func (app *App) handlePub(pattern string, fn handlerFunc) { func (app *App) handlePub(pattern string, fn handlerFunc) {
wrapped := func(w http.ResponseWriter, r *http.Request) { wrapped := func(w http.ResponseWriter, r *http.Request) {
sessionID := app.getCookie(r, SESSION_ID_COOKIE_NAME) sessionID := app.getCookie(r, sessionIDCookieName)
s, err := app.api.Session_Get(sessionID) s, err := app.api.Session_Get(sessionID)
if err != nil { if err != nil {
log.Printf("Failed to get session: %v", err) log.Printf("Failed to get session: %v", err)
@@ -20,17 +20,13 @@ func (app *App) handlePub(pattern string, fn handlerFunc) {
return return
} }
if s.SessionID != sessionID {
app.setCookie(w, SESSION_ID_COOKIE_NAME, s.SessionID)
}
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
r.ParseMultipartForm(64 * 1024) r.ParseMultipartForm(64 * 1024)
} else { } else {
r.ParseForm() r.ParseForm()
} }
if err := fn(s, w, r); err != nil { if err := fn(&s, w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }

View File

@@ -10,6 +10,7 @@ import (
"git.crumpington.com/lib/go/webutil" "git.crumpington.com/lib/go/webutil"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
) )
func (a *App) _root(s *api.Session, w http.ResponseWriter, r *http.Request) error { func (a *App) _root(s *api.Session, w http.ResponseWriter, r *http.Request) error {
@@ -33,9 +34,11 @@ func (a *App) _signinSubmit(s *api.Session, w http.ResponseWriter, r *http.Reque
return err return err
} }
if err := a.api.Session_SignIn(s, pwd); err != nil { sess, err := a.api.Session_SignIn(pwd)
if err != nil {
return err return err
} }
a.setCookie(w, sessionIDCookieName, sess.SessionID)
return a.redirect(w, r, "/") return a.redirect(w, r, "/")
} }
@@ -48,7 +51,7 @@ func (a *App) _adminSignOutSubmit(s *api.Session, w http.ResponseWriter, r *http
if err := a.api.Session_Delete(s.SessionID); err != nil { if err := a.api.Session_Delete(s.SessionID); err != nil {
log.Printf("Failed to delete session cookie %s: %v", s.SessionID, err) log.Printf("Failed to delete session cookie %s: %v", s.SessionID, err)
} }
a.deleteCookie(w, SESSION_ID_COOKIE_NAME) a.deleteCookie(w, sessionIDCookieName)
return a.redirect(w, r, "/") return a.redirect(w, r, "/")
} }
@@ -72,7 +75,7 @@ func (a *App) _adminNetworkCreateSubmit(s *api.Session, w http.ResponseWriter, r
var netStr string var netStr string
err := webutil.NewFormScanner(r.Form). err := webutil.NewFormScanner(r.Form).
Scan("Name", &n.Name). Scan("LocalDomain", &n.LocalDomain).
Scan("Network", &netStr). Scan("Network", &netStr).
Error() Error()
if err != nil { if err != nil {
@@ -142,27 +145,26 @@ func (a *App) _adminPeerCreate(s *api.Session, w http.ResponseWriter, r *http.Re
} }
func (a *App) _adminPeerCreateSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error { func (a *App) _adminPeerCreateSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var ip1Str, ip2Str string var addr4Str, addr6Str string
p := &api.Peer{} p := &api.Peer{}
err := webutil.NewFormScanner(r.Form). err := webutil.NewFormScanner(r.Form).
Scan("NetworkID", &p.NetworkID). Scan("NetworkID", &p.NetworkID).
Scan("IP", &p.PeerIP). Scan("IP", &p.PeerIP).
Scan("Name", &p.Name). Scan("Name", &p.Name).
Scan("PublicIP1", &ip1Str). Scan("Addr4", &addr4Str).
Scan("Port1", &p.Port1). Scan("Addr6", &addr6Str).
Scan("PublicIP2", &ip2Str). Scan("Port", &p.Port).
Scan("Port2", &p.Port2).
Scan("Relay", &p.Relay). Scan("Relay", &p.Relay).
Error() Error()
if err != nil { if err != nil {
return err return err
} }
if p.PublicIP1, err = stringToIP(ip1Str); err != nil { if p.Addr4, err = stringToIP(addr4Str); err != nil {
return err return err
} }
if p.PublicIP2, err = stringToIP(ip2Str); err != nil { if p.Addr6, err = stringToIP(addr6Str); err != nil {
return err return err
} }
@@ -185,53 +187,6 @@ func (a *App) _adminPeerView(s *api.Session, w http.ResponseWriter, r *http.Requ
}{s, net, peer}) }{s, net, peer})
} }
func (a *App) _adminPeerEdit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
net, peer, err := a.formGetPeer(r.Form)
if err != nil {
return err
}
return a.render("/network/peer-edit.html", w, struct {
Session *api.Session
Network *api.Network
Peer *api.Peer
}{s, net, peer})
}
func (a *App) _adminPeerEditSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
_, peer, err := a.formGetPeer(r.Form)
if err != nil {
return err
}
var ip1Str, ip2Str string
err = webutil.NewFormScanner(r.Form).
Scan("Name", &peer.Name).
Scan("PublicIP1", &ip1Str).
Scan("Port1", &peer.Port1).
Scan("PublicIP2", &ip2Str).
Scan("Port2", &peer.Port2).
Scan("Relay", &peer.Relay).
Error()
if err != nil {
return err
}
if peer.PublicIP1, err = stringToIP(ip1Str); err != nil {
return err
}
if peer.PublicIP2, err = stringToIP(ip2Str); err != nil {
return err
}
if err = a.api.Peer_Update(peer); err != nil {
return err
}
return a.redirect(w, r, "/admin/peer/view/?NetworkID=%d&PeerIP=%d", peer.NetworkID, peer.PeerIP)
}
func (a *App) _adminPeerDelete(s *api.Session, w http.ResponseWriter, r *http.Request) error { func (a *App) _adminPeerDelete(s *api.Session, w http.ResponseWriter, r *http.Request) error {
n, peer, err := a.formGetPeer(r.Form) n, peer, err := a.formGetPeer(r.Form)
if err != nil { if err != nil {
@@ -262,13 +217,17 @@ func (a *App) _adminPasswordEdit(s *api.Session, w http.ResponseWriter, r *http.
func (a *App) _adminPasswordSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error { func (a *App) _adminPasswordSubmit(s *api.Session, w http.ResponseWriter, r *http.Request) error {
var ( var (
conf = a.api.Config_Get()
curPwd string curPwd string
newPwd string newPwd string
newPwd2 string newPwd2 string
) )
err := webutil.NewFormScanner(r.Form). conf, err := a.api.Config_Get()
if err != nil {
return err
}
err = webutil.NewFormScanner(r.Form).
Scan("CurrentPassword", &curPwd). Scan("CurrentPassword", &curPwd).
Scan("NewPassword", &newPwd). Scan("NewPassword", &newPwd).
Scan("NewPassword2", &newPwd2). Scan("NewPassword2", &newPwd2).
@@ -315,6 +274,15 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request)
return err return err
} }
if len(args.WGPubKey) != 32 {
http.Error(w, "invalid WGPubKey", http.StatusBadRequest)
return nil
}
if len(args.SignPubKey) != 32 {
http.Error(w, "invalid SignPubKey", http.StatusBadRequest)
return nil
}
net, err := a.api.Network_Get(peer.NetworkID) net, err := a.api.Network_Get(peer.NetworkID)
if err != nil { if err != nil {
return err return err
@@ -327,9 +295,10 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request)
resp := m.PeerInitResp{ resp := m.PeerInitResp{
PeerIP: peer.PeerIP, PeerIP: peer.PeerIP,
Network: net.Network, Network: net.Network,
LocalDomain: net.LocalDomain,
} }
resp.NetworkState.Peers, err = a.peersArray(net.NetworkID) resp.NetworkState.Peers, err = a.peersList(net.NetworkID)
if err != nil { if err != nil {
return err return err
} }
@@ -338,35 +307,42 @@ 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 { func (a *App) _peerFetchState(peer *api.Peer, w http.ResponseWriter, r *http.Request) error {
peers, err := a.peersList(peer.NetworkID)
peers, err := a.peersArray(peer.NetworkID)
if err != nil { if err != nil {
return err return err
} }
return a.sendJSON(w, m.NetworkState{Peers: peers}) return a.sendJSON(w, m.NetworkState{Peers: peers})
} }
func (a *App) peersArray(networkID int64) (peers [256]*m.Peer, err error) { func (a *App) peersList(networkID int64) (peers []m.Peer, err error) {
l, err := a.api.Peer_List(networkID) l, err := a.api.Peer_List(networkID)
if err != nil { if err != nil {
return peers, err return nil, err
} }
peers = make([]m.Peer, 0, len(l))
for _, p := range l { for _, p := range l {
if len(p.WGPubKey) != 0 { if len(p.WGPubKey) == 0 {
peers[p.PeerIP] = &m.Peer{ continue
}
wgKey, err := wgtypes.NewKey(p.WGPubKey)
if err != nil {
continue // malformed key; skip rather than serve garbage
}
var signKey [32]byte
copy(signKey[:], p.SignPubKey)
peers = append(peers, m.Peer{
PeerIP: p.PeerIP, PeerIP: p.PeerIP,
Version: p.Version,
Name: p.Name, Name: p.Name,
PublicIP1: p.PublicIP1, Addr4: addrFromBytes(p.Addr4),
Port1: p.Port1, Addr6: addrFromBytes(p.Addr6),
PublicIP2: p.PublicIP2, Port: p.Port,
Port2: p.Port2,
Relay: p.Relay, Relay: p.Relay,
WGPubKey: p.WGPubKey, WGPubKey: wgKey,
} SignPubKey: signKey,
} })
} }
return return peers, nil
} }

View File

@@ -22,8 +22,6 @@ func (a *App) registerRoutes() {
a.handleSignedIn("GET /admin/peer/create/", a._adminPeerCreate) a.handleSignedIn("GET /admin/peer/create/", a._adminPeerCreate)
a.handleSignedIn("POST /admin/peer/create/", a._adminPeerCreateSubmit) a.handleSignedIn("POST /admin/peer/create/", a._adminPeerCreateSubmit)
a.handleSignedIn("GET /admin/peer/view/", a._adminPeerView) a.handleSignedIn("GET /admin/peer/view/", a._adminPeerView)
a.handleSignedIn("GET /admin/peer/edit/", a._adminPeerEdit)
a.handleSignedIn("POST /admin/peer/edit/", a._adminPeerEditSubmit)
a.handleSignedIn("GET /admin/peer/delete/", a._adminPeerDelete) a.handleSignedIn("GET /admin/peer/delete/", a._adminPeerDelete)
a.handleSignedIn("POST /admin/peer/delete/", a._adminPeerDeleteSubmit) a.handleSignedIn("POST /admin/peer/delete/", a._adminPeerDeleteSubmit)

View File

@@ -3,8 +3,8 @@
<form method="POST"> <form method="POST">
<p> <p>
<label>Name</label><br> <label>Local Domain (ending with .local)</label><br>
<input type="text" name="Name"> <input type="text" name="LocalDomain">
</p> </p>
<p> <p>
<label>Network /24</label><br> <label>Network /24</label><br>

View File

@@ -9,7 +9,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Local Domain</th>
<th>Network</th> <th>Network</th>
</tr> </tr>
</thead> </thead>
@@ -18,7 +18,7 @@
<tr> <tr>
<td> <td>
<a href="/admin/network/view/?NetworkID={{.NetworkID}}"> <a href="/admin/network/view/?NetworkID={{.NetworkID}}">
{{.Name}} {{.LocalDomain}}
</a> </a>
</td> </td>
<td>{{ipToString .Network}}</td> <td>{{ipToString .Network}}</td>

View File

@@ -17,7 +17,7 @@
</header> </header>
<h2> <h2>
Network: Network:
<a href="/admin/network/view/?NetworkID={{.Network.NetworkID}}">{{.Network.Name}}</a> <a href="/admin/network/view/?NetworkID={{.Network.NetworkID}}">{{.Network.LocalDomain}}</a>
</h2> </h2>
{{block "body" .}}There's nothing here.{{end}} {{block "body" .}}There's nothing here.{{end}}

View File

@@ -22,10 +22,9 @@
<tr> <tr>
<th>PeerIP</th> <th>PeerIP</th>
<th>Name</th> <th>Name</th>
<th>Public IP 1</th> <th>IPv4</th>
<th>Port 1</th> <th>IPv6</th>
<th>Public IP 2</th> <th>Port</th>
<th>Port 2</th>
<th>Relay</th> <th>Relay</th>
</tr> </tr>
</thead> </thead>
@@ -38,10 +37,9 @@
</a> </a>
</td> </td>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td>{{ipToString .PublicIP1}}</td> <td>{{ipToString .Addr4}}</td>
<td>{{.Port1}}</td> <td>{{ipToString .Addr6}}</td>
<td>{{ipToString .PublicIP2}}</td> <td>{{.Port}}</td>
<td>{{.Port2}}</td>
<td>{{if .Relay}}T{{else}}F{{end}}</td> <td>{{if .Relay}}T{{else}}F{{end}}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -12,20 +12,16 @@
<input type="text" name="Name"> <input type="text" name="Name">
</p> </p>
<p> <p>
<label>Public IP 1</label><br> <label>IPv4 Address (optional)</label><br>
<input type="text" name="PublicIP1"> <input type="text" name="Addr4">
</p> </p>
<p> <p>
<label>Port 1</label><br> <label>IPv6 Address (optional)</label><br>
<input type="number" name="Port1" value="456"> <input type="text" name="Addr6">
</p> </p>
<p> <p>
<label>Public IP 2 (optional)</label><br> <label>WireGuard Port</label><br>
<input type="text" name="PublicIP2"> <input type="number" name="Port" value="51820">
</p>
<p>
<label>Port 2</label><br>
<input type="number" name="Port2" value="0">
</p> </p>
<p> <p>
<label> <label>

View File

@@ -4,7 +4,7 @@
{{with .Peer -}} {{with .Peer -}}
<form method="POST"> <form method="POST">
<input type="hidden" name="NetworkID" value="{{.NetworkID}}"> <input type="hidden" name="NetworkID" value="{{.NetworkID}}">
<input type="hidden" name="NetworkID" value="{{.PeerIP}}"> <input type="hidden" name="PeerIP" value="{{.PeerIP}}">
<p> <p>
<button type="submit">Delete</button> <button type="submit">Delete</button>
<a href="/admin/peer/view/?PeerIP={{.PeerIP}}&NetworkID={{.NetworkID}}">Cancel</a> <a href="/admin/peer/view/?PeerIP={{.PeerIP}}&NetworkID={{.NetworkID}}">Cancel</a>

View File

@@ -1,42 +0,0 @@
{{define "body" -}}
<h2>Edit Peer</h2>
{{with .Peer -}}
<form method="POST">
<p>
<label>Peer IP</label><br>
<input type="text" value="{{.PeerIP}}" disabled>
</p>
<p>
<label>Name</label><br>
<input type="text" name="Name" value="{{.Name}}">
</p>
<p>
<label>Public IP 1</label><br>
<input type="text" name="PublicIP1" value="{{ipToString .PublicIP1}}">
</p>
<p>
<label>Port 1</label><br>
<input type="number" name="Port1" value="{{.Port1}}">
</p>
<p>
<label>Public IP 2 (optional)</label><br>
<input type="text" name="PublicIP2" value="{{ipToString .PublicIP2}}">
</p>
<p>
<label>Port 2</label><br>
<input type="number" name="Port2" value="{{.Port2}}">
</p>
<p>
<label>
<input type="checkbox" name="Relay" {{if .Relay}}checked{{end}}>
Relay
</label>
</p>
<p>
<button type="submit">Save</button>
<a href="/admin/peer/view/?NetworkID={{$.Network.NetworkID}}&PeerIP={{.PeerIP}}">Cancel</a>
</p>
</form>
{{- end}}
{{- end}}

View File

@@ -1,17 +1,15 @@
{{define "body" -}} {{define "body" -}}
<h3>{{.Peer.Name}}</h3> <h3>{{.Peer.Name}}</h3>
<p> <p>
<a href="/admin/peer/edit/?NetworkID={{.Network.NetworkID}}&PeerIP={{.Peer.PeerIP}}">Edit</a> /
<a href="/admin/peer/delete/?NetworkID={{.Network.NetworkID}}&PeerIP={{.Peer.PeerIP}}">Delete</a> <a href="/admin/peer/delete/?NetworkID={{.Network.NetworkID}}&PeerIP={{.Peer.PeerIP}}">Delete</a>
</p> </p>
{{with .Peer -}} {{with .Peer -}}
<table class="def-list"> <table class="def-list">
<tr><td>Peer IP</td><td>{{.PeerIP}}</td></tr> <tr><td>Peer IP</td><td>{{.PeerIP}}</td></tr>
<tr><td>Public IP 1</td><td>{{ipToString .PublicIP1}}</td></tr> <tr><td>IPv4 Address</td><td>{{ipToString .Addr4}}</td></tr>
<tr><td>Port 1</td><td>{{.Port1}}</td></tr> <tr><td>IPv6 Address</td><td>{{ipToString .Addr6}}</td></tr>
<tr><td>Public IP 2</td><td>{{ipToString .PublicIP2}}</td></tr> <tr><td>WireGuard Port</td><td>{{.Port}}</td></tr>
<tr><td>Port 2</td><td>{{.Port2}}</td></tr>
<tr><td>Relay</td><td>{{if .Relay}}T{{else}}F{{end}}</td></tr> <tr><td>Relay</td><td>{{if .Relay}}T{{else}}F{{end}}</td></tr>
<tr><td>WG Public Key</td><td>{{wgKeyString .WGPubKey}}</td></tr> <tr><td>WG Public Key</td><td>{{wgKeyString .WGPubKey}}</td></tr>
</table> </table>

View File

@@ -38,6 +38,19 @@ func (app *App) sendJSON(w http.ResponseWriter, data any) error {
return nil return nil
} }
// addrFromBytes parses raw IP bytes (4 or 16) into a netip.Addr, unmapping
// IPv4-in-IPv6, returning the zero Addr for empty/invalid input.
func addrFromBytes(b []byte) netip.Addr {
if len(b) == 0 {
return netip.Addr{}
}
addr, ok := netip.AddrFromSlice(b)
if !ok {
return netip.Addr{}
}
return addr.Unmap()
}
func stringToIP(in string) ([]byte, error) { func stringToIP(in string) ([]byte, error) {
in = strings.TrimSpace(in) in = strings.TrimSpace(in)
if len(in) == 0 { if len(in) == 0 {

View File

@@ -1,28 +1,133 @@
// The package `m` contains models shared between the hub and peer programs. // The package `m` contains models shared between the hub and peer programs.
package m package m
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/netip"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type PeerInitArgs struct { type PeerInitArgs struct {
WGPubKey []byte WGPubKey []byte
SignPubKey []byte
} }
type PeerInitResp struct { type PeerInitResp struct {
PeerIP byte PeerIP byte
Network []byte Network []byte
LocalDomain string
NetworkState NetworkState NetworkState NetworkState
} }
// Peer is the network membership record for a single peer, exchanged between
// the hub and peers. Addr4/Addr6 are the peer's public endpoint addresses (zero
// if it has none); Port is its WireGuard listen port, meaningful even for a
// non-public peer (it is the peer's own bind/beacon port).
type Peer struct { type Peer struct {
PeerIP byte PeerIP byte
Version int64
Name string Name string
PublicIP1 []byte Addr4 netip.Addr // zero if none
Port1 uint16 Addr6 netip.Addr // zero if none
PublicIP2 []byte Port uint16
Port2 uint16
Relay bool Relay bool
WGPubKey []byte WGPubKey wgtypes.Key
SignPubKey [32]byte
}
// IsPublic reports whether the peer advertises at least one reachable endpoint.
func (p Peer) IsPublic() bool {
return p.Addr4.IsValid() || p.Addr6.IsValid()
}
// Endpoint4 returns the IPv4 endpoint (addr+port), or the zero AddrPort if the
// peer has no IPv4 address.
func (p Peer) Endpoint4() netip.AddrPort {
if !p.Addr4.IsValid() {
return netip.AddrPort{}
}
return netip.AddrPortFrom(p.Addr4, p.Port)
}
// Endpoint6 returns the IPv6 endpoint (addr+port), or the zero AddrPort if the
// peer has no IPv6 address.
func (p Peer) Endpoint6() netip.AddrPort {
if !p.Addr6.IsValid() {
return netip.AddrPort{}
}
return netip.AddrPortFrom(p.Addr6, p.Port)
}
// PreferredEndpoint returns the IPv4 endpoint if present, else IPv6.
func (p Peer) PreferredEndpoint() netip.AddrPort {
if ep := p.Endpoint4(); ep.IsValid() {
return ep
}
return p.Endpoint6()
}
// peerJSON is the wire representation. netip.Addr fields round-trip as text
// strings automatically; only the fixed-size key arrays need base64 (otherwise
// encoding/json would emit them as arrays of numbers).
type peerJSON struct {
PeerIP byte
Name string
Addr4 netip.Addr
Addr6 netip.Addr
Port uint16
Relay bool
WGPubKey string
SignPubKey string
}
func (p Peer) MarshalJSON() ([]byte, error) {
return json.Marshal(peerJSON{
PeerIP: p.PeerIP,
Name: p.Name,
Addr4: p.Addr4,
Addr6: p.Addr6,
Port: p.Port,
Relay: p.Relay,
WGPubKey: base64.StdEncoding.EncodeToString(p.WGPubKey[:]),
SignPubKey: base64.StdEncoding.EncodeToString(p.SignPubKey[:]),
})
}
func (p *Peer) UnmarshalJSON(data []byte) error {
var j peerJSON
if err := json.Unmarshal(data, &j); err != nil {
return err
}
wg, err := base64.StdEncoding.DecodeString(j.WGPubKey)
if err != nil {
return fmt.Errorf("decode WGPubKey: %w", err)
}
key, err := wgtypes.NewKey(wg)
if err != nil {
return fmt.Errorf("invalid WGPubKey: %w", err)
}
sign, err := base64.StdEncoding.DecodeString(j.SignPubKey)
if err != nil {
return fmt.Errorf("decode SignPubKey: %w", err)
}
if len(sign) != 32 {
return fmt.Errorf("invalid SignPubKey length: %d", len(sign))
}
*p = Peer{
PeerIP: j.PeerIP,
Name: j.Name,
Addr4: j.Addr4,
Addr6: j.Addr6,
Port: j.Port,
Relay: j.Relay,
WGPubKey: key,
SignPubKey: [32]byte(sign),
}
return nil
} }
type NetworkState struct { type NetworkState struct {
Peers [256]*Peer Peers []Peer
} }

105
peer/app.go Normal file
View File

@@ -0,0 +1,105 @@
package peer
import (
"net/netip"
"os"
"os/signal"
"syscall"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/control"
"vppn/peer/multicast"
"vppn/peer/wginterface"
)
var _ WGDevice = (*wginterface.Device)(nil) // compile-time check: Device satisfies WGDevice
const (
ControlPort = 4561
PingInterval = 8 * time.Second
TimeoutInterval = 30 * time.Second
)
// scratchSize is large enough for the biggest buffer either the ping or the
// multicast path serializes through the shared App scratch.
const scratchSize = max(control.Size, multicast.SignedPacketSize)
type PingEvent struct {
srcVPNIP netip.Addr
ping control.Ping
}
// App is the peer application. All mutable state lives here and is
// 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
localDomain string
// Infrastructure
dev WGDevice
controlConn ControlConn
// Peer state
relay *Peer
peersByKey map[wgtypes.Key]*Peer
peersByIP map[netip.Addr]*Peer
// Our own external endpoints, learned from Dst fields in incoming pings
selfV4 netip.AddrPort
selfV6 netip.AddrPort
// Reusable serialization scratch for outgoing pings and multicast signature
// verification. Only touched from the Run goroutine.
scratch []byte
// Event channels fed by background goroutines
hubAddCh <-chan m.Peer
hubRemoveCh <-chan wgtypes.Key
pingCh <-chan PingEvent
multicastCh <-chan multicast.Packet
}
// 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()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sig)
for {
select {
case p := <-a.hubAddCh:
a.onAddPeer(p)
case key := <-a.hubRemoveCh:
a.onRemovePeer(key)
case e := <-a.pingCh:
a.onPing(e)
case e := <-a.multicastCh:
a.onMulticastDiscovery(e)
case <-ticker.C:
a.onTick()
case <-sig:
return a.onShutdown()
}
}
}
func (a *App) onShutdown() error {
return wginterface.Delete(a.dev.Name())
}

62
peer/app_test.go Normal file
View File

@@ -0,0 +1,62 @@
package peer
import (
"net/netip"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/multicast"
)
// addRelayPeer adds a public relay peer and marks it Up so it satisfies
// CanRelay. It does not set a.relay — callers do that explicitly.
func addRelayPeer(t *testing.T, a *App, vpnIP string, ep netip.AddrPort) *Peer {
t.Helper()
key := mustKey(t)
ip := netip.MustParseAddr(vpnIP)
a.onAddPeer(m.Peer{
WGPubKey: key,
PeerIP: ip.As4()[3],
Addr4: ep.Addr(),
Port: ep.Port(),
Relay: true,
})
p := a.peersByKey[key]
p.wgPeer.LastHandshakeTime = time.Now()
return p
}
// newTestApp returns a minimal App wired to a fakeWGDevice and fakeControlConn.
// vpnIP is the local VPN address (e.g. "10.0.0.1").
// isPublic / isRelay describe the local node's role.
func newTestApp(t *testing.T, vpnIP string, isPublic, isRelay bool) (*App, *fakeWGDevice, *fakeControlConn) {
t.Helper()
privKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatalf("generate key: %v", err)
}
ip := netip.MustParseAddr(vpnIP)
dev := &fakeWGDevice{}
cc := &fakeControlConn{}
a := &App{
vpnIP: ip,
vpnNet: netip.MustParsePrefix("10.0.0.0/24"),
privKey: privKey,
pubKey: privKey.PublicKey(),
isPublic: isPublic,
isRelay: isRelay,
dev: dev,
controlConn: cc,
peersByKey: make(map[wgtypes.Key]*Peer),
peersByIP: make(map[netip.Addr]*Peer),
scratch: make([]byte, scratchSize),
hubAddCh: make(chan m.Peer),
hubRemoveCh: make(chan wgtypes.Key),
pingCh: make(chan PingEvent),
multicastCh: make(chan multicast.Packet),
}
return a, dev, cc
}

76
peer/control/ping.go Normal file
View File

@@ -0,0 +1,76 @@
// Package control implements the VPN-internal peer control protocol.
// Peers exchange Ping packets over UDP on the VPN control port to maintain
// liveness and discover external endpoints for direct connection attempts.
package control
import (
"encoding/binary"
"fmt"
"net/netip"
)
const (
version = 1
Size = 51 // 1 version + 8 PingTS + 6 SrcV4 + 18 SrcV6 + 18 Dst
)
// Ping is the single control packet type exchanged between VPN peers.
//
// In each peer pair, the peer with the lower VPN IP is the client: it sets
// PingTS and sends pings on a timer. The server echoes PingTS back in its
// response, allowing the client to compute RTT = now - PingTS.
//
// Both client and server populate SrcV4, SrcV6, and Dst on every packet so
// endpoint information flows in both directions.
//
// Dst is the recipient's external endpoint as observed by the sender from the
// WireGuard handshake source. Zero if the sender has not observed a handshake
// from the recipient.
type Ping struct {
PingTS int64 // Client ping send time in nanoseconds.
SrcV4 netip.AddrPort // Sender's discovered IPv4 address and port.
SrcV6 netip.AddrPort // Sender's discovered IPv6 address and port.
Dst netip.AddrPort
}
// Marshal encodes p into buf (which must be at least Size bytes) and returns
// buf[:Size]. Taking the buffer lets callers reuse one across sends; every
// field is written unconditionally so a reused buffer needs no pre-zeroing.
func (p Ping) Marshal(buf []byte) []byte {
buf[0] = version
binary.BigEndian.PutUint64(buf[1:9], uint64(p.PingTS))
if p.SrcV4.IsValid() {
a4 := p.SrcV4.Addr().As4()
copy(buf[9:13], a4[:])
binary.BigEndian.PutUint16(buf[13:15], p.SrcV4.Port())
} else {
clear(buf[9:15])
}
a16 := p.SrcV6.Addr().As16()
copy(buf[15:31], a16[:])
binary.BigEndian.PutUint16(buf[31:33], p.SrcV6.Port())
a16 = p.Dst.Addr().As16()
copy(buf[33:49], a16[:])
binary.BigEndian.PutUint16(buf[49:51], p.Dst.Port())
return buf[:Size]
}
// Unmarshal decodes a Ping from a fixed-size 51-byte array.
func Unmarshal(buf [Size]byte) (Ping, error) {
if buf[0] != version {
return Ping{}, fmt.Errorf("unknown ping version %d", buf[0])
}
p := Ping{
PingTS: int64(binary.BigEndian.Uint64(buf[1:9])),
}
if addr := netip.AddrFrom4([4]byte(buf[9:13])); !addr.IsUnspecified() {
p.SrcV4 = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[13:15]))
}
if addr := netip.AddrFrom16([16]byte(buf[15:31])); !addr.IsUnspecified() {
p.SrcV6 = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[31:33]))
}
if addr := netip.AddrFrom16([16]byte(buf[33:49])).Unmap(); !addr.IsUnspecified() {
p.Dst = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[49:51]))
}
return p, nil
}

106
peer/control/ping_test.go Normal file
View File

@@ -0,0 +1,106 @@
package control_test
import (
"net/netip"
"testing"
"vppn/peer/control"
)
func TestRoundTrip(t *testing.T) {
cases := []struct {
name string
ping control.Ping
}{
{
name: "zero",
ping: control.Ping{},
},
{
name: "client ping",
ping: control.Ping{
PingTS: 1234567890,
SrcV4: netip.MustParseAddrPort("1.2.3.4:51820"),
Dst: netip.MustParseAddrPort("5.6.7.8:51820"),
},
},
{
name: "server response",
ping: control.Ping{
PingTS: 1234567890,
SrcV4: netip.MustParseAddrPort("5.6.7.8:51820"),
Dst: netip.MustParseAddrPort("1.2.3.4:9999"),
},
},
{
name: "IPv6 only",
ping: control.Ping{
PingTS: 999,
SrcV6: netip.MustParseAddrPort("[2001:db8::1]:51820"),
Dst: netip.MustParseAddrPort("[2001:db8::2]:51820"),
},
},
{
name: "dual stack",
ping: control.Ping{
PingTS: 555,
SrcV4: netip.MustParseAddrPort("1.2.3.4:51820"),
SrcV6: netip.MustParseAddrPort("[2001:db8::1]:51820"),
Dst: netip.MustParseAddrPort("5.6.7.8:9999"),
},
},
{
name: "no src known",
ping: control.Ping{
Dst: netip.MustParseAddrPort("5.6.7.8:51820"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf [control.Size]byte
tc.ping.Marshal(buf[:])
got, err := control.Unmarshal(buf)
if err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got != tc.ping {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", got, tc.ping)
}
})
}
}
func TestUnmarshalBadVersion(t *testing.T) {
var buf [control.Size]byte
buf[0] = 99
if _, err := control.Unmarshal(buf); err == nil {
t.Fatal("expected error for unknown version, got nil")
}
}
func TestZeroEncoding(t *testing.T) {
var buf [control.Size]byte
(control.Ping{}).Marshal(buf[:])
for i, b := range buf {
if i == 0 {
continue // version byte
}
if b != 0 {
t.Fatalf("expected zero encoding at byte %d, got %d", i, b)
}
}
}
func TestRoleFor(t *testing.T) {
lo := netip.MustParseAddr("10.0.0.1")
hi := netip.MustParseAddr("10.0.0.2")
if control.RoleFor(lo, hi) != control.Client {
t.Error("lower IP should be client")
}
if control.RoleFor(hi, lo) != control.Server {
t.Error("higher IP should be server")
}
}

22
peer/control/role.go Normal file
View File

@@ -0,0 +1,22 @@
package control
import "net/netip"
// Role identifies a peer's role in a ping exchange with a specific remote peer.
type Role string
const (
// Client initiates pings and measures RTT.
Client Role = "CLIENT"
// Server responds to pings.
Server Role = "SERVER"
)
// RoleFor returns the Role of local relative to remote.
// The peer with the lower VPN IP is the client.
func RoleFor(local, remote netip.Addr) Role {
if local.Compare(remote) < 0 {
return Client
}
return Server
}

64
peer/control_conn.go Normal file
View File

@@ -0,0 +1,64 @@
package peer
import (
"log"
"net"
"net/netip"
"vppn/peer/control"
)
var _ ControlConn = (*udpControlConn)(nil)
type udpControlConn struct {
conn *net.UDPConn
}
// newUDPControlConn opens a UDP socket bound to localIP:port.
func newUDPControlConn(localIP netip.Addr, port uint16) (*udpControlConn, error) {
addr := net.UDPAddrFromAddrPort(netip.AddrPortFrom(localIP, port))
conn, err := net.ListenUDP("udp4", addr)
if err != nil {
return nil, err
}
return &udpControlConn{conn: conn}, nil
}
func (c *udpControlConn) SendPing(dst netip.AddrPort, ping control.Ping, buf []byte) error {
_, err := c.conn.WriteToUDP(ping.Marshal(buf), net.UDPAddrFromAddrPort(dst))
return err
}
// run reads incoming ping packets and forwards them to ch until ctx is done.
// Call this in a goroutine before starting the App event loop.
func (c *udpControlConn) run(ch chan<- PingEvent) {
var buf [control.Size]byte
for {
n, src, err := c.conn.ReadFromUDP(buf[:])
if err != nil {
log.Printf("control read: %v", err)
continue
}
if n != control.Size {
continue
}
ping, err := control.Unmarshal(buf)
if err != nil {
log.Printf("control unmarshal: %v", err)
continue
}
srcIP, ok := netip.AddrFromSlice(src.IP)
if !ok {
continue
}
ch <- PingEvent{srcVPNIP: srcIP.Unmap(), ping: ping}
}
}
func (c *udpControlConn) Close() error {
return c.conn.Close()
}

View File

@@ -1,15 +0,0 @@
package peer
import (
"log"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func generateWGKey() wgtypes.Key {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
log.Fatalf("Failed to generate WireGuard private key: %v", err)
}
return key
}

79
peer/device.go Normal file
View File

@@ -0,0 +1,79 @@
package peer
import (
"errors"
"log"
"net/netip"
"syscall"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// devRetry calls fn up to 6 times with exponential backoff, retrying on EBUSY
// (transient netlink contention during WireGuard handshake/rekey). Fatal on
// any other error.
func devRetry(vpnIP netip.Addr, op string, fn func() error) {
const attempts = 6
timeout := 10 * time.Millisecond
for i := range attempts {
err := fn()
if err == nil {
return
}
if errors.Is(err, syscall.EBUSY) && i < attempts-1 {
time.Sleep(timeout)
timeout *= 2
continue
}
log.Fatalf("%s %v: %v", op, vpnIP, err)
}
}
func (a *App) devPeers() []wgtypes.Peer {
peers, err := a.dev.Peers()
if err != nil {
log.Fatalf("Failed to get peers %v: %v", a.vpnIP, err)
}
return peers
}
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 // Dirrect connection. The app marks peer as relay.
}
func (a *App) devPromote(p *Peer) {
ep := p.WGEndpoint()
if ep.IsValid() {
log.Printf("PROMOTED: %s - %s @ %s", p.Name, p.VPNIP.String(), p.WGEndpoint().String())
} else {
log.Printf("PROMOTED: %s - %s (no IP)", p.Name, p.VPNIP.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()) })
}

View File

@@ -0,0 +1,43 @@
package peer
import (
"net/netip"
"testing"
"vppn/peer/control"
)
type sentPing struct {
Dst netip.AddrPort
Ping control.Ping
}
type fakeControlConn struct {
Sent []sentPing
}
func (f *fakeControlConn) SendPing(dst netip.AddrPort, ping control.Ping, _ []byte) error {
f.Sent = append(f.Sent, sentPing{Dst: dst, Ping: ping})
return nil
}
func (f *fakeControlConn) AssertNone(t *testing.T) {
t.Helper()
if len(f.Sent) != 0 {
t.Fatalf("expected no pings sent, got %d: %v", len(f.Sent), f.Sent)
}
}
func (f *fakeControlConn) AssertSent(t *testing.T, i int, dst netip.AddrPort, ping control.Ping) {
t.Helper()
if i >= len(f.Sent) {
t.Fatalf("no ping at index %d (have %d)", i, len(f.Sent))
}
got := f.Sent[i]
if got.Dst != dst {
t.Errorf("ping[%d].Dst = %v, want %v", i, got.Dst, dst)
}
if got.Ping != ping {
t.Errorf("ping[%d].Ping = %+v, want %+v", i, got.Ping, ping)
}
}

123
peer/fake_wgdevice_test.go Normal file
View File

@@ -0,0 +1,123 @@
package peer
import (
"net/netip"
"sync"
"testing"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// fakeWGDevice records every call made to it. It is safe to read Calls after
// the event loop has processed the event under test (single-threaded loop
// means no extra synchronisation needed, but the mutex guards concurrent test
// helpers if needed).
type fakeWGDevice struct {
mu sync.Mutex
Calls []fakeCall
peers []wgtypes.Peer
}
type fakeCall struct {
Method string
PubKey wgtypes.Key
Endpoint netip.AddrPort
VPNiP netip.Addr
Network netip.Prefix
}
func (f *fakeWGDevice) record(c fakeCall) {
f.mu.Lock()
f.Calls = append(f.Calls, c)
f.mu.Unlock()
}
func (f *fakeWGDevice) Name() string { return "wg-test" }
func (f *fakeWGDevice) Peers() ([]wgtypes.Peer, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]wgtypes.Peer, len(f.peers))
copy(out, f.peers)
return out, nil
}
func (f *fakeWGDevice) AddPeer(pubKey wgtypes.Key) error {
f.record(fakeCall{Method: "AddPeer", PubKey: pubKey})
return nil
}
func (f *fakeWGDevice) AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error {
f.record(fakeCall{Method: "AddDirect", PubKey: pubKey, Endpoint: endpoint, VPNiP: vpnIP})
return nil
}
func (f *fakeWGDevice) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error {
f.record(fakeCall{Method: "SetRelay", PubKey: pubKey, Endpoint: endpoint, Network: network})
return nil
}
func (f *fakeWGDevice) AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error {
f.record(fakeCall{Method: "AddProbe", PubKey: pubKey, Endpoint: endpoint})
return nil
}
func (f *fakeWGDevice) Promote(pubKey wgtypes.Key, vpnIP netip.Addr) error {
f.record(fakeCall{Method: "Promote", PubKey: pubKey, VPNiP: vpnIP})
return nil
}
func (f *fakeWGDevice) RemovePeer(pubKey wgtypes.Key) error {
f.record(fakeCall{Method: "RemovePeer", PubKey: pubKey})
return nil
}
// AssertNoCalls fails the test if any dev calls were recorded.
func (f *fakeWGDevice) AssertNoCalls(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.Calls) != 0 {
t.Fatalf("unexpected dev calls: %v", f.Calls)
}
}
func (f *fakeWGDevice) AssertAddPeer(t *testing.T, i int, pubKey wgtypes.Key) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddPeer", PubKey: pubKey})
}
func (f *fakeWGDevice) AssertAddDirect(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddDirect", PubKey: pubKey, Endpoint: endpoint, VPNiP: vpnIP})
}
func (f *fakeWGDevice) AssertSetRelay(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "SetRelay", PubKey: pubKey, Endpoint: endpoint, Network: network})
}
func (f *fakeWGDevice) AssertAddProbe(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddProbe", PubKey: pubKey, Endpoint: endpoint})
}
func (f *fakeWGDevice) AssertPromote(t *testing.T, i int, pubKey wgtypes.Key, vpnIP netip.Addr) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "Promote", PubKey: pubKey, VPNiP: vpnIP})
}
func (f *fakeWGDevice) AssertRemovePeer(t *testing.T, i int, pubKey wgtypes.Key) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "RemovePeer", PubKey: pubKey})
}
func (f *fakeWGDevice) assertCall(t *testing.T, i int, c fakeCall) {
t.Helper()
if len(f.Calls) <= i {
t.Fatalf("no call at index %d: %v", i, c)
}
if c != f.Calls[i] {
t.Fatalf("call[%d]: got %v, want %v", i, f.Calls[i], c)
}
}

View File

@@ -1,97 +0,0 @@
package peer
import (
"encoding/json"
"log"
"os"
"path/filepath"
"vppn/m"
)
type LocalConfig struct {
LocalPeerIP byte
Network []byte
WGPrivKey string
}
func configDir(netName string) string {
d, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Failed to get user home directory: %v", err)
}
return filepath.Join(d, ".vppn", netName)
}
func lockFilePath(netName string) string {
return filepath.Join(configDir(netName), "__lock__")
}
func peerConfigPath(netName string) string {
return filepath.Join(configDir(netName), "config.json")
}
func peerStatePath(netName string) string {
return filepath.Join(configDir(netName), "state.json")
}
func statusSocketPath(netName string) string {
return filepath.Join(configDir(netName), "status.sock")
}
func storeJson(x any, outPath string) error {
outDir := filepath.Dir(outPath)
_ = os.MkdirAll(outDir, 0700)
tmpPath := outPath + ".tmp"
buf, err := json.Marshal(x)
if err != nil {
return err
}
f, err := os.Create(tmpPath)
if err != nil {
return err
}
if _, err := f.Write(buf); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmpPath, outPath)
}
func storePeerConfig(netName string, pc LocalConfig) error {
return storeJson(pc, peerConfigPath(netName))
}
func storeNetworkState(netName string, ps m.NetworkState) error {
return storeJson(ps, peerStatePath(netName))
}
func loadJson(dataPath string, ptr any) error {
data, err := os.ReadFile(dataPath)
if err != nil {
return err
}
return json.Unmarshal(data, ptr)
}
func loadPeerConfig(netName string) (pc LocalConfig, err error) {
return pc, loadJson(peerConfigPath(netName), &pc)
}
func loadNetworkState(netName string) (ps m.NetworkState, err error) {
return ps, loadJson(peerStatePath(netName), &ps)
}

View File

@@ -1,57 +0,0 @@
package peer
import (
"path/filepath"
"reflect"
"testing"
)
func TestFilePaths(t *testing.T) {
confDir := configDir("netName")
if filepath.Base(confDir) != "netName" {
t.Fatal(confDir)
}
if filepath.Base(filepath.Dir(confDir)) != ".vppn" {
t.Fatal(confDir)
}
path := peerConfigPath("netName")
if path != filepath.Join(confDir, "config.json") {
t.Fatal(path)
}
path = peerStatePath("netName")
if path != filepath.Join(confDir, "state.json") {
t.Fatal(path)
}
}
func TestStoreLoadJson(t *testing.T) {
type Object struct {
Name string
Age int
Price float64
}
tmpDir := t.TempDir()
outPath := filepath.Join(tmpDir, "object.json")
obj := Object{
Name: "Jason",
Age: 22,
Price: 123.534,
}
if err := storeJson(obj, outPath); err != nil {
t.Fatal(err)
}
obj2 := Object{}
if err := loadJson(outPath, &obj2); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(obj, obj2) {
t.Fatal(obj, obj2)
}
}

View File

@@ -1,39 +0,0 @@
package peer
import (
"net"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
const (
broadcastInterval = 16 * time.Second
broadcastErrorTimeoutInterval = 8 * time.Second
)
var multicastAddr = net.UDPAddrFromAddrPort(netip.AddrPortFrom(
netip.AddrFrom4([4]byte{224, 0, 0, 157}),
4560))
type Globals struct {
LocalConfig // Embed, immutable.
// WireGuard private key, client, and device name. Immutable after init.
WGPrivKey wgtypes.Key
WGClient *wgctrl.Client
WGDevName string
// Local public address (if available). Immutable.
LocalAddr netip.AddrPort
LocalAddrValid bool
}
func NewGlobals(localConfig LocalConfig, localAddr netip.AddrPort) (g Globals) {
g.LocalConfig = localConfig
g.LocalAddr = localAddr
g.LocalAddrValid = localAddr.IsValid()
return g
}

128
peer/hosts.go Normal file
View File

@@ -0,0 +1,128 @@
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
}

205
peer/hosts_test.go Normal file
View File

@@ -0,0 +1,205 @@
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, end := hostMarkers(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)
}
}
// TestUpdateHosts_PrefixDomainsCoexist guards finding 4.4: two domains where
// one label is a prefix of the other ("net" vs "net2") must each manage their
// own section without clobbering the other's, even sharing one hosts file.
func TestUpdateHosts_PrefixDomainsCoexist(t *testing.T) {
path := writeTempHosts(t, "127.0.0.1 localhost\n")
if err := updateHosts(path, "net2.local", map[netip.Addr]*Peer{
netip.MustParseAddr("10.0.2.1"): peer("a"),
}); err != nil {
t.Fatal(err)
}
if err := updateHosts(path, "net.local", map[netip.Addr]*Peer{
netip.MustParseAddr("10.0.1.1"): peer("b"),
}); err != nil {
t.Fatal(err)
}
// Both sections coexist after writing the prefix domain.
if in, _ := readManagedSection(t, path, "net2.local"); len(in) != 1 || in[0] != "10.0.2.1 a.net2.local" {
t.Errorf("net2 section clobbered: %v", in)
}
if in, _ := readManagedSection(t, path, "net.local"); len(in) != 1 || in[0] != "10.0.1.1 b.net.local" {
t.Errorf("net section wrong: %v", in)
}
// Re-updating net2 must not disturb the net section.
if err := updateHosts(path, "net2.local", map[netip.Addr]*Peer{
netip.MustParseAddr("10.0.2.2"): peer("c"),
}); err != nil {
t.Fatal(err)
}
if in, _ := readManagedSection(t, path, "net.local"); len(in) != 1 || in[0] != "10.0.1.1 b.net.local" {
t.Errorf("net section disturbed by net2 update: %v", in)
}
if in, _ := readManagedSection(t, path, "net2.local"); len(in) != 1 || in[0] != "10.0.2.2 c.net2.local" {
t.Errorf("net2 section not updated: %v", in)
}
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

153
peer/hub_poller.go Normal file
View File

@@ -0,0 +1,153 @@
package peer
import (
"encoding/json"
"io"
"log"
"net/http"
"net/netip"
"net/url"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
const hubPollInterval = 64 * time.Second
type HubPoller struct {
selfVPNIP netip.Addr
vpnNet netip.Prefix
hubURL string
apiKey string
statePath string // where the network state cache is persisted
addCh chan<- m.Peer
removeCh chan<- wgtypes.Key
known map[wgtypes.Key]struct{} // pubKeys currently configured
}
func NewHubPoller(
selfVPNIP netip.Addr,
vpnNet netip.Prefix,
hubURL, apiKey string,
statePath string,
addCh chan<- m.Peer,
removeCh chan<- wgtypes.Key,
) (*HubPoller, error) {
u, err := url.Parse(hubURL)
if err != nil {
return nil, err
}
u.Path = "/peer/fetch-state/"
return &HubPoller{
selfVPNIP: selfVPNIP,
vpnNet: vpnNet,
hubURL: u.String(),
apiKey: apiKey,
statePath: statePath,
addCh: addCh,
removeCh: removeCh,
known: make(map[wgtypes.Key]struct{}),
}, nil
}
func (hp *HubPoller) Run() {
// Prime from the on-disk cache before reaching the hub, so the peer
// configures WireGuard from its last known state even if the hub is down.
// known starts empty, so this emits every cached peer as an add; the first
// real poll then emits only deltas (adds for new peers, removes for gone).
if state, err := loadNetworkState(hp.statePath); err == nil {
hp.apply(state)
}
hp.poll()
for range time.Tick(hubPollInterval) {
hp.poll()
}
}
func (hp *HubPoller) poll() {
req, err := http.NewRequest(http.MethodGet, hp.hubURL, nil)
if err != nil {
log.Printf("[HubPoller] build request: %v", err)
return
}
req.SetBasicAuth("", hp.apiKey)
client := &http.Client{Timeout: 32 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[HubPoller] fetch: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[HubPoller] unexpected status %d", resp.StatusCode)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("[HubPoller] read body: %v", err)
return
}
var state m.NetworkState
if err := json.Unmarshal(body, &state); err != nil {
log.Printf("[HubPoller] unmarshal: %v", err)
return
}
// Persist only when the state actually changed, to avoid needless writes
// on every poll.
if hp.apply(state) {
if err := saveNetworkState(hp.statePath, state); err != nil {
log.Printf("[HubPoller] save state: %v", err)
}
}
}
// apply diffs state against the set of known peers, emitting an add for each
// newly-seen peer and a remove for each that disappeared. It returns true if
// anything changed. A peer's config is immutable under a stable WG key (the hub
// has no peer-edit path), so a key already in known needs no re-emit.
func (hp *HubPoller) apply(state m.NetworkState) (changed bool) {
seen := make(map[wgtypes.Key]struct{}, len(hp.known))
netAddr := hp.vpnNet.Addr().As4()
for _, p := range state.Peers {
if p.WGPubKey == (wgtypes.Key{}) {
continue
}
octets := netAddr
octets[3] = p.PeerIP
vpnIP := netip.AddrFrom4(octets)
if vpnIP == hp.selfVPNIP {
continue
}
seen[p.WGPubKey] = struct{}{}
if _, ok := hp.known[p.WGPubKey]; ok {
continue
}
hp.known[p.WGPubKey] = struct{}{}
hp.addCh <- p
changed = true
}
for key := range hp.known {
if _, ok := seen[key]; !ok {
delete(hp.known, key)
hp.removeCh <- key
changed = true
}
}
return changed
}

80
peer/hub_poller_test.go Normal file
View File

@@ -0,0 +1,80 @@
package peer
import (
"net/netip"
"testing"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
func testPoller(t *testing.T) (*HubPoller, chan m.Peer, chan wgtypes.Key) {
t.Helper()
addCh := make(chan m.Peer, 8)
removeCh := make(chan wgtypes.Key, 8)
hp := &HubPoller{
selfVPNIP: netip.MustParseAddr("10.0.0.1"),
vpnNet: netip.MustParsePrefix("10.0.0.0/24"),
addCh: addCh,
removeCh: removeCh,
known: make(map[wgtypes.Key]struct{}),
}
return hp, addCh, removeCh
}
func stateWith(key wgtypes.Key, peerIP byte) m.NetworkState {
return m.NetworkState{Peers: []m.Peer{{
PeerIP: peerIP,
WGPubKey: key,
}}}
}
func TestApply_EmitsAddsAndReportsChange(t *testing.T) {
hp, addCh, _ := testPoller(t)
key := mustKey(t)
if changed := hp.apply(stateWith(key, 2)); !changed {
t.Fatal("expected changed=true on first apply")
}
if len(addCh) != 1 {
t.Fatalf("expected 1 add, got %d", len(addCh))
}
if got := <-addCh; got.WGPubKey != key {
t.Errorf("add pubkey mismatch")
}
}
func TestApply_NoChangeWhenKnown(t *testing.T) {
hp, addCh, _ := testPoller(t)
key := mustKey(t)
hp.apply(stateWith(key, 2))
<-addCh // drain initial add
if changed := hp.apply(stateWith(key, 2)); changed {
t.Fatal("expected changed=false when peer already known")
}
if len(addCh) != 0 {
t.Fatalf("expected no re-emit, got %d adds", len(addCh))
}
}
func TestApply_RemovesVanishedPeer(t *testing.T) {
hp, addCh, removeCh := testPoller(t)
key := mustKey(t)
hp.apply(stateWith(key, 2))
<-addCh
// Empty state: the peer is gone.
if changed := hp.apply(m.NetworkState{}); !changed {
t.Fatal("expected changed=true when peer vanishes")
}
if len(removeCh) != 1 {
t.Fatalf("expected 1 remove, got %d", len(removeCh))
}
if got := <-removeCh; got != key {
t.Errorf("remove key mismatch")
}
}

View File

@@ -1,148 +0,0 @@
package peer
import (
"encoding/json"
"io"
"log"
"net/http"
"net/netip"
"net/url"
"time"
"vppn/m"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type HubPoller struct {
Globals
holePunch *HolePunch
client *http.Client
req *http.Request
versions [256]int64
netName string
}
func NewHubPoller(
g Globals,
hp *HolePunch,
netName,
hubURL,
apiKey string,
) (*HubPoller, error) {
u, err := url.Parse(hubURL)
if err != nil {
return nil, err
}
u.Path = "/peer/fetch-state/"
client := &http.Client{Timeout: 8 * time.Second}
req := &http.Request{
Method: http.MethodGet,
URL: u,
Header: http.Header{},
}
req.SetBasicAuth("", apiKey)
return &HubPoller{
Globals: g,
holePunch: hp,
client: client,
req: req,
netName: netName,
}, nil
}
func (hp *HubPoller) logf(s string, args ...any) {
log.Printf("[HubPoller] "+s, args...)
}
func (hp *HubPoller) Run() {
state, err := loadNetworkState(hp.netName)
if err != nil {
hp.logf("Failed to load network state: %v", err)
hp.logf("Polling hub...")
hp.pollHub()
} else {
hp.applyNetworkState(state)
}
for range time.Tick(64 * time.Second) {
hp.pollHub()
}
}
func (hp *HubPoller) pollHub() {
var state m.NetworkState
resp, err := hp.client.Do(hp.req)
if err != nil {
hp.logf("Failed to fetch peer state: %v", err)
return
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
hp.logf("Failed to read body from hub: %v", err)
return
}
if err := json.Unmarshal(body, &state); err != nil {
hp.logf("Failed to unmarshal response from hub: %v\n%s", err, body)
return
}
if err := storeNetworkState(hp.netName, state); err != nil {
hp.logf("Failed to store network state: %v", err)
}
hp.applyNetworkState(state)
}
func (hp *HubPoller) applyNetworkState(state m.NetworkState) {
for i, peer := range state.Peers {
if i == int(hp.LocalPeerIP) {
continue
}
if peer != nil && peer.Version == hp.versions[i] {
continue
}
hp.applyPeerConfig(peer)
if peer != nil {
hp.versions[i] = peer.Version
}
}
}
func (hp *HubPoller) applyPeerConfig(peer *m.Peer) {
if peer == nil || len(peer.WGPubKey) != wgtypes.KeyLen {
return
}
if len(peer.PublicIP1) == 0 || peer.Port1 == 0 {
return
}
pubKey, err := wgtypes.NewKey(peer.WGPubKey)
if err != nil {
hp.logf("Invalid WG key for peer %d: %v", peer.PeerIP, err)
return
}
ip, ok := netip.AddrFromSlice(peer.PublicIP1)
if !ok {
hp.logf("Invalid public IP for peer %d", peer.PeerIP)
return
}
endpoint := netip.AddrPortFrom(ip.Unmap(), peer.Port1)
if peer.Relay {
if err := applyBaseConfig(hp.WGClient, hp.WGDevName, pubKey, endpoint, hp.Network); err != nil {
hp.logf("Failed to update relay config: %v", err)
}
return
}
if hp.holePunch != nil {
hp.holePunch.OnEndpointLearned(peer.PeerIP, pubKey, endpoint, true)
}
}

190
peer/init.go Normal file
View File

@@ -0,0 +1,190 @@
package peer
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"os"
"golang.org/x/crypto/nacl/sign"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
// 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
LocalDomain string
}
// localStateJSON is the on-disk representation.
type localStateJSON struct {
PrivKey string
SignKey string
VPNIP netip.Addr
VPNNet netip.Prefix
WGPort uint16
IsRelay bool
IsPublic bool
LocalDomain string
}
// LoadOrInit loads LocalState from path, or registers with the hub and creates
// the file if it doesn't exist.
func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
var state LocalState
switch err := loadJSON(statePath, &state); {
case err == nil:
return state, nil
case !os.IsNotExist(err):
// File exists but is unreadable/corrupt: surface it rather than
// silently regenerating a new identity and re-registering.
return LocalState{}, fmt.Errorf("load state: %w", err)
}
privKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return LocalState{}, fmt.Errorf("generate key: %w", err)
}
state, err = initFromHub(hubURL, apiKey, privKey)
if err != nil {
return LocalState{}, err
}
if err := storeJSON(statePath, state); err != nil {
return LocalState{}, fmt.Errorf("save state: %w", err)
}
return state, nil
}
func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error) {
wgPubKey := privKey.PublicKey()
signPubKey, signPrivKey, err := sign.GenerateKey(rand.Reader)
if err != nil {
return LocalState{}, fmt.Errorf("generate sign key: %w", err)
}
body, err := json.Marshal(m.PeerInitArgs{
WGPubKey: wgPubKey[:],
SignPubKey: signPubKey[:],
})
if err != nil {
return LocalState{}, fmt.Errorf("json error: %w", err)
}
req, err := http.NewRequest(http.MethodPost, hubURL+"/peer/init/", bytes.NewReader(body))
if err != nil {
return LocalState{}, err
}
req.SetBasicAuth("", apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return LocalState{}, fmt.Errorf("hub init: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return LocalState{}, fmt.Errorf("hub init: HTTP %d", resp.StatusCode)
}
var r m.PeerInitResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return LocalState{}, fmt.Errorf("hub init decode: %w", err)
}
if len(r.Network) != 4 {
return LocalState{}, fmt.Errorf("hub init: invalid network %v", r.Network)
}
netAddr := netip.AddrFrom4([4]byte(r.Network))
octets := netAddr.As4()
octets[3] = r.PeerIP
vpnIP := netip.AddrFrom4(octets)
vpnNet := netip.PrefixFrom(netAddr, 24)
var self *m.Peer
for i := range r.NetworkState.Peers {
if r.NetworkState.Peers[i].PeerIP == r.PeerIP {
self = &r.NetworkState.Peers[i]
break
}
}
if self == nil {
return LocalState{}, fmt.Errorf("hub init: no peer for own IP: %d", r.PeerIP)
}
public := self.IsPublic()
return LocalState{
PrivKey: privKey,
SignKey: *signPrivKey,
VPNIP: vpnIP,
VPNNet: vpnNet,
WGPort: self.Port,
IsRelay: self.Relay && public,
IsPublic: public,
LocalDomain: r.LocalDomain,
}, nil
}
func (s LocalState) MarshalJSON() ([]byte, error) {
return json.Marshal(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,
LocalDomain: s.LocalDomain,
})
}
func (s *LocalState) UnmarshalJSON(data []byte) error {
var j localStateJSON
if err := json.Unmarshal(data, &j); err != nil {
return err
}
keyBytes, err := base64.StdEncoding.DecodeString(j.PrivKey)
if err != nil {
return fmt.Errorf("decode key: %w", err)
}
key, err := wgtypes.NewKey(keyBytes)
if err != nil {
return fmt.Errorf("invalid key: %w", err)
}
signKeyBytes, err := base64.StdEncoding.DecodeString(j.SignKey)
if err != nil {
return fmt.Errorf("decode sign key: %w", err)
}
if len(signKeyBytes) != 64 {
return fmt.Errorf("invalid sign key length: %d", len(signKeyBytes))
}
*s = LocalState{
PrivKey: key,
SignKey: [64]byte(signKeyBytes),
VPNIP: j.VPNIP,
VPNNet: j.VPNNet,
WGPort: j.WGPort,
IsRelay: j.IsRelay,
IsPublic: j.IsPublic,
LocalDomain: j.LocalDomain,
}
return nil
}

28
peer/interfaces.go Normal file
View File

@@ -0,0 +1,28 @@
package peer
import (
"net/netip"
"vppn/peer/control"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// WGDevice is the subset of wginterface.Device used by App.
type WGDevice interface {
Name() string
Peers() ([]wgtypes.Peer, error)
AddPeer(pubKey wgtypes.Key) error
AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error
SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error
AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error
Promote(pubKey wgtypes.Key, vpnIP netip.Addr) error
RemovePeer(pubKey wgtypes.Key) error
}
// ControlConn sends pings to peers over the VPN control port.
// Reading is handled separately via run, which feeds the App's pingCh.
// buf is a caller-provided scratch buffer (at least control.Size bytes) used to
// marshal the ping; the caller reuses one across sends.
type ControlConn interface {
SendPing(dst netip.AddrPort, ping control.Ping, buf []byte) error
}

36
peer/json.go Normal file
View File

@@ -0,0 +1,36 @@
package peer
import (
"encoding/json"
"os"
"path/filepath"
)
func loadJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
func storeJSON(path string, obj any) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(obj, "", " ")
if err != nil {
return err
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}

View File

@@ -1,209 +0,0 @@
package peer
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"os"
"time"
)
// Usage:
//
// vppn netName run
// vppn netName status
func Main2() {
printUsage := func() {
fmt.Fprintf(os.Stderr, `%s COMMAND [ARGUMENTS...]
Available commands:
run
status
hosts
`, os.Args[0])
os.Exit(1)
}
if len(os.Args) < 2 {
printUsage()
}
command := os.Args[1]
switch command {
case "run":
main_run()
case "status":
main_status()
case "hosts":
main_hosts()
default:
printUsage()
}
}
// ----------------------------------------------------------------------------
type mainArgs struct {
NetName string
HubAddress string
APIKey string
}
func main_run() {
printUsage := func() {
fmt.Fprintf(os.Stderr, `Usage: %s run NETWORK_NAME HUB_ADDRESS API_KEY
NETWORK_NAME
Unique name of the network interface created. The network name
shouldn't change between invocations of the application.
HUB_ADDRESS
The address of the hub server. This should also contain the scheme, for
example https://hub.domain.com/.
API_KEY
The API key assigned to this peer by the hub.
`, os.Args[0])
os.Exit(1)
}
if len(os.Args) != 5 {
printUsage()
}
args := mainArgs{
NetName: os.Args[2],
HubAddress: os.Args[3],
APIKey: os.Args[4],
}
newPeerMain(args).Run()
}
// ----------------------------------------------------------------------------
func main_status() {
printUsage := func() {
fmt.Fprintf(os.Stderr, `Usage: %s status NETWORK_NAME
NETWORK_NAME
Unique name of the network interface created.
`, os.Args[0])
os.Exit(1)
}
if len(os.Args) != 3 {
printUsage()
}
netName := os.Args[2]
report := fetchStatusReport(netName)
fmt.Printf("\n%s Status\n\n", netName)
if len(report.Network) != 4 {
fmt.Println("ERROR: Network isn't 4 bytes.")
fmt.Printf("Network: %v\n\n", report.Network)
} else {
nw := report.Network
fmt.Printf("%-8s %d.%d.%d.%d\n", "IP", nw[0], nw[1], nw[2], report.LocalPeerIP)
fmt.Printf("%-8s %d.%d.%d.%d/24\n", "Network", nw[0], nw[1], nw[2], nw[3])
}
if report.RelayPeerIP != 0 {
fmt.Printf("%-8s %d\n\n", "Relay", report.RelayPeerIP)
} else {
fmt.Printf("%-8s -\n\n", "Relay")
}
for _, status := range report.Remotes {
fmt.Printf("%3d %s\n", status.PeerIP, status.Name)
fmt.Printf(" %-11s %v\n", "Up", status.Up)
pubIP, ok := netip.AddrFromSlice(status.PublicIP)
if ok {
fmt.Printf(" %-11s %v\n", "Public IP", pubIP)
} else {
fmt.Printf(" %-11s\n", "Public IP")
}
fmt.Printf(" %-11s %d\n", "Port", status.Port)
fmt.Printf(" %-11s %v\n", "Relay", status.Relay)
fmt.Printf(" %-11s %v\n", "Server", status.Server)
fmt.Printf(" %-11s %v\n", "Direct", status.Direct)
if status.DirectAddr.IsValid() {
fmt.Printf(" %-11s %v\n", "Address", status.DirectAddr)
}
fmt.Println("")
}
}
// ----------------------------------------------------------------------------
func main_hosts() {
printUsage := func() {
fmt.Fprintf(os.Stderr, `Usage: %s hosts NETWORK_NAME
NETWORK_NAME
Unique name of the network interface created.
`, os.Args[0])
os.Exit(1)
}
if len(os.Args) != 3 {
printUsage()
}
netName := os.Args[2]
state, err := loadNetworkState(netName)
if err != nil {
log.Fatalf("Failed to load network state: %v", err)
}
config, err := loadPeerConfig(netName)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
nw := config.Network
for _, peer := range state.Peers {
if peer == nil {
continue
}
fmt.Printf("%d.%d.%d.%d %s\n",
nw[0], nw[1], nw[2], peer.PeerIP, peer.Name)
}
fmt.Println("")
}
// ----------------------------------------------------------------------------
func fetchStatusReport(netName string) StatusReport {
client := http.Client{
Transport: &http.Transport{
Dial: func(_, _ string) (net.Conn, error) {
return net.Dial("unix", statusSocketPath(netName))
},
},
Timeout: 8 * time.Second,
}
getURL := "http://unix" + statusSocketPath(netName)
resp, err := client.Get(getURL)
if err != nil {
log.Fatalf("Failed to get response: %v", err)
}
report := StatusReport{}
if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
log.Fatalf("Failed to decode status report: %v", err)
}
return report
}

View File

@@ -1,66 +0,0 @@
package peer
import (
"encoding/binary"
"fmt"
"log"
"net"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func RunMCReader(g Globals, hp *HolePunch, netName string) {
for {
if err := runMCReaderInner(g, hp, netName); err != nil {
log.Printf("[MCReader] %v", err)
}
time.Sleep(broadcastErrorTimeoutInterval)
}
}
func runMCReaderInner(g Globals, hp *HolePunch, netName string) error {
conn, err := net.ListenMulticastUDP("udp", nil, multicastAddr)
if err != nil {
return fmt.Errorf("bind: %w", err)
}
defer conn.Close()
buf := make([]byte, 64)
for {
conn.SetReadDeadline(time.Now().Add(32 * time.Second))
n, src, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
return fmt.Errorf("read: %w", err)
}
if n != beaconLen {
continue
}
handleBeacon(g, hp, netName, buf[:n], src)
}
}
func handleBeacon(g Globals, hp *HolePunch, netName string, beacon []byte, src netip.AddrPort) {
peerIPByte := beacon[0]
if peerIPByte == g.LocalPeerIP {
return
}
pubKey, err := wgtypes.NewKey(beacon[1:33])
if err != nil {
return
}
// Skip relay peers: probing would replace their /24 AllowedIPs with empty.
if state, err := loadNetworkState(netName); err == nil {
if p := state.Peers[peerIPByte]; p != nil && p.Relay {
return
}
}
wgPort := binary.BigEndian.Uint16(beacon[33:35])
endpoint := netip.AddrPortFrom(src.Addr().Unmap(), wgPort)
hp.OnEndpointLearned(peerIPByte, pubKey, endpoint, false)
}

View File

@@ -1,43 +0,0 @@
package peer
import (
"encoding/binary"
"fmt"
"log"
"net"
"time"
)
const beaconLen = 35 // 1 VPN IP byte + 32 WG pubkey + 2 WG listen port
func RunMCWriter(g Globals) {
conn, err := net.ListenMulticastUDP("udp", nil, multicastAddr)
if err != nil {
log.Fatalf("[MCWriter] bind: %v", err)
}
for range time.Tick(broadcastInterval) {
beacon, err := buildBeacon(g)
if err != nil {
log.Printf("[MCWriter] build beacon: %v", err)
continue
}
log.Printf("[MCWriter] Broadcasting on %v...", multicastAddr)
if _, err := conn.WriteToUDP(beacon, multicastAddr); err != nil {
log.Printf("[MCWriter] write: %v", err)
}
}
}
func buildBeacon(g Globals) ([]byte, error) {
dev, err := g.WGClient.Device(g.WGDevName)
if err != nil {
return nil, fmt.Errorf("get WG device: %w", err)
}
beacon := make([]byte, beaconLen)
beacon[0] = g.LocalPeerIP
pubKey := g.WGPrivKey.PublicKey()
copy(beacon[1:33], pubKey[:])
binary.BigEndian.PutUint16(beacon[33:35], uint16(dev.ListenPort))
return beacon, nil
}

View File

@@ -0,0 +1,62 @@
package multicast
import (
"log"
"net"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
var addr = net.UDPAddrFromAddrPort(netip.AddrPortFrom(
netip.AddrFrom4([4]byte{224, 0, 0, 157}),
4560))
func Broadcast(
selfVPNIP netip.Addr,
pubKey wgtypes.Key,
wgPort uint16,
signKey *[64]byte,
) {
for {
broadcastInner(selfVPNIP, pubKey, wgPort, signKey)
time.Sleep(errorTimeout)
}
}
func broadcastInner(selfVPNIP netip.Addr, pubKey wgtypes.Key, wgPort uint16, signKey *[64]byte) {
conn, err := net.ListenMulticastUDP("udp", nil, addr)
if err != nil {
log.Printf("[MCBroadcast] bind: %v", err)
return
}
defer conn.Close()
buf := make([]byte, BufferSize)
packet := Packet{
PeerIP: selfVPNIP.As4()[3],
WGPubKey: pubKey,
WGPort: wgPort,
}
// Re-sign on each send so the timestamp is fresh; a stale timestamp would be
// dropped by receivers' freshness gate.
send := func() error {
packet.Timestamp = time.Now().Unix()
payload := packet.Marshal(buf, signKey)
_, err := conn.WriteToUDP(payload, addr)
return err
}
if err := send(); err != nil {
log.Printf("[MCBroadcast] write: %v", err)
}
for range time.Tick(broadcastInterval) {
if err := send(); err != nil {
log.Printf("[MCBroadcast] write: %v", err)
return
}
}
}

9
peer/multicast/global.go Normal file
View File

@@ -0,0 +1,9 @@
package multicast
import "time"
const (
errorTimeout = 16 * time.Second
broadcastInterval = 16 * time.Second
maxPacketAge = time.Minute
)

54
peer/multicast/packet.go Normal file
View File

@@ -0,0 +1,54 @@
package multicast
import (
"encoding/binary"
"net/netip"
"golang.org/x/crypto/nacl/sign"
)
const (
BufferSize = packetSize + SignedPacketSize
SignedPacketSize = packetSize + signSize
packetSize = 43
signSize = 64
)
// Layout:
//
// [0] final octet of the sender's VPN IP
// [1:33] WG public key
// [33:35] WG listen port (big-endian uint16)
// [35:43] send time, Unix seconds (big-endian int64) — freshness/replay gate
type Packet struct {
PeerIP byte // Final octet of the sender's VPN IP.
WGPubKey [32]byte // WG public key.
WGPort uint16 // WG listen port.
Timestamp int64 // Unix timestamp.
Src netip.Addr // Source of packet.
Signed []byte // Raw signed message for verification (incoming packet).
}
// Marshal the packet into a buffer with prefixed signature.
func (p Packet) Marshal(buf []byte, signKey *[64]byte) []byte {
buf[0] = p.PeerIP
copy(buf[1:33], p.WGPubKey[:])
binary.BigEndian.PutUint16(buf[33:35], p.WGPort)
binary.BigEndian.PutUint64(buf[35:43], uint64(p.Timestamp))
return sign.Sign(buf[packetSize:packetSize], buf[:packetSize], signKey)
}
func (p Packet) Verify(buf []byte, pubKey *[32]byte) bool {
_, ok := sign.Open(buf, p.Signed, pubKey)
return ok
}
func Unmarshal(signed []byte) (p Packet) {
buf := signed[signSize:]
p.PeerIP = buf[0]
copy(p.WGPubKey[:], buf[1:33])
p.WGPort = binary.BigEndian.Uint16(buf[33:35])
p.Timestamp = int64(binary.BigEndian.Uint64(buf[35:43]))
p.Signed = signed
return
}

View File

@@ -0,0 +1,38 @@
package multicast
import (
"crypto/rand"
"testing"
"golang.org/x/crypto/nacl/sign"
)
func TestPacket(t *testing.T) {
pub, priv, err := sign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
p := Packet{
PeerIP: 10,
WGPubKey: [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2},
WGPort: 44,
Timestamp: 12948893,
}
buf := make([]byte, BufferSize)
signed := p.Marshal(buf, priv)
if len(signed) != SignedPacketSize {
t.Fatalf("signed length = %d, want %d", len(signed), SignedPacketSize)
}
got := Unmarshal(signed)
if got.PeerIP != p.PeerIP || got.WGPubKey != p.WGPubKey ||
got.WGPort != p.WGPort || got.Timestamp != p.Timestamp {
t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p)
}
if !got.Verify(nil, pub) {
t.Error("signature did not verify")
}
}

View File

@@ -0,0 +1,61 @@
package multicast
import (
"bytes"
"fmt"
"log"
"net"
"net/netip"
"time"
)
func Receiver(vpnNet netip.Prefix, selfVPNIP netip.Addr, ch chan<- Packet) {
for {
if err := receiver(vpnNet, selfVPNIP, ch); err != nil {
log.Printf("[MCReader] %v", err)
}
time.Sleep(errorTimeout)
}
}
func receiver(vpnNet netip.Prefix, selfVPNIP netip.Addr, ch chan<- Packet) error {
selfIP := selfVPNIP.As4()[3]
conn, err := net.ListenMulticastUDP("udp", nil, addr)
if err != nil {
return fmt.Errorf("bind: %w", err)
}
defer conn.Close()
buf := make([]byte, BufferSize+1) // +1 to detect oversized packets
for {
conn.SetReadDeadline(time.Now().Add(32 * time.Second))
n, src, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return fmt.Errorf("read: %w", err)
}
if n != SignedPacketSize {
continue
}
packet := Unmarshal(buf[:n])
if packet.PeerIP == selfIP {
continue
}
age := time.Since(time.Unix(packet.Timestamp, 0))
if age > maxPacketAge || age < -maxPacketAge {
continue
}
packet.Signed = bytes.Clone(packet.Signed)
packet.Src = src.Addr().Unmap()
ch <- packet
}
}

18
peer/network_state.go Normal file
View File

@@ -0,0 +1,18 @@
package peer
import "vppn/m"
// loadNetworkState reads a cached network state from disk. Any error (most
// commonly a missing file on first run) is returned to the caller, which
// treats it as "no cache available".
func loadNetworkState(path string) (m.NetworkState, error) {
var state m.NetworkState
err := loadJSON(path, &state)
return state, err
}
// saveNetworkState writes state to path atomically (see storeJSON), so a crash
// mid-write cannot leave a corrupt cache.
func saveNetworkState(path string, state m.NetworkState) error {
return storeJSON(path, state)
}

View File

@@ -0,0 +1,56 @@
package peer
import (
"net/netip"
"path/filepath"
"reflect"
"testing"
"vppn/m"
)
func TestNetworkState_RoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "network.json")
var sign1 [32]byte
copy(sign1[:], []byte("0123456789abcdef0123456789abcdef"))
state := m.NetworkState{Peers: []m.Peer{
{
PeerIP: 1,
Name: "hub",
Addr4: netip.MustParseAddr("10.11.12.1"),
Port: 51820,
Relay: true,
WGPubKey: mustKey(t),
SignPubKey: sign1,
},
{
PeerIP: 10,
Name: "laptop",
Addr4: netip.MustParseAddr("10.11.12.10"),
Port: 51820,
WGPubKey: mustKey(t),
},
}}
if err := saveNetworkState(path, state); err != nil {
t.Fatal(err)
}
got, err := loadNetworkState(path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, state) {
t.Errorf("round-trip mismatch:\n got: %+v\nwant: %+v", got.Peers[1], state.Peers[1])
}
}
func TestNetworkState_LoadMissing(t *testing.T) {
path := filepath.Join(t.TempDir(), "does-not-exist.json")
if _, err := loadNetworkState(path); err == nil {
t.Fatal("expected error loading missing cache, got nil")
}
}

109
peer/new.go Normal file
View File

@@ -0,0 +1,109 @@
package peer
import (
"fmt"
"net/netip"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/multicast"
"vppn/peer/wginterface"
)
// New constructs an App, creates the WireGuard interface, and starts the
// background goroutines (hub poller, multicast, control conn reader).
// The caller should invoke Run() to start the event loop.
func New(
state LocalState,
hubURL, apiKey string,
ifaceName string,
localDomain string,
networkStatePath string,
) (*App, error) {
a4 := state.VPNIP.As4()
if err := wginterface.Create(ifaceName, a4[:], 24); err != nil {
return nil, fmt.Errorf("create WG interface: %w", err)
}
dev, err := wginterface.Open(ifaceName)
if err != nil {
_ = wginterface.Delete(ifaceName)
return nil, fmt.Errorf("open WG device: %w", err)
}
cc, err := newUDPControlConn(state.VPNIP, ControlPort)
if err != nil {
_ = dev.Close()
_ = wginterface.Delete(ifaceName)
return nil, fmt.Errorf("control conn: %w", err)
}
cleanup := func() {
_ = cc.Close()
_ = dev.Close()
_ = wginterface.Delete(ifaceName)
}
if err := dev.Configure(state.PrivKey, int(state.WGPort)); err != nil {
cleanup()
return nil, fmt.Errorf("configure WG device: %w", err)
}
if state.IsRelay {
if err := dev.EnableForwarding(); err != nil {
cleanup()
return nil, fmt.Errorf("enable forwarding: %w", err)
}
}
pingCh := make(chan PingEvent)
hubAddCh := make(chan m.Peer)
hubRemoveCh := make(chan wgtypes.Key)
multicastCh := make(chan multicast.Packet)
poller, err := NewHubPoller(
state.VPNIP,
state.VPNNet,
hubURL,
apiKey,
networkStatePath,
hubAddCh,
hubRemoveCh)
if err != nil {
cleanup()
return nil, fmt.Errorf("hub poller: %w", err)
}
go cc.run(pingCh)
go poller.Run()
if !state.IsPublic {
go multicast.Broadcast(state.VPNIP, state.PrivKey.PublicKey(), state.WGPort, &state.SignKey)
go multicast.Receiver(state.VPNNet, state.VPNIP, multicastCh)
}
return &App{
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,
peersByKey: make(map[wgtypes.Key]*Peer),
peersByIP: make(map[netip.Addr]*Peer),
scratch: make([]byte, scratchSize),
hubAddCh: hubAddCh,
hubRemoveCh: hubRemoveCh,
pingCh: pingCh,
multicastCh: multicastCh,
}, nil
}

114
peer/on_hub.go Normal file
View File

@@ -0,0 +1,114 @@
package peer
import (
"log"
"math"
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/control"
)
func (a *App) onAddPeer(p m.Peer) {
a.onRemovePeer(p.WGPubKey)
octets := a.vpnNet.Addr().As4()
octets[3] = p.PeerIP
vpnIP := netip.AddrFrom4(octets)
peer := &Peer{
wgPeer: wgtypes.Peer{PublicKey: p.WGPubKey},
VPNIP: vpnIP,
Name: p.Name,
IsRelay: p.Relay,
IsPublic: p.IsPublic(),
EndpointV4: p.Endpoint4(),
EndpointV6: p.Endpoint6(),
RTT: time.Duration(math.MaxInt64) * time.Nanosecond,
Role: roleFor(a.isPublic, a.vpnIP, p.IsPublic(), vpnIP),
SignPubKey: p.SignPubKey,
}
a.peersByKey[p.WGPubKey] = peer
a.peersByIP[peer.VPNIP] = peer
defer a.updateHosts()
if !peer.IsPublic {
if a.isPublic {
// Public nodes accept traffic from non-public peers as soon as they
// initiate a handshake. Set /32 AllowedIPs now; WireGuard learns the
// endpoint from the incoming handshake automatically.
a.devPromote(peer)
} else {
a.devAddPeer(peer)
}
return
}
a.devAddDirect(peer, peer.PreferredEndpoint())
}
func (a *App) onRemovePeer(key wgtypes.Key) {
peer, exists := a.peersByKey[key]
if !exists {
return
}
a.devRemove(peer)
delete(a.peersByKey, key)
delete(a.peersByIP, peer.VPNIP)
a.updateHosts()
if peer == a.relay {
a.relay = nil
a.switchActiveRelay()
}
}
// switchActiveRelay promotes the lowest-latency relay peer to active.
func (a *App) switchActiveRelay() {
if a.relay != nil {
// If we have a relay, it's public, so should go back to being a direct
// peer - this will convert it's /24 to a /32.
a.devAddDirect(a.relay, a.relay.PreferredEndpoint())
a.relay = nil
}
var best *Peer
for _, p := range a.peersByKey {
if !p.CanRelay() {
continue
}
if best == nil || p.RTT < best.RTT {
best = p
}
}
if best == nil {
log.Printf("no relay available")
return
}
a.devSetRelay(best, best.PreferredEndpoint())
a.relay = best
}
func preferredEndpoint(v4, v6 netip.AddrPort) netip.AddrPort {
// We always prefer v4 since all peers can connect to IPv4 addresses.
if v4.IsValid() {
return v4
}
return v6
}
func roleFor(selfIsPublic bool, selfIP netip.Addr, peerIsPublic bool, peerVPNIP netip.Addr) control.Role {
if !selfIsPublic && peerIsPublic {
return control.Client
}
if selfIsPublic && !peerIsPublic {
return control.Server
}
return control.RoleFor(selfIP, peerVPNIP)
}

299
peer/on_hub_test.go Normal file
View File

@@ -0,0 +1,299 @@
package peer
import (
"net/netip"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
func mustKey(t *testing.T) wgtypes.Key {
t.Helper()
k, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatalf("generate key: %v", err)
}
return k.PublicKey()
}
func TestOnAddPeer(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
peerVPNIP := netip.MustParseAddr("10.0.0.2")
testCases := []struct {
name string
setup func(a *App, key wgtypes.Key)
peer func(key wgtypes.Key) m.Peer
check func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key)
}{
{
name: "non-public peer registered in WG via AddPeer",
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
if p == nil {
t.Fatal("not in peersByKey")
}
if a.peersByIP[peerVPNIP] == nil {
t.Fatal("not in peersByIP")
}
if p.State != StateRelayed {
t.Fatalf("state = %v, want StateRelayed", p.State)
}
dev.AssertAddPeer(t, 0, key)
},
},
{
name: "public peer with endpoint registered via AddDirect",
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
if p == nil {
t.Fatal("not in peersByKey")
}
dev.AssertAddDirect(t, 0, p.PubKey(), ep1, p.VPNIP)
},
},
{
name: "re-add removes old WG entry before adding new one",
setup: func(a *App, key wgtypes.Key) {
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()})
},
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2, Addr4: ep2.Addr(), Port: ep2.Port()}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [RemovePeer, AddDirect]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, key)
dev.AssertAddDirect(t, 1, key, ep2, peerVPNIP)
if len(a.peersByKey) != 1 || len(a.peersByIP) != 1 {
t.Errorf("maps: peersByKey=%d peersByIP=%d, want 1 each", len(a.peersByKey), len(a.peersByIP))
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev, _ := newTestApp(t, "10.0.0.1", false, false)
key := mustKey(t)
if tc.setup != nil {
tc.setup(a, key)
dev.Calls = nil
}
a.onAddPeer(tc.peer(key))
tc.check(t, a, dev, key)
})
}
}
func TestOnRemovePeer(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
testCases := []struct {
name string
setup func(t *testing.T, a *App) wgtypes.Key // returns the key to remove
check func(t *testing.T, a *App, dev *fakeWGDevice)
}{
{
name: "unknown key is a no-op",
setup: func(t *testing.T, a *App) wgtypes.Key {
return mustKey(t)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
dev.AssertNoCalls(t)
if len(a.peersByKey) != 0 {
t.Errorf("peersByKey should be empty")
}
},
},
{
name: "StateRelayed peer removed from maps with RemovePeer",
setup: func(t *testing.T, a *App) wgtypes.Key {
key := mustKey(t)
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [RemovePeer]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
if len(a.peersByKey) != 0 || len(a.peersByIP) != 0 {
t.Errorf("maps should be empty after remove")
}
},
},
{
name: "StateDirect peer removed from maps with RemovePeer",
setup: func(t *testing.T, a *App) wgtypes.Key {
key := mustKey(t)
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [RemovePeer]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
if len(a.peersByKey) != 0 || len(a.peersByIP) != 0 {
t.Errorf("maps should be empty after remove")
}
},
},
{
name: "removing active relay with no backup clears relay field",
setup: func(t *testing.T, a *App) wgtypes.Key {
relay := addRelayPeer(t, a, "10.0.0.10", ep1)
a.relay = relay
return relay.PubKey()
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [RemovePeer]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
if a.relay != nil {
t.Errorf("relay should be nil after removing only relay")
}
},
},
{
name: "removing active relay elects backup via SetRelay",
setup: func(t *testing.T, a *App) wgtypes.Key {
relay1 := addRelayPeer(t, a, "10.0.0.10", ep1)
addRelayPeer(t, a, "10.0.0.11", ep2)
a.relay = relay1
return relay1.PubKey()
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [RemovePeer, SetRelay]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
dev.AssertSetRelay(t, 1, dev.Calls[1].PubKey, ep2, a.vpnNet)
if a.relay == nil {
t.Errorf("relay should be set to backup after failover")
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev, _ := newTestApp(t, "10.0.0.1", false, false)
key := tc.setup(t, a)
dev.Calls = nil
a.onRemovePeer(key)
tc.check(t, a, dev)
})
}
}
func TestSwitchActiveRelay(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
testCases := []struct {
name string
setup func(t *testing.T, a *App)
check func(t *testing.T, a *App, dev *fakeWGDevice)
}{
{
name: "no candidates leaves relay nil",
setup: func(t *testing.T, a *App) {},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
dev.AssertNoCalls(t)
if a.relay != nil {
t.Error("relay should be nil")
}
},
},
{
name: "single candidate elected via SetRelay",
setup: func(t *testing.T, a *App) {
addRelayPeer(t, a, "10.0.0.10", ep1)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
if a.relay == nil {
t.Error("relay should be set")
}
},
},
{
name: "measured RTT beats zero RTT",
setup: func(t *testing.T, a *App) {
r1 := addRelayPeer(t, a, "10.0.0.10", ep1)
r1.RTT = 10 * time.Millisecond
addRelayPeer(t, a, "10.0.0.11", ep2) // RTT stays MaxInt64 (unmeaured)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
},
},
{
name: "lower RTT wins",
setup: func(t *testing.T, a *App) {
r1 := addRelayPeer(t, a, "10.0.0.10", ep1)
r1.RTT = 5 * time.Millisecond
r2 := addRelayPeer(t, a, "10.0.0.11", ep2)
r2.RTT = 20 * time.Millisecond
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
},
},
{
name: "stale relay demoted to direct before backup elected",
setup: func(t *testing.T, a *App) {
old := addRelayPeer(t, a, "10.0.0.10", ep1)
old.wgPeer.LastHandshakeTime = time.Time{} // stale — triggers switch from onTick
a.relay = old
addRelayPeer(t, a, "10.0.0.11", ep2)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [AddDirect, SetRelay]", dev.Calls)
}
if dev.Calls[0].Method != "AddDirect" || dev.Calls[0].Endpoint != ep1 {
t.Errorf("call[0]: got %v, want AddDirect with ep1", dev.Calls[0])
}
dev.AssertSetRelay(t, 1, dev.Calls[1].PubKey, ep2, a.vpnNet)
if a.relay == nil || a.relay.EndpointV4 != ep2 {
t.Error("relay should be the backup peer")
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev, _ := newTestApp(t, "10.0.0.1", false, false)
tc.setup(t, a)
dev.Calls = nil
a.switchActiveRelay()
tc.check(t, a, dev)
})
}
}

51
peer/on_multicast.go Normal file
View File

@@ -0,0 +1,51 @@
package peer
import (
"net/netip"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/peer/multicast"
)
func (a *App) onMulticastDiscovery(pkt multicast.Packet) {
if a.isPublic {
return
}
// Locate the sender peer by its VPN IP (final octet carried in the beacon).
octets := a.vpnNet.Addr().As4()
octets[3] = pkt.PeerIP
vpnIP := netip.AddrFrom4(octets)
peer, ok := a.peersByIP[vpnIP]
if !ok || peer.IsPublic || peer.State == StateDirect {
return
}
// Authenticate the beacon against the peer's known sign key. scratch[:0]
// gives sign.Open an empty-but-capacity buffer to decode into.
if !pkt.Verify(a.scratch[:0], &peer.SignPubKey) {
return
}
// The beacon is authentic but must also advertise the WG key the hub gave
// us for this peer; otherwise it's inconsistent — drop it.
if wgtypes.Key(pkt.WGPubKey) != peer.PubKey() {
return
}
endpoint := netip.AddrPortFrom(pkt.Src, pkt.WGPort)
if !endpoint.IsValid() {
return
}
var v4, v6 netip.AddrPort
if pkt.Src.Is4() {
v4 = endpoint
} else {
v6 = endpoint
}
a.addProbe(peer, v4, v6)
}

58
peer/on_ping.go Normal file
View File

@@ -0,0 +1,58 @@
package peer
import (
"net/netip"
"time"
"vppn/peer/control"
)
func (a *App) onPing(e PingEvent) {
peer, ok := a.peersByIP[e.srcVPNIP]
if !ok {
// TODO: Log here.
return
}
now := time.Now()
// If we're the server, respond - this is always necessary as it's used to
// know if peers are up or down.
if peer.Role == control.Server {
a.sendPing(peer, e.ping.PingTS)
}
// Compute RTT from server echo.
if peer.Role == control.Client {
peer.RTT = now.Sub(time.Unix(0, e.ping.PingTS))
}
// If we're public, nothing more to do.
if a.isPublic {
return
}
// We can only learn our own endpoint from directly-connected peers — Dst
// is the sender's observation of our WG handshake source.
if peer.State == StateDirect {
if dst := e.ping.Dst; dst.IsValid() {
if dst.Addr().Is4() {
a.selfV4 = dst
} else {
a.selfV6 = dst
}
}
return
}
a.addProbe(peer, e.ping.SrcV4, e.ping.SrcV6)
}
func (a *App) addProbe(peer *Peer, v4, v6 netip.AddrPort) {
endpoint := preferredEndpoint(v4, v6)
if !endpoint.IsValid() || endpoint == peer.PreferredEndpoint() {
return
}
peer.UpdateEndpoints(v4, v6)
a.devAddProbe(peer, endpoint)
}

52
peer/on_tick.go Normal file
View File

@@ -0,0 +1,52 @@
package peer
import (
"log"
"time"
"vppn/peer/control"
"vppn/peer/wginterface"
)
func (a *App) onTick() {
wgPeers := a.devPeers()
now := time.Now().UnixNano()
for _, wgPeer := range wgPeers {
p, ok := a.peersByKey[wgPeer.PublicKey]
if !ok {
log.Printf("Wireguard peer not in index, removing: %v", wgPeer)
a.devRemove(&Peer{wgPeer: wgPeer})
continue
}
p.wgPeer = wgPeer
// Send pings to peers where we're the client.
if p.Role == control.Client {
a.sendPing(p, now)
}
switch p.State {
case StateProbing:
// Promote probing peers to direct once alive (direct path confirmed
// working).
if time.Since(p.LastHandshakeTime()) < 2*wginterface.ProbeKeepalive {
a.devPromote(p)
}
case StateDirect:
if p.IsPublic || a.isPublic || p.Up() {
break
}
// Stale non-public direct peer: demote to probing so WireGuard
// resumes handshake attempts on the direct path.
a.devAddProbe(p, p.WGEndpoint())
}
}
// Ensure we have a live relay (if we're not public).
if !a.isPublic && (a.relay == nil || !a.relay.Up()) {
a.switchActiveRelay()
}
}

View File

@@ -1,220 +0,0 @@
package peer
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/netip"
"net/url"
"os"
"vppn/m"
"git.crumpington.com/lib/go/flock"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type peerMain struct {
Globals
netName string
holePunch *HolePunch
controlServer *ControlServer
endpointReporter *EndpointReporter // non-nil on relay peers only
hubPoller *HubPoller
lockFile *os.File
}
func newPeerMain(args mainArgs) *peerMain {
logf := func(s string, args ...any) {
log.Printf("[Main] "+s, args...)
}
if err := os.MkdirAll(configDir(args.NetName), 0700); err != nil {
log.Fatalf("Failed to create config directory: %v", err)
}
lockFile, err := flock.TryLock(lockFilePath(args.NetName))
if err != nil {
log.Fatalf("Failed to open lock file: %v", err)
}
if lockFile == nil {
log.Fatalf("Failed to obtain file lock.")
}
config, err := loadPeerConfig(args.NetName)
if err != nil {
logf("Failed to load configuration: %v", err)
logf("Initializing...")
initPeerWithHub(args)
config, err = loadPeerConfig(args.NetName)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
}
state, err := loadNetworkState(args.NetName)
if err != nil {
log.Fatalf("Failed to load network state: %v", err)
}
wgPrivKey, err := wgtypes.ParseKey(config.WGPrivKey)
if err != nil {
log.Fatalf("Failed to parse WireGuard private key: %v", err)
}
localPeer := state.Peers[config.LocalPeerIP]
var listenPort int
if localPeer != nil {
listenPort = int(localPeer.Port1)
}
vpnIP := netip.AddrFrom4([4]byte{
config.Network[0],
config.Network[1],
config.Network[2],
config.LocalPeerIP,
})
wgClient, err := createWGDevice(args.NetName, wgPrivKey, listenPort, vpnIP, config.Network)
if err != nil {
log.Fatalf("Failed to create WireGuard device: %v", err)
}
for _, p := range state.Peers {
if p == nil || !p.Relay || p.PeerIP == config.LocalPeerIP {
continue
}
if len(p.WGPubKey) != wgtypes.KeyLen || len(p.PublicIP1) == 0 || p.Port1 == 0 {
continue
}
relayPubKey, err := wgtypes.NewKey(p.WGPubKey)
if err != nil {
logf("Invalid relay WG key: %v", err)
continue
}
relayIP, ok := netip.AddrFromSlice(p.PublicIP1)
if !ok {
continue
}
relayEndpoint := netip.AddrPortFrom(relayIP.Unmap(), p.Port1)
if err := applyBaseConfig(wgClient, args.NetName, relayPubKey, relayEndpoint, config.Network); err != nil {
logf("Failed to apply relay base config: %v", err)
}
break
}
g := NewGlobals(config, netip.AddrPort{})
g.WGPrivKey = wgPrivKey
g.WGClient = wgClient
g.WGDevName = args.NetName
holePunch := NewHolePunch(g)
controlServer, err := NewControlServer(g, holePunch, args.NetName)
if err != nil {
log.Fatalf("Failed to create control server: %v", err)
}
var endpointReporter *EndpointReporter
if localPeer != nil && localPeer.Relay {
if err := enableForwarding(args.NetName); err != nil {
log.Fatalf("Failed to enable IP forwarding: %v", err)
}
endpointReporter = NewEndpointReporter(g, controlServer, args.NetName)
}
hubPoller, err := NewHubPoller(g, holePunch, args.NetName, args.HubAddress, args.APIKey)
if err != nil {
log.Fatalf("Failed to create hub poller: %v", err)
}
go runStatusServer(g, statusSocketPath(args.NetName))
return &peerMain{
Globals: g,
netName: args.NetName,
holePunch: holePunch,
controlServer: controlServer,
endpointReporter: endpointReporter,
hubPoller: hubPoller,
lockFile: lockFile,
}
}
func (p *peerMain) Run() {
go p.controlServer.Run()
if p.endpointReporter != nil {
go p.endpointReporter.Run()
}
go RunMCWriter(p.Globals)
go RunMCReader(p.Globals, p.holePunch, p.netName)
go p.hubPoller.Run()
select {}
}
func initPeerWithHub(args mainArgs) {
privKey := generateWGKey()
pubKey := privKey.PublicKey()
initURL, err := url.Parse(args.HubAddress)
if err != nil {
log.Fatalf("Failed to parse hub URL: %v", err)
}
initURL.Path = "/peer/init/"
initArgs := m.PeerInitArgs{
WGPubKey: pubKey[:],
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(initArgs); err != nil {
log.Fatalf("Failed to encode init args: %v", err)
}
req, err := http.NewRequest(http.MethodPost, initURL.String(), buf)
if err != nil {
log.Fatalf("Failed to construct request: %v", err)
}
req.SetBasicAuth("", args.APIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("Failed to init with hub: %v", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read response body: %v", err)
}
if resp.StatusCode == http.StatusConflict {
log.Fatalf("WireGuard key already registered (HTTP 409). Delete and re-create the peer to re-register.")
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("Hub returned unexpected status %d: %s", resp.StatusCode, data)
}
initResp := m.PeerInitResp{}
if err := json.Unmarshal(data, &initResp); err != nil {
log.Fatalf("Failed to parse configuration: %v\n%s", err, data)
}
config := LocalConfig{
LocalPeerIP: initResp.PeerIP,
Network: initResp.Network,
WGPrivKey: privKey.String(),
}
if err := storeNetworkState(args.NetName, initResp.NetworkState); err != nil {
log.Fatalf("Failed to store network state: %v", err)
}
if err := storePeerConfig(args.NetName, config); err != nil {
log.Fatalf("Failed to store configuration: %v", err)
}
log.Print("Initialization successful.")
}

21
peer/ping.go Normal file
View File

@@ -0,0 +1,21 @@
package peer
import (
"log"
"net/netip"
"vppn/peer/control"
)
func (a *App) sendPing(p *Peer, ts int64) {
ping := control.Ping{
PingTS: ts,
SrcV4: a.selfV4,
SrcV6: a.selfV6,
Dst: p.WGEndpoint(),
}
dst := netip.AddrPortFrom(p.VPNIP, ControlPort)
if err := a.controlConn.SendPing(dst, ping, a.scratch); err != nil {
log.Printf("sendPing %v: %v", p.VPNIP, err)
}
}

75
peer/remote.go Normal file
View File

@@ -0,0 +1,75 @@
package peer
import (
"net/netip"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/peer/control"
"vppn/peer/wginterface"
)
type PeerState string
const (
StateRelayed = PeerState("RELAY")
StateProbing = PeerState("PROBE")
StateDirect = PeerState("DIRECT")
)
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.
EndpointV4 netip.AddrPort // Reported IPv4 endpoint.
EndpointV6 netip.AddrPort // Reported IPv6 endpoint.
RTT time.Duration // Round-trip time.
State PeerState // Current routing state; updated on each devXxx call.
Role control.Role // Client initiates pings; server responds.
SignPubKey [32]byte // nacl/sign public key for verifying multicast beacons.
}
// PubKey is the wireguard public key.
func (p *Peer) PubKey() wgtypes.Key {
return p.wgPeer.PublicKey
}
func (p *Peer) WGEndpoint() netip.AddrPort {
ep := p.wgPeer.Endpoint
if ep == nil {
return netip.AddrPort{}
}
addr, ok := netip.AddrFromSlice(ep.IP)
if !ok {
return netip.AddrPort{}
}
return netip.AddrPortFrom(addr.Unmap(), uint16(ep.Port))
}
func (p *Peer) LastHandshakeTime() time.Time {
return p.wgPeer.LastHandshakeTime
}
func (p *Peer) Up() bool {
return time.Since(p.wgPeer.LastHandshakeTime) < wginterface.SessionTimeout
}
func (p *Peer) CanRelay() bool {
return p.IsRelay && p.Up()
}
func (p *Peer) PreferredEndpoint() netip.AddrPort {
return preferredEndpoint(p.EndpointV4, p.EndpointV6)
}
func (p *Peer) UpdateEndpoints(v4, v6 netip.AddrPort) {
if v4.IsValid() {
p.EndpointV4 = v4
}
if v6.IsValid() {
p.EndpointV6 = v6
}
}

View File

@@ -1,50 +0,0 @@
package peer
import (
"encoding/json"
"log"
"net"
"net/http"
"net/netip"
"os"
)
type StatusReport struct {
LocalPeerIP byte
Network []byte
RelayPeerIP byte
Remotes []RemoteStatus
}
type RemoteStatus struct {
PeerIP byte
Up bool
Name string
PublicIP []byte
Port uint16
Relay bool
Server bool
Direct bool
DirectAddr netip.AddrPort
}
func runStatusServer(g Globals, socketPath string) {
_ = os.RemoveAll(socketPath)
handler := func(w http.ResponseWriter, r *http.Request) {
report := StatusReport{
LocalPeerIP: g.LocalPeerIP,
Network: g.Network,
}
json.NewEncoder(w).Encode(report)
}
server := http.Server{Handler: http.HandlerFunc(handler)}
unixListener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("Failed to bind to unix socket: %v", err)
}
if err := server.Serve(unixListener); err != nil {
log.Fatalf("Failed to serve on unix socket: %v", err)
}
}

View File

@@ -21,6 +21,7 @@ import (
// Create creates a WireGuard interface named name, assigns vpnIP/prefixLen to // Create creates a WireGuard interface named name, assigns vpnIP/prefixLen to
// it, and brings it up. // it, and brings it up.
func Create(name string, vpnIP net.IP, prefixLen int) error { func Create(name string, vpnIP net.IP, prefixLen int) error {
_ = Delete(name) // remove any stale interface left by a previous run
if err := nlNewLink(name); err != nil { if err := nlNewLink(name); err != nil {
return fmt.Errorf("failed to create wireguard link: %w", err) return fmt.Errorf("failed to create wireguard link: %w", err)
} }

View File

@@ -23,10 +23,9 @@ const (
SessionTimeout = 180 * time.Second SessionTimeout = 180 * time.Second
) )
var ( const ProbeKeepalive = 8 * time.Second
probeKeepalive = 5 * time.Second
zeroKeepalive = time.Duration(0) var zeroKeepalive = time.Duration(0)
)
// Device wraps a wgctrl client bound to a named WireGuard interface. // Device wraps a wgctrl client bound to a named WireGuard interface.
type Device struct { type Device struct {
@@ -48,6 +47,11 @@ func (d *Device) Close() error {
return d.client.Close() return d.client.Close()
} }
// Name returns the interface name.
func (d *Device) Name() string {
return d.name
}
// Configure sets the device's private key and UDP listen port. // Configure sets the device's private key and UDP listen port.
func (d *Device) Configure(privKey wgtypes.Key, listenPort int) error { func (d *Device) Configure(privKey wgtypes.Key, listenPort int) error {
return d.client.ConfigureDevice(d.name, wgtypes.Config{ return d.client.ConfigureDevice(d.name, wgtypes.Config{
@@ -79,6 +83,17 @@ func (d *Device) Peer(pubKey wgtypes.Key) (wgtypes.Peer, error) {
return wgtypes.Peer{}, fmt.Errorf("peer %v not found in %q", pubKey, d.name) return wgtypes.Peer{}, fmt.Errorf("peer %v not found in %q", pubKey, d.name)
} }
// AddPeer registers a peer with no AllowedIPs and no endpoint. WireGuard will
// accept handshakes from this peer but route no traffic to it yet.
func (d *Device) AddPeer(pubKey wgtypes.Key) error {
return d.client.ConfigureDevice(d.name, wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
ReplaceAllowedIPs: true,
}},
})
}
// SetRelay configures the relay peer with AllowedIPs covering the entire VPN // SetRelay configures the relay peer with AllowedIPs covering the entire VPN
// network prefix. This is the fallback route for all VPN traffic. // network prefix. This is the fallback route for all VPN traffic.
func (d *Device) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error { func (d *Device) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error {
@@ -100,13 +115,14 @@ func (d *Device) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network n
// AddProbe adds a peer with no AllowedIPs and a 5s keepalive. WireGuard will // AddProbe adds a peer with no AllowedIPs and a 5s keepalive. WireGuard will
// attempt handshakes without routing any traffic through this peer yet. // attempt handshakes without routing any traffic through this peer yet.
func (d *Device) AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error { func (d *Device) AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error {
keepalive := ProbeKeepalive
return d.client.ConfigureDevice(d.name, wgtypes.Config{ return d.client.ConfigureDevice(d.name, wgtypes.Config{
Peers: []wgtypes.PeerConfig{{ Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey, PublicKey: pubKey,
Endpoint: net.UDPAddrFromAddrPort(endpoint), Endpoint: net.UDPAddrFromAddrPort(endpoint),
AllowedIPs: []net.IPNet{}, AllowedIPs: []net.IPNet{},
ReplaceAllowedIPs: true, ReplaceAllowedIPs: true,
PersistentKeepaliveInterval: &probeKeepalive, PersistentKeepaliveInterval: &keepalive,
}}, }},
}) })
} }
@@ -157,9 +173,12 @@ func (d *Device) RemovePeer(pubKey wgtypes.Key) error {
}) })
} }
// EnableForwarding enables IPv4 forwarding on the interface, required for // EnableForwarding enables IPv4 forwarding globally and on the interface,
// relay peers that forward traffic between VPN peers. // required for relay peers that forward traffic between VPN peers.
func (d *Device) EnableForwarding() error { func (d *Device) EnableForwarding() error {
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0644); err != nil {
return err
}
path := fmt.Sprintf("/proc/sys/net/ipv4/conf/%s/forwarding", d.name) path := fmt.Sprintf("/proc/sys/net/ipv4/conf/%s/forwarding", d.name)
return os.WriteFile(path, []byte("1\n"), 0644) return os.WriteFile(path, []byte("1\n"), 0644)
} }