disco,types,wgengine: implement Knock,KnockReply disco messages

EXTREME WIP, DO NOT SUBMIT

Updates #1227
tom/disco
Tom DNetto 2023-06-21 14:57:15 -07:00
parent c783f28228
commit f205d23233
5 changed files with 296 additions and 0 deletions

View File

@ -27,6 +27,7 @@ import (
"net/netip"
"go4.org/mem"
"golang.org/x/crypto/nacl/box"
"tailscale.com/types/key"
)
@ -44,6 +45,8 @@ const (
TypePing = MessageType(0x01)
TypePong = MessageType(0x02)
TypeCallMeMaybe = MessageType(0x03)
TypeKnock = MessageType(0x04)
TypeKnockReply = MessageType(0x05)
)
const v0 = byte(0)
@ -83,6 +86,10 @@ func Parse(p []byte) (Message, error) {
return parsePong(ver, p)
case TypeCallMeMaybe:
return parseCallMeMaybe(ver, p)
case TypeKnock:
return parseKnock(ver, p)
case TypeKnockReply:
return parseKnockReply(ver, p)
default:
return nil, fmt.Errorf("unknown message type 0x%02x", byte(t))
}
@ -240,6 +247,54 @@ func parsePong(ver uint8, p []byte) (m *Pong, err error) {
return m, nil
}
type Knock struct {
// SealedNonce is the random client-generated per-knock nonce,
// which is NaCL-box sealed to the node key of the destination.
// The unencrypted nonce is 8 bytes.
SealedNonce [box.AnonymousOverhead + 8]byte
}
func (m *Knock) AppendMarshal(b []byte) []byte {
dataLen := box.AnonymousOverhead + 8
ret, d := appendMsgHeader(b, TypeKnock, v0, dataLen)
copy(d, m.SealedNonce[:])
return ret
}
func parseKnock(ver uint8, p []byte) (m *Knock, err error) {
if len(p) < (box.AnonymousOverhead + 8) {
return nil, errShort
}
m = new(Knock)
p = p[copy(m.SealedNonce[:], p):]
// Deliberately lax on longer-than-expected messages, for future
// compatibility.
return m, nil
}
type KnockReply struct {
// Nonce is the nonce value from the Knock request.
Nonce [8]byte
}
func (m *KnockReply) AppendMarshal(b []byte) []byte {
dataLen := 8
ret, d := appendMsgHeader(b, TypeKnockReply, v0, dataLen)
copy(d, m.Nonce[:])
return ret
}
func parseKnockReply(ver uint8, p []byte) (m *KnockReply, err error) {
if len(p) < 8 {
return nil, errShort
}
m = new(KnockReply)
p = p[copy(m.Nonce[:], p):]
// Deliberately lax on longer-than-expected messages, for future
// compatibility.
return m, nil
}
// MessageSummary returns a short summary of m for logging purposes.
func MessageSummary(m Message) string {
switch m := m.(type) {
@ -249,6 +304,10 @@ func MessageSummary(m Message) string {
return fmt.Sprintf("pong tx=%x", m.TxID[:6])
case *CallMeMaybe:
return "call-me-maybe"
case *Knock:
return fmt.Sprintf("knock")
case *KnockReply:
return fmt.Sprintf("knock reply nonce=%x", m.Nonce[:])
default:
return fmt.Sprintf("%#v", m)
}

View File

@ -4,6 +4,7 @@
package disco
import (
"bytes"
"fmt"
"net/netip"
"reflect"
@ -66,6 +67,20 @@ func TestMarshalAndParse(t *testing.T) {
},
want: "03 00 00 00 00 00 00 00 00 00 00 00 ff ff 01 02 03 04 02 37 20 01 00 00 00 00 00 00 00 00 00 00 00 00 34 56 03 15",
},
{
name: "knock",
m: &Knock{
SealedNonce: [16 + 32 + 8]byte(bytes.Repeat([]byte{1, 2}, 28)),
},
want: "04 00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02 01 02",
},
{
name: "knock_reply",
m: &KnockReply{
Nonce: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
},
want: "05 00 01 02 03 04 05 06 07 08",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -142,6 +142,26 @@ func (k NodePrivate) OpenFrom(p NodePublic, ciphertext []byte) (cleartext []byte
return box.Open(nil, ciphertext[len(nonce):], nonce, &p.k, &k.k)
}
// SealAnonymous seals the cleartext to the node key k.
func (k NodePublic) SealAnonymous(cleartext []byte) (ciphertext []byte, err error) {
if k.IsZero() {
panic("can't seal with zero keys")
}
return box.SealAnonymous(nil, cleartext, &k.k, nil)
}
// OpenAnonymous opens the anonymous NaCl box ciphertext, which must be a value
// created by SealAnonymous, and returns the inner cleartext if ciphertext is
// a valid box to k.
func (k NodePrivate) OpenAnonymous(ciphertext []byte) (cleartext []byte, ok bool) {
if k.IsZero() {
panic("can't open with zero keys")
}
p := k.Public()
return box.OpenAnonymous(nil, ciphertext, &p.k, &k.k)
}
func (k NodePrivate) UntypedHexString() string {
return hex.EncodeToString(k.k[:])
}

View File

@ -2306,6 +2306,25 @@ func (c *Conn) handleDiscoMessage(msg []byte, src netip.AddrPort, derpNodeSrc ke
ep.publicKey.ShortString(), derpStr(src.String()),
len(dm.MyNumber))
go ep.handleCallMeMaybe(dm)
case *disco.Knock:
metricRecvDiscoKnock.Add(1)
if isDERP {
metricRecvDiscoKnockBadDisco.Add(1)
c.logf("[unexpected] Knock packets should only come via LAN")
return
}
c.handleKnockLocked(dm, src, di)
case *disco.KnockReply:
metricRecvDiscoKnockReply.Add(1)
if isDERP {
metricRecvDiscoKnockReplyBadDisco.Add(1)
c.logf("[unexpected] Knock reply packets should only come via LAN")
return
}
c.logf("magicsock: disco: got knock reply %v from %v", dm, src)
c.peerMap.forEachEndpointWithDiscoKey(sender, func(ep *endpoint) (keepGoing bool) {
return !ep.handleKnockReplyLocked(dm, src, di)
})
}
return
}
@ -2348,6 +2367,114 @@ func (c *Conn) unambiguousNodeKeyOfPingLocked(dm *disco.Ping, dk key.DiscoPublic
return nk, false
}
// handleKnockReplyLocked handles a DISCO Knock Reply message. If the nonce is
// correct, the callback for the pending knock is invoked.
//
// True is returned if this endpoint handled the nonce.
//
// di is the discoInfo of the source of the knock packet.
func (de *endpoint) handleKnockReplyLocked(dm *disco.KnockReply, src netip.AddrPort, di *discoInfo) bool {
de.mu.Lock()
defer de.mu.Unlock()
if de.pendingKnock == nil || !bytes.Equal(dm.Nonce[:], de.pendingKnock.nonce[:]) {
return false
}
// From this point on, nonce is correct
cb := de.pendingKnock.cb
de.pendingKnock = nil
go cb(nil)
return true
}
// handleKnockLocked handles a DISCO Knock message. If the recieved packet
// is in order, a response is sent containing the unwrapped nonce.
//
// di is the discoInfo of the source of the knock packet.
func (c *Conn) handleKnockLocked(dm *disco.Knock, src netip.AddrPort, di *discoInfo) {
// TODO(tom): Filter to LAN-only sources
nonceBytes, ok := c.privateKey.OpenAnonymous(dm.SealedNonce[:])
if !ok {
metricRecvDiscoKnockBadSeal.Add(1)
c.logf("magicsock: disco: dropping bad knock from %v", src)
return
}
var nonce [8]byte
copy(nonce[:], nonceBytes)
c.peerMap.forEachEndpointWithDiscoKey(di.discoKey, func(ep *endpoint) (keepGoing bool) {
go c.sendDiscoMessage(src, ep.publicKey, di.discoKey, &disco.KnockReply{
Nonce: nonce,
}, discoVerboseLog)
return true
})
}
// Knock handles a request to knock a specific peer.
func (c *Conn) Knock(addr netip.AddrPort, peer *tailcfg.Node, cb func(error)) {
if runtime.GOOS == "js" {
cb(errors.New("no direct over tsconnect"))
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.privateKey.IsZero() {
cb(errNetworkDown)
return
}
ep, ok := c.peerMap.endpointForNodeKey(peer.Key)
if !ok {
cb(errors.New("unknown peer"))
return
}
ep.knock(addr, cb)
}
func (de *endpoint) knock(addr netip.AddrPort, cb func(error)) {
de.mu.Lock()
defer de.mu.Unlock()
if de.expired {
cb(errExpired)
return
}
epDisco := de.disco.Load()
if epDisco == nil {
cb(errors.New("no disco key"))
return
}
var nonce [8]byte
if _, err := crand.Read(nonce[:]); err != nil {
panic(err) // worth dying for
}
sealed, err := de.publicKey.SealAnonymous(nonce[:])
if err != nil {
cb(err)
return
}
if de.pendingKnock != nil {
de.pendingKnock.cb(errors.New("superceded"))
}
de.pendingKnock = &pendingKnock{addr, cb, nonce}
go func() {
knock := disco.Knock{}
copy(knock.SealedNonce[:], sealed)
sent, _ := de.c.sendDiscoMessage(addr, de.publicKey, epDisco.key, &knock, discoVerboseLog)
if !sent {
panic("not sent")
}
}()
de.noteActiveLocked()
}
// di is the discoInfo of the source of the ping.
// derpNodeSrc is non-zero if the ping arrived via DERP.
func (c *Conn) handlePingLocked(dm *disco.Ping, src netip.AddrPort, di *discoInfo, derpNodeSrc key.NodePublic) {
@ -4141,6 +4268,8 @@ type endpoint struct {
pendingCLIPings []pendingCLIPing // any outstanding "tailscale ping" commands running
pendingKnock *pendingKnock // any outstanding knock challenge, if any
// The following fields are related to the new "silent disco"
// implementation that's a WIP as of 2022-10-20.
// See #540 for background.
@ -4156,6 +4285,12 @@ type pendingCLIPing struct {
cb func(*ipnstate.PingResult)
}
type pendingKnock struct {
addr netip.AddrPort
cb func(error)
nonce [8]byte
}
const (
// sessionActiveTimeout is how long since the last activity we
// try to keep an established endpoint peering alive.
@ -5269,6 +5404,7 @@ func (de *endpoint) stopAndReset() {
de.heartBeatTimer = nil
}
de.pendingCLIPings = nil
de.pendingKnock = nil
}
// resetLocked clears all the endpoint's p2p state, reverting it to a
@ -5468,6 +5604,11 @@ var (
metricRecvDiscoCallMeMaybe = clientmetric.NewCounter("magicsock_disco_recv_callmemaybe")
metricRecvDiscoCallMeMaybeBadNode = clientmetric.NewCounter("magicsock_disco_recv_callmemaybe_bad_node")
metricRecvDiscoCallMeMaybeBadDisco = clientmetric.NewCounter("magicsock_disco_recv_callmemaybe_bad_disco")
metricRecvDiscoKnock = clientmetric.NewCounter("magicsock_disco_recv_knock")
metricRecvDiscoKnockBadDisco = clientmetric.NewCounter("magicsock_disco_recv_knock_bad_disco")
metricRecvDiscoKnockBadSeal = clientmetric.NewCounter("magicsock_disco_recv_knock_bad_seal")
metricRecvDiscoKnockReply = clientmetric.NewCounter("magicsock_disco_recv_knock_reply")
metricRecvDiscoKnockReplyBadDisco = clientmetric.NewCounter("magicsock_disco_recv_knock_reply_bad_disco")
metricRecvDiscoDERPPeerNotHere = clientmetric.NewCounter("magicsock_disco_recv_derp_peer_not_here")
metricRecvDiscoDERPPeerGoneUnknown = clientmetric.NewCounter("magicsock_disco_recv_derp_peer_gone_unknown")
// metricDERPHomeChange is how many times our DERP home region DI has

View File

@ -2908,3 +2908,64 @@ func TestAddrForSendLockedForWireGuardOnly(t *testing.T) {
}
}
}
func TestDiscoKnock(t *testing.T) {
mstun := &natlab.Machine{Name: "stun"}
m1 := &natlab.Machine{Name: "m1"}
m2 := &natlab.Machine{Name: "m2"}
inet := natlab.NewInternet()
sif := mstun.Attach("eth0", inet)
m1if := m1.Attach("eth0", inet)
m2if := m2.Attach("eth0", inet)
d := &devices{
m1: m1,
m1IP: m1if.V4(),
m2: m2,
m2IP: m2if.V4(),
stun: mstun,
stunIP: sif.V4(),
}
logf, closeLogf := logger.LogfCloser(t.Logf)
defer closeLogf()
derpMap, cleanup := runDERPAndStun(t, logf, d.stun, d.stunIP)
defer cleanup()
ms1 := newMagicStack(t, logger.WithPrefix(logf, "conn1: "), d.m1, derpMap)
defer ms1.Close()
ms2 := newMagicStack(t, logger.WithPrefix(logf, "conn2: "), d.m2, derpMap)
defer ms2.Close()
cleanup = meshStacks(t.Logf, nil, ms1, ms2)
defer cleanup()
// Wait for both peers to know about each other.
for {
if s1 := ms1.Status(); len(s1.Peer) != 1 {
time.Sleep(10 * time.Millisecond)
continue
}
if s2 := ms2.Status(); len(s2.Peer) != 1 {
time.Sleep(10 * time.Millisecond)
continue
}
break
}
cbErr := make(chan error, 1)
ms1.conn.Knock(netip.AddrPortFrom(m2if.V4(), ms2.conn.pconn4.LocalAddr().AddrPort().Port()), &tailcfg.Node{Key: ms2.privateKey.Public()}, func(err error) {
cbErr <- err
})
select {
case err := <-cbErr:
if err != nil {
t.Errorf("Knock failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Error("timeout waiting for knock callback")
}
}