Bug fixes, removed old files.

This commit is contained in:
jdl
2026-06-07 20:01:51 +02:00
parent e9dffee2de
commit 747d73890f
12 changed files with 23 additions and 766 deletions

View File

@@ -22,6 +22,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// TODO: Acquire flock on lock file.
statePath := networkStatePath(*name) statePath := networkStatePath(*name)
state, err := peer.LoadOrInit(statePath, *hub, *apiKey) state, err := peer.LoadOrInit(statePath, *hub, *apiKey)

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

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

View File

@@ -8,10 +8,9 @@
{{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

@@ -1,98 +0,0 @@
//go:build ignore
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.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
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,41 +0,0 @@
//go:build ignore
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
}

View File

@@ -1,157 +0,0 @@
//go:build ignore
package peer
import (
"log"
"net/netip"
"sync"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
const (
probeWait = 15 * time.Second
backoffStart = 5 * time.Minute
backoffMax = time.Hour
)
type probeState struct {
pubKey wgtypes.Key
endpoint netip.AddrPort
probing bool
direct bool
backoff time.Duration
backoffAt time.Time
}
type HolePunch struct {
Globals
mu sync.Mutex
peers [256]*probeState
}
func NewHolePunch(g Globals) *HolePunch {
return &HolePunch{Globals: g}
}
// OnEndpointLearned is called when a peer's external WG endpoint becomes known,
// either from the VPN control channel (MsgMyEndpoint) or from the hub poller.
// fromHub=true resets any existing backoff so the hub-reported change is acted
// on immediately.
func (hp *HolePunch) OnEndpointLearned(peerIP byte, pubKey wgtypes.Key, endpoint netip.AddrPort, fromHub bool) {
hp.mu.Lock()
defer hp.mu.Unlock()
ps := hp.peers[peerIP]
if ps == nil {
ps = &probeState{pubKey: pubKey}
hp.peers[peerIP] = ps
}
if fromHub {
ps.backoff = 0
ps.backoffAt = time.Time{}
ps.direct = false
ps.pubKey = pubKey
}
ps.endpoint = endpoint
if ps.probing || ps.direct {
return
}
if ps.backoff > 0 && time.Now().Before(ps.backoffAt) {
return
}
ps.probing = true
go hp.runProbe(peerIP)
}
func (hp *HolePunch) runProbe(peerIP byte) {
hp.mu.Lock()
ps := hp.peers[peerIP]
if ps == nil {
hp.mu.Unlock()
return
}
pubKey := ps.pubKey
endpoint := ps.endpoint
hp.mu.Unlock()
vpnIP := netip.AddrFrom4([4]byte{
hp.Network[0], hp.Network[1], hp.Network[2], peerIP,
})
probeStart := time.Now()
if err := addProbeEntry(hp.WGClient, hp.WGDevName, pubKey, endpoint); err != nil {
log.Printf("[HolePunch] addProbeEntry peer %d: %v", peerIP, err)
hp.finishProbe(peerIP, false)
return
}
time.Sleep(probeWait)
_, handshakeTime, err := getPeerEndpoint(hp.WGClient, hp.WGDevName, pubKey)
if err == nil && handshakeTime.After(probeStart) {
if err := promoteToDirect(hp.WGClient, hp.WGDevName, pubKey, vpnIP); err != nil {
log.Printf("[HolePunch] promoteToDirect peer %d: %v", peerIP, err)
}
hp.finishProbe(peerIP, true)
return
}
// Probe failed — remove entry and schedule backoff retry.
if err := removePeerEntry(hp.WGClient, hp.WGDevName, pubKey); err != nil {
log.Printf("[HolePunch] removePeerEntry peer %d: %v", peerIP, err)
}
hp.mu.Lock()
ps = hp.peers[peerIP]
var delay time.Duration
if ps != nil {
if ps.backoff == 0 {
ps.backoff = backoffStart
} else {
ps.backoff = min(ps.backoff*2, backoffMax)
}
ps.backoffAt = time.Now().Add(ps.backoff)
ps.probing = false
delay = ps.backoff
}
hp.mu.Unlock()
if delay > 0 {
go func() {
time.Sleep(delay)
hp.retryProbe(peerIP)
}()
}
}
func (hp *HolePunch) finishProbe(peerIP byte, success bool) {
hp.mu.Lock()
defer hp.mu.Unlock()
ps := hp.peers[peerIP]
if ps == nil {
return
}
ps.probing = false
if success {
ps.direct = true
ps.backoff = 0
}
}
func (hp *HolePunch) retryProbe(peerIP byte) {
hp.mu.Lock()
ps := hp.peers[peerIP]
if ps == nil || ps.probing || ps.direct {
hp.mu.Unlock()
return
}
ps.probing = true
hp.mu.Unlock()
hp.runProbe(peerIP)
}

View File

@@ -1,146 +0,0 @@
//go:build ignore
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 {
client *http.Client
req *http.Request
versions [256]int64
netName string
}
func NewHubPoller(
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.Addr4) == 0 || peer.Port == 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.Addr4)
if !ok {
hp.logf("Invalid public IP for peer %d", peer.PeerIP)
return
}
endpoint := netip.AddrPortFrom(ip.Unmap(), peer.Port)
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)
}
}

View File

@@ -1,68 +0,0 @@
//go:build ignore
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,45 +0,0 @@
//go:build ignore
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

@@ -1,179 +0,0 @@
//go:build ignore
package peer
import (
"fmt"
"net"
"net/netip"
"os"
"time"
"github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func createWGDevice(name string, privKey wgtypes.Key, listenPort int, vpnIP netip.Addr, network []byte) (*wgctrl.Client, error) {
if len(network) != 4 {
return nil, fmt.Errorf("expected 4-byte network, got %d", len(network))
}
la := netlink.NewLinkAttrs()
la.Name = name
if err := netlink.LinkAdd(&netlink.GenericLink{LinkAttrs: la, LinkType: "wireguard"}); err != nil {
return nil, fmt.Errorf("add wireguard link: %w", err)
}
link, err := netlink.LinkByName(name)
if err != nil {
_ = destroyWGDevice(name)
return nil, fmt.Errorf("get wireguard link: %w", err)
}
a4 := vpnIP.As4()
if err := netlink.AddrAdd(link, &netlink.Addr{
IPNet: &net.IPNet{
IP: net.IP(a4[:]),
Mask: net.CIDRMask(24, 32),
},
}); err != nil {
_ = destroyWGDevice(name)
return nil, fmt.Errorf("add VPN address: %w", err)
}
if err := netlink.LinkSetUp(link); err != nil {
_ = destroyWGDevice(name)
return nil, fmt.Errorf("set link up: %w", err)
}
client, err := wgctrl.New()
if err != nil {
_ = destroyWGDevice(name)
return nil, fmt.Errorf("new wgctrl client: %w", err)
}
cfg := wgtypes.Config{
PrivateKey: &privKey,
ListenPort: &listenPort,
}
if err := client.ConfigureDevice(name, cfg); err != nil {
client.Close()
_ = destroyWGDevice(name)
return nil, fmt.Errorf("configure wireguard: %w", err)
}
return client, nil
}
func destroyWGDevice(name string) error {
link, err := netlink.LinkByName(name)
if err != nil {
return fmt.Errorf("get link %q: %w", name, err)
}
return netlink.LinkDel(link)
}
// applyBaseConfig adds the relay peer with /24 AllowedIPs, making it the
// fallback route for all VPN traffic.
func applyBaseConfig(client *wgctrl.Client, devName string, relayPubKey wgtypes.Key, relayEndpoint netip.AddrPort, network []byte) error {
if len(network) != 4 {
return fmt.Errorf("expected 4-byte network, got %d", len(network))
}
keepalive := 25 * time.Second
cfg := wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: relayPubKey,
Endpoint: net.UDPAddrFromAddrPort(relayEndpoint),
AllowedIPs: []net.IPNet{{
IP: net.IP{network[0], network[1], network[2], 0},
Mask: net.CIDRMask(24, 32),
}},
ReplaceAllowedIPs: true,
PersistentKeepaliveInterval: &keepalive,
}},
}
return client.ConfigureDevice(devName, cfg)
}
// addProbeEntry adds a peer with no AllowedIPs and a 5s keepalive so WireGuard
// attempts handshakes without routing any traffic through it yet.
func addProbeEntry(client *wgctrl.Client, devName string, pubKey wgtypes.Key, endpoint netip.AddrPort) error {
keepalive := 5 * time.Second
cfg := wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
Endpoint: net.UDPAddrFromAddrPort(endpoint),
AllowedIPs: []net.IPNet{},
ReplaceAllowedIPs: true,
PersistentKeepaliveInterval: &keepalive,
}},
}
return client.ConfigureDevice(devName, cfg)
}
// addDirectPeer adds a peer with a known endpoint and /32 AllowedIPs in one
// step, for use when the hub reports a peer with a stable public endpoint.
func addDirectPeer(client *wgctrl.Client, devName string, pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error {
a4 := vpnIP.As4()
cfg := wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
Endpoint: net.UDPAddrFromAddrPort(endpoint),
AllowedIPs: []net.IPNet{{
IP: net.IP(a4[:]),
Mask: net.CIDRMask(32, 32),
}},
ReplaceAllowedIPs: true,
}},
}
return client.ConfigureDevice(devName, cfg)
}
// promoteToDirect upgrades a probe entry to a /32 AllowedIPs entry, causing
// WireGuard to prefer the direct path over the relay's /24 route.
func promoteToDirect(client *wgctrl.Client, devName string, pubKey wgtypes.Key, vpnIP netip.Addr) error {
a4 := vpnIP.As4()
cfg := wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
AllowedIPs: []net.IPNet{{
IP: net.IP(a4[:]),
Mask: net.CIDRMask(32, 32),
}},
ReplaceAllowedIPs: true,
}},
}
return client.ConfigureDevice(devName, cfg)
}
func removePeerEntry(client *wgctrl.Client, devName string, pubKey wgtypes.Key) error {
cfg := wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
Remove: true,
}},
}
return client.ConfigureDevice(devName, cfg)
}
func enableForwarding(ifaceName string) error {
path := fmt.Sprintf("/proc/sys/net/ipv4/conf/%s/forwarding", ifaceName)
return os.WriteFile(path, []byte("1\n"), 0644)
}
func getPeerEndpoint(client *wgctrl.Client, devName string, pubKey wgtypes.Key) (netip.AddrPort, time.Time, error) {
dev, err := client.Device(devName)
if err != nil {
return netip.AddrPort{}, time.Time{}, fmt.Errorf("get device: %w", err)
}
for _, p := range dev.Peers {
if p.PublicKey == pubKey {
if p.Endpoint == nil {
return netip.AddrPort{}, p.LastHandshakeTime, nil
}
return p.Endpoint.AddrPort(), p.LastHandshakeTime, nil
}
}
return netip.AddrPort{}, time.Time{}, fmt.Errorf("peer %v not found in device %s", pubKey, devName)
}