diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 6d206d682..adcf18c04 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -96,7 +96,8 @@ type CapabilityVersion int // - 57: 2023-01-25: Client understands CapabilityBindToInterfaceByRoute // - 58: 2023-03-10: Client retries lite map updates before restarting map poll. // - 59: 2023-03-16: Client understands Peers[].SelfNodeV4MasqAddrForThisPeer -const CurrentCapabilityVersion CapabilityVersion = 59 +// - 60: 2023-04-06: Client understands IsWireGuardOnly +const CurrentCapabilityVersion CapabilityVersion = 60 type StableID string @@ -289,6 +290,12 @@ type Node struct { // peer or any of its subnets. Traffic originating from subnet routes will // not be masqueraded (e.g. in case of --snat-subnet-routes). SelfNodeV4MasqAddrForThisPeer netip.Addr `json:",omitempty"` + + // IsWireGuardOnly indicates that this is a non-Tailscale WireGuard peer, it + // is not expected to speak Disco or DERP, and it must have Endpoints in + // order to be reachable. TODO(#7826): 2023-04-06: only the first parseable + // Endpoint is used, see #7826 for updates. + IsWireGuardOnly bool `json:",omitempty"` } // DisplayName returns the user-facing name for a node which should @@ -1715,7 +1722,8 @@ func (n *Node) Equal(n2 *Node) bool { n.ComputedNameWithHost == n2.ComputedNameWithHost && eqStrings(n.Tags, n2.Tags) && n.Expired == n2.Expired && - n.SelfNodeV4MasqAddrForThisPeer == n2.SelfNodeV4MasqAddrForThisPeer + n.SelfNodeV4MasqAddrForThisPeer == n2.SelfNodeV4MasqAddrForThisPeer && + n.IsWireGuardOnly == n2.IsWireGuardOnly } func eqBoolPtr(a, b *bool) bool { diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 746cf95b7..ef8dac56e 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -99,6 +99,7 @@ var _NodeCloneNeedsRegeneration = Node(struct { DataPlaneAuditLogID string Expired bool SelfNodeV4MasqAddrForThisPeer netip.Addr + IsWireGuardOnly bool }{}) // Clone makes a deep copy of Hostinfo. diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index d5f5402aa..4beca887f 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -350,6 +350,7 @@ func TestNodeEqual(t *testing.T) { "UnsignedPeerAPIOnly", "ComputedName", "computedHostIfDifferent", "ComputedNameWithHost", "DataPlaneAuditLogID", "Expired", "SelfNodeV4MasqAddrForThisPeer", + "IsWireGuardOnly", } if have := fieldsOf(reflect.TypeOf(Node{})); !reflect.DeepEqual(have, nodeHandles) { t.Errorf("Node.Equal check might be out of sync\nfields: %q\nhandled: %q\n", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 2d038108a..5df1ea6a6 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -179,6 +179,7 @@ func (v NodeView) Expired() bool { return v.ж.Expired } func (v NodeView) SelfNodeV4MasqAddrForThisPeer() netip.Addr { return v.ж.SelfNodeV4MasqAddrForThisPeer } +func (v NodeView) IsWireGuardOnly() bool { return v.ж.IsWireGuardOnly } func (v NodeView) Equal(v2 NodeView) bool { return v.ж.Equal(v2.ж) } // A compilation failure here means this code must be regenerated, with the command at the top of this file. @@ -214,6 +215,7 @@ var _NodeViewNeedsRegeneration = Node(struct { DataPlaneAuditLogID string Expired bool SelfNodeV4MasqAddrForThisPeer netip.Addr + IsWireGuardOnly bool }{}) // View returns a readonly view of Hostinfo. diff --git a/util/deephash/deephash_test.go b/util/deephash/deephash_test.go index 6ad65e314..e712767f2 100644 --- a/util/deephash/deephash_test.go +++ b/util/deephash/deephash_test.go @@ -575,8 +575,8 @@ func TestGetTypeHasher(t *testing.T) { { name: "tailcfg.Node", val: &tailcfg.Node{}, - out: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", - out32: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + out: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + out32: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", }, } for _, tt := range tests { diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index 8f976a7da..45a8d17d6 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -208,6 +208,12 @@ func (m *peerMap) upsertEndpoint(ep *endpoint, oldDiscoKey key.DiscoPublic) { delete(m.nodesOfDisco[oldDiscoKey], ep.publicKey) } if epDisco == nil { + // If the peer does not support Disco, but it does have an endpoint address, + // attempt to use that (e.g. WireGuardOnly peers). + if ep.bestAddr.AddrPort.IsValid() { + m.setNodeKeyForIPPort(ep.bestAddr.AddrPort, ep.publicKey) + } + return } set := m.nodesOfDisco[epDisco.key] @@ -2732,8 +2738,14 @@ func (c *Conn) SetNetworkMap(nm *netmap.NetworkMap) { // handle full set updates. for _, n := range nm.Peers { if ep, ok := c.peerMap.endpointForNodeKey(n.Key); ok { - if n.DiscoKey.IsZero() { - // Discokey transitioned from non-zero to zero? Ignore. Server's confused. + if n.DiscoKey.IsZero() && !n.IsWireGuardOnly { + // Discokey transitioned from non-zero to zero? This should not + // happen in the wild, however it could mean: + // 1. A node was downgraded from post 0.100 to pre 0.100. + // 2. A Tailscale node key was extracted and used on a + // non-Tailscale node (should not enter here due to the + // IsWireGuardOnly check) + // 3. The server is misbehaving. c.peerMap.deleteEndpoint(ep) continue } @@ -2745,9 +2757,8 @@ func (c *Conn) SetNetworkMap(nm *netmap.NetworkMap) { c.peerMap.upsertEndpoint(ep, oldDiscoKey) // maybe update discokey mappings in peerMap continue } - if n.DiscoKey.IsZero() { - // Ancient pre-0.100 node. Ignore, so we can assume elsewhere in magicsock - // that all nodes have a DiscoKey. + if n.DiscoKey.IsZero() && !n.IsWireGuardOnly { + // Ancient pre-0.100 node, which does not have a disco key, and will only be reachable via DERP. continue } @@ -2763,35 +2774,40 @@ func (c *Conn) SetNetworkMap(nm *netmap.NetworkMap) { if len(n.Addresses) > 0 { ep.nodeAddr = n.Addresses[0].Addr() } - ep.disco.Store(&endpointDisco{ - key: n.DiscoKey, - short: n.DiscoKey.ShortString(), - }) ep.initFakeUDPAddr() - if debugDisco() { // rather than making a new knob - c.logf("magicsock: created endpoint key=%s: disco=%s; %v", n.Key.ShortString(), n.DiscoKey.ShortString(), logger.ArgWriter(func(w *bufio.Writer) { - const derpPrefix = "127.3.3.40:" - if strings.HasPrefix(n.DERP, derpPrefix) { - ipp, _ := netip.ParseAddrPort(n.DERP) - regionID := int(ipp.Port()) - code := c.derpRegionCodeLocked(regionID) - if code != "" { - code = "(" + code + ")" + if n.DiscoKey.IsZero() { + ep.disco.Store(nil) + } else { + ep.disco.Store(&endpointDisco{ + key: n.DiscoKey, + short: n.DiscoKey.ShortString(), + }) + + if debugDisco() { // rather than making a new knob + c.logf("magicsock: created endpoint key=%s: disco=%s; %v", n.Key.ShortString(), n.DiscoKey.ShortString(), logger.ArgWriter(func(w *bufio.Writer) { + const derpPrefix = "127.3.3.40:" + if strings.HasPrefix(n.DERP, derpPrefix) { + ipp, _ := netip.ParseAddrPort(n.DERP) + regionID := int(ipp.Port()) + code := c.derpRegionCodeLocked(regionID) + if code != "" { + code = "(" + code + ")" + } + fmt.Fprintf(w, "derp=%v%s ", regionID, code) } - fmt.Fprintf(w, "derp=%v%s ", regionID, code) - } - for _, a := range n.AllowedIPs { - if a.IsSingleIP() { - fmt.Fprintf(w, "aip=%v ", a.Addr()) - } else { - fmt.Fprintf(w, "aip=%v ", a) + for _, a := range n.AllowedIPs { + if a.IsSingleIP() { + fmt.Fprintf(w, "aip=%v ", a.Addr()) + } else { + fmt.Fprintf(w, "aip=%v ", a) + } } - } - for _, ep := range n.Endpoints { - fmt.Fprintf(w, "ep=%v ", ep) - } - })) + for _, ep := range n.Endpoints { + fmt.Fprintf(w, "ep=%v ", ep) + } + })) + } } ep.updateFromNode(n, heartbeatDisabled) c.peerMap.upsertEndpoint(ep, key.DiscoPublic{}) @@ -4627,6 +4643,22 @@ func (de *endpoint) updateFromNode(n *tailcfg.Node, heartbeatDisabled bool) { de.heartbeatDisabled = heartbeatDisabled de.expired = n.Expired + // TODO(#7826): add support for more than one endpoint for pure WireGuard + // peers, and/or support for probing "bestness" for endpoints. + if n.IsWireGuardOnly { + for _, ep := range n.Endpoints { + ipp, err := netip.ParseAddrPort(ep) + if err != nil { + de.c.logf("magicsock: invalid endpoint: %s %s", ep, err) + continue + } + de.bestAddr = addrLatency{ + AddrPort: ipp, + } + break + } + } + epDisco := de.disco.Load() var discoKey key.DiscoPublic if epDisco != nil { diff --git a/wgengine/magicsock/magicsock_test.go b/wgengine/magicsock/magicsock_test.go index 3a553ddb7..20181528b 100644 --- a/wgengine/magicsock/magicsock_test.go +++ b/wgengine/magicsock/magicsock_test.go @@ -39,6 +39,7 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/connstats" "tailscale.com/net/netaddr" + "tailscale.com/net/packet" "tailscale.com/net/stun/stuntest" "tailscale.com/net/tstun" "tailscale.com/tailcfg" @@ -200,6 +201,7 @@ func newMagicStackWithKey(t testing.TB, logf logger.Logf, l nettype.PacketListen } func (s *magicStack) Reconfig(cfg *wgcfg.Config) error { + s.tsTun.SetWGConfig(cfg) s.wgLogger.SetPeers(cfg.Peers) return wgcfg.ReconfigDevice(s.dev, cfg, s.conn.logf) } @@ -2102,3 +2104,175 @@ func Test_batchingUDPConn_coalesceMessages(t *testing.T) { }) } } + +// newWireguard starts up a new wireguard-go device attached to a test tun, and +// returns the device, tun and netpoint address. To add peers call device.IpcSet +// with UAPI instructions. +func newWireguard(t *testing.T, uapi string, aips []netip.Prefix) (*device.Device, *tuntest.ChannelTUN, netip.AddrPort) { + wgtun := tuntest.NewChannelTUN() + wglogf := func(f string, args ...any) { + t.Logf("wg-go: "+f, args...) + } + wglog := device.Logger{ + Verbosef: func(string, ...any) {}, + Errorf: wglogf, + } + wgdev := wgcfg.NewDevice(wgtun.TUN(), wgconn.NewDefaultBind(), &wglog) + + if err := wgdev.IpcSet(uapi); err != nil { + t.Fatal(err) + } + + if err := wgdev.Up(); err != nil { + t.Fatal(err) + } + + var wgEp netip.AddrPort + + s, err := wgdev.IpcGet() + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + k, v, _ := strings.Cut(line, "=") + if k == "listen_port" { + wgEp = netip.MustParseAddrPort("127.0.0.1:" + v) + break + } + } + + if !wgEp.IsValid() { + t.Fatalf("failed to get endpoint out of wg-go") + } + t.Logf("wg-go endpoint: %s", wgEp) + + return wgdev, wgtun, wgEp +} + +func TestIsWireGuardOnlyPeer(t *testing.T) { + derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) + defer cleanup() + + tskey := key.NewNode() + tsaip := netip.MustParsePrefix("100.111.222.111/32") + + wgkey := key.NewNode() + wgaip := netip.MustParsePrefix("100.222.111.222/32") + + uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nallowed_ip=%s\n\n", + wgkey.UntypedHexString(), tskey.Public().UntypedHexString(), tsaip.String()) + wgdev, wgtun, wgEp := newWireguard(t, uapi, []netip.Prefix{wgaip}) + defer wgdev.Close() + + m := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, tskey) + defer m.Close() + + nm := &netmap.NetworkMap{ + Name: "ts", + PrivateKey: m.privateKey, + NodeKey: m.privateKey.Public(), + Addresses: []netip.Prefix{tsaip}, + Peers: []*tailcfg.Node{ + { + Key: wgkey.Public(), + Endpoints: []string{wgEp.String()}, + IsWireGuardOnly: true, + Addresses: []netip.Prefix{wgaip}, + AllowedIPs: []netip.Prefix{wgaip}, + }, + }, + } + m.conn.SetNetworkMap(nm) + + cfg, err := nmcfg.WGCfg(nm, t.Logf, netmap.AllowSingleHosts|netmap.AllowSubnetRoutes, "") + if err != nil { + t.Fatal(err) + } + m.Reconfig(cfg) + + pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr()) + m.tun.Outbound <- pbuf + + select { + case p := <-wgtun.Inbound: + if !bytes.Equal(p, pbuf) { + t.Errorf("got unexpected packet: %x", p) + } + case <-time.After(time.Second): + t.Fatal("no packet after 1s") + } +} + +func TestIsWireGuardOnlyPeerWithMasquerade(t *testing.T) { + derpMap, cleanup := runDERPAndStun(t, t.Logf, localhostListener{}, netaddr.IPv4(127, 0, 0, 1)) + defer cleanup() + + tskey := key.NewNode() + tsaip := netip.MustParsePrefix("100.111.222.111/32") + + wgkey := key.NewNode() + wgaip := netip.MustParsePrefix("10.64.0.1/32") + + // the ip that the wireguard peer has in allowed ips and expects as a masq source + masqip := netip.MustParsePrefix("10.64.0.2/32") + + uapi := fmt.Sprintf("private_key=%s\npublic_key=%s\nallowed_ip=%s\n\n", + wgkey.UntypedHexString(), tskey.Public().UntypedHexString(), masqip.String()) + wgdev, wgtun, wgEp := newWireguard(t, uapi, []netip.Prefix{wgaip}) + defer wgdev.Close() + + m := newMagicStackWithKey(t, t.Logf, localhostListener{}, derpMap, tskey) + defer m.Close() + + nm := &netmap.NetworkMap{ + Name: "ts", + PrivateKey: m.privateKey, + NodeKey: m.privateKey.Public(), + Addresses: []netip.Prefix{tsaip}, + Peers: []*tailcfg.Node{ + { + Key: wgkey.Public(), + Endpoints: []string{wgEp.String()}, + IsWireGuardOnly: true, + Addresses: []netip.Prefix{wgaip}, + AllowedIPs: []netip.Prefix{wgaip}, + SelfNodeV4MasqAddrForThisPeer: masqip.Addr(), + }, + }, + } + m.conn.SetNetworkMap(nm) + + cfg, err := nmcfg.WGCfg(nm, t.Logf, netmap.AllowSingleHosts|netmap.AllowSubnetRoutes, "") + if err != nil { + t.Fatal(err) + } + m.Reconfig(cfg) + + pbuf := tuntest.Ping(wgaip.Addr(), tsaip.Addr()) + m.tun.Outbound <- pbuf + + select { + case p := <-wgtun.Inbound: + + // TODO(raggi): move to a bytes.Equal based test later, once + // tuntest.Ping produces correct checksums! + + var pkt packet.Parsed + pkt.Decode(p) + if pkt.ICMP4Header().Type != packet.ICMP4EchoRequest { + t.Fatalf("unexpected packet: %x", p) + } + if pkt.Src.Addr() != masqip.Addr() { + t.Fatalf("bad source IP, got %s, want %s", pkt.Src.Addr(), masqip.Addr()) + } + if pkt.Dst.Addr() != wgaip.Addr() { + t.Fatalf("bad source IP, got %s, want %s", pkt.Src.Addr(), masqip.Addr()) + } + case <-time.After(time.Second): + t.Fatal("no packet after 1s") + } +} diff --git a/wgengine/wgcfg/nmcfg/nmcfg.go b/wgengine/wgcfg/nmcfg/nmcfg.go index d07f7232b..f01b42cb1 100644 --- a/wgengine/wgcfg/nmcfg/nmcfg.go +++ b/wgengine/wgcfg/nmcfg/nmcfg.go @@ -85,7 +85,7 @@ func WGCfg(nm *netmap.NetworkMap, logf logger.Logf, flags netmap.WGConfigFlags, skippedSubnets := new(bytes.Buffer) for _, peer := range nm.Peers { - if peer.DiscoKey.IsZero() && peer.DERP == "" { + if peer.DiscoKey.IsZero() && peer.DERP == "" && !peer.IsWireGuardOnly { // Peer predates both DERP and active discovery, we cannot // communicate with it. logf("[v1] wgcfg: skipped peer %s, doesn't offer DERP or disco", peer.Key.ShortString())