Explorar o código

hostinfo, ipnlocal: flesh out Wake-on-LAN support, send MACs, add c2n sender

This optionally uploads MAC address(es) to control, then adds a
c2n handler so control can ask a node to send a WoL packet.

Updates #306

RELNOTE=now supports waking up peer nodes on your LAN via Wake-on-LAN packets

Change-Id: Ibea1275fcd2048dc61d7059039abfbaf1ad4f465
Signed-off-by: Brad Fitzpatrick <[email protected]>
Brad Fitzpatrick %!s(int64=2) %!d(string=hai) anos
pai
achega
b4816e19b6

+ 1 - 0
hostinfo/hostinfo.go

@@ -57,6 +57,7 @@ func New() *tailcfg.Hostinfo {
 		Cloud:           string(cloudenv.Get()),
 		Cloud:           string(cloudenv.Get()),
 		NoLogsNoSupport: envknob.NoLogsNoSupport(),
 		NoLogsNoSupport: envknob.NoLogsNoSupport(),
 		AllowsUpdate:    envknob.AllowsRemoteUpdate(),
 		AllowsUpdate:    envknob.AllowsRemoteUpdate(),
+		WoLMACs:         getWoLMACs(),
 	}
 	}
 }
 }
 
 

+ 106 - 0
hostinfo/wol.go

@@ -0,0 +1,106 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package hostinfo
+
+import (
+	"log"
+	"net"
+	"runtime"
+	"strings"
+	"unicode"
+
+	"tailscale.com/envknob"
+)
+
+// TODO(bradfitz): this is all too simplistic and static. It needs to run
+// continuously in response to netmon events (USB ethernet adapaters might get
+// plugged in) and look for the media type/status/etc. Right now on macOS it
+// still detects a half dozen "up" en0, en1, en2, en3 etc interfaces that don't
+// have any media. We should only report the one that's actually connected.
+// But it works for now (2023-10-05) for fleshing out the rest.
+
+var wakeMAC = envknob.RegisterString("TS_WAKE_MAC") // mac address, "false" or "auto". for https://github.com/tailscale/tailscale/issues/306
+
+// getWoLMACs returns up to 10 MAC address of the local machine to send
+// wake-on-LAN packets to in order to wake it up. The returned MACs are in
+// lowercase hex colon-separated form ("xx:xx:xx:xx:xx:xx").
+//
+// If TS_WAKE_MAC=auto, it tries to automatically find the MACs based on the OS
+// type and interface properties. (TODO(bradfitz): incomplete) If TS_WAKE_MAC is
+// set to a MAC address, that sole MAC address is returned.
+func getWoLMACs() (macs []string) {
+	switch runtime.GOOS {
+	case "ios", "android":
+		return nil
+	}
+	if s := wakeMAC(); s != "" {
+		switch s {
+		case "auto":
+			ifs, _ := net.Interfaces()
+			for _, iface := range ifs {
+				if iface.Flags&net.FlagLoopback != 0 {
+					continue
+				}
+				if iface.Flags&net.FlagBroadcast == 0 ||
+					iface.Flags&net.FlagRunning == 0 ||
+					iface.Flags&net.FlagUp == 0 {
+					continue
+				}
+				if keepMAC(iface.Name, iface.HardwareAddr) {
+					macs = append(macs, iface.HardwareAddr.String())
+				}
+				if len(macs) == 10 {
+					break
+				}
+			}
+			return macs
+		case "false", "off": // fast path before ParseMAC error
+			return nil
+		}
+		mac, err := net.ParseMAC(s)
+		if err != nil {
+			log.Printf("invalid MAC %q", s)
+			return nil
+		}
+		return []string{mac.String()}
+	}
+	return nil
+}
+
+var ignoreWakeOUI = map[[3]byte]bool{
+	{0x00, 0x15, 0x5d}: true, // Hyper-V
+	{0x00, 0x50, 0x56}: true, // VMware
+	{0x00, 0x1c, 0x14}: true, // VMware
+	{0x00, 0x05, 0x69}: true, // VMware
+	{0x00, 0x0c, 0x29}: true, // VMware
+	{0x00, 0x1c, 0x42}: true, // Parallels
+	{0x08, 0x00, 0x27}: true, // VirtualBox
+	{0x00, 0x21, 0xf6}: true, // VirtualBox
+	{0x00, 0x14, 0x4f}: true, // VirtualBox
+	{0x00, 0x0f, 0x4b}: true, // VirtualBox
+	{0x52, 0x54, 0x00}: true, // VirtualBox/Vagrant
+}
+
+func keepMAC(ifName string, mac []byte) bool {
+	if len(mac) != 6 {
+		return false
+	}
+	base := strings.TrimRightFunc(ifName, unicode.IsNumber)
+	switch runtime.GOOS {
+	case "darwin":
+		switch base {
+		case "llw", "awdl", "utun", "bridge", "lo", "gif", "stf", "anpi", "ap":
+			return false
+		}
+	}
+	if mac[0] == 0x02 && mac[1] == 0x42 {
+		// Docker container.
+		return false
+	}
+	oui := [3]byte{mac[0], mac[1], mac[2]}
+	if ignoreWakeOUI[oui] {
+		return false
+	}
+	return true
+}

+ 67 - 7
ipn/ipnlocal/c2n.go

@@ -9,15 +9,18 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"path/filepath"
 	"path/filepath"
 	"runtime"
 	"runtime"
+	"sort"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/kortschak/wol"
 	"tailscale.com/clientupdate"
 	"tailscale.com/clientupdate"
 	"tailscale.com/envknob"
 	"tailscale.com/envknob"
 	"tailscale.com/net/sockstats"
 	"tailscale.com/net/sockstats"
@@ -30,11 +33,12 @@ import (
 
 
 var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
 var c2nLogHeap func(http.ResponseWriter, *http.Request) // non-nil on most platforms (c2n_pprof.go)
 
 
+func writeJSON(w http.ResponseWriter, v any) {
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(v)
+}
+
 func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
 func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
-	writeJSON := func(v any) {
-		w.Header().Set("Content-Type", "application/json")
-		json.NewEncoder(w).Encode(v)
-	}
 	switch r.URL.Path {
 	switch r.URL.Path {
 	case "/echo":
 	case "/echo":
 		// Test handler.
 		// Test handler.
@@ -50,6 +54,9 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
 			return
 			return
 		}
 		}
+	case "/wol":
+		b.handleC2NWoL(w, r)
+		return
 	case "/logtail/flush":
 	case "/logtail/flush":
 		if r.Method != "POST" {
 		if r.Method != "POST" {
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
@@ -64,7 +71,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Type", "text/plain")
 		w.Header().Set("Content-Type", "text/plain")
 		w.Write(goroutines.ScrubbedGoroutineDump(true))
 		w.Write(goroutines.ScrubbedGoroutineDump(true))
 	case "/debug/prefs":
 	case "/debug/prefs":
-		writeJSON(b.Prefs())
+		writeJSON(w, b.Prefs())
 	case "/debug/metrics":
 	case "/debug/metrics":
 		w.Header().Set("Content-Type", "text/plain")
 		w.Header().Set("Content-Type", "text/plain")
 		clientmetric.WritePrometheusExpositionFormat(w)
 		clientmetric.WritePrometheusExpositionFormat(w)
@@ -82,7 +89,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
 		if err != nil {
 		if err != nil {
 			res.Error = err.Error()
 			res.Error = err.Error()
 		}
 		}
-		writeJSON(res)
+		writeJSON(w, res)
 	case "/debug/logheap":
 	case "/debug/logheap":
 		if c2nLogHeap != nil {
 		if c2nLogHeap != nil {
 			c2nLogHeap(w, r)
 			c2nLogHeap(w, r)
@@ -103,7 +110,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
 			http.Error(w, err.Error(), 500)
 			http.Error(w, err.Error(), 500)
 			return
 			return
 		}
 		}
-		writeJSON(res)
+		writeJSON(w, res)
 	case "/sockstats":
 	case "/sockstats":
 		if r.Method != "POST" {
 		if r.Method != "POST" {
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
 			http.Error(w, "bad method", http.StatusMethodNotAllowed)
@@ -270,3 +277,56 @@ func findCmdTailscale() (string, error) {
 	}
 	}
 	return "", fmt.Errorf("unsupported OS %v", runtime.GOOS)
 	return "", fmt.Errorf("unsupported OS %v", runtime.GOOS)
 }
 }
+
+func (b *LocalBackend) handleC2NWoL(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "bad method", http.StatusMethodNotAllowed)
+		return
+	}
+	r.ParseForm()
+	var macs []net.HardwareAddr
+	for _, macStr := range r.Form["mac"] {
+		mac, err := net.ParseMAC(macStr)
+		if err != nil {
+			http.Error(w, "bad 'mac' param", http.StatusBadRequest)
+			return
+		}
+		macs = append(macs, mac)
+	}
+	var res struct {
+		SentTo []string
+		Errors []string
+	}
+	st := b.sys.NetMon.Get().InterfaceState()
+	if st == nil {
+		res.Errors = append(res.Errors, "no interface state")
+		writeJSON(w, &res)
+		return
+	}
+	var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
+	for _, mac := range macs {
+		for ifName, ips := range st.InterfaceIPs {
+			for _, ip := range ips {
+				if ip.Addr().IsLoopback() || ip.Addr().Is6() {
+					continue
+				}
+				local := &net.UDPAddr{
+					IP:   ip.Addr().AsSlice(),
+					Port: 0,
+				}
+				remote := &net.UDPAddr{
+					IP:   net.IPv4bcast,
+					Port: 0,
+				}
+				if err := wol.Wake(mac, password, local, remote); err != nil {
+					res.Errors = append(res.Errors, err.Error())
+				} else {
+					res.SentTo = append(res.SentTo, ifName)
+				}
+				break // one per interface is enough
+			}
+		}
+	}
+	sort.Strings(res.SentTo)
+	writeJSON(w, &res)
+}

+ 1 - 1
ipn/ipnlocal/peerapi.go

@@ -1291,7 +1291,7 @@ func (h *peerAPIHandler) handleWakeOnLAN(w http.ResponseWriter, r *http.Request)
 		http.Error(w, "bad 'mac' param", http.StatusBadRequest)
 		http.Error(w, "bad 'mac' param", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	var password []byte // TODO(bradfitz): support?
+	var password []byte // TODO(bradfitz): support? does anything use WoL passwords?
 	st := h.ps.b.sys.NetMon.Get().InterfaceState()
 	st := h.ps.b.sys.NetMon.Get().InterfaceState()
 	if st == nil {
 	if st == nil {
 		http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)
 		http.Error(w, "failed to get interfaces state", http.StatusInternalServerError)

+ 3 - 1
tailcfg/tailcfg.go

@@ -118,7 +118,8 @@ type CapabilityVersion int
 //   - 75: 2023-09-12: Client understands NodeAttrDNSForwarderDisableTCPRetries
 //   - 75: 2023-09-12: Client understands NodeAttrDNSForwarderDisableTCPRetries
 //   - 76: 2023-09-20: Client understands ExitNodeDNSResolvers for IsWireGuardOnly nodes
 //   - 76: 2023-09-20: Client understands ExitNodeDNSResolvers for IsWireGuardOnly nodes
 //   - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer
 //   - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer
-const CurrentCapabilityVersion CapabilityVersion = 77
+//   - 78: 2023-10-05: can handle c2n Wake-on-LAN sending
+const CurrentCapabilityVersion CapabilityVersion = 78
 
 
 type StableID string
 type StableID string
 
 
@@ -735,6 +736,7 @@ type Hostinfo struct {
 	GoVersion       string         `json:",omitempty"` // Go version binary was built with
 	GoVersion       string         `json:",omitempty"` // Go version binary was built with
 	RoutableIPs     []netip.Prefix `json:",omitempty"` // set of IP ranges this client can route
 	RoutableIPs     []netip.Prefix `json:",omitempty"` // set of IP ranges this client can route
 	RequestTags     []string       `json:",omitempty"` // set of ACL tags this node wants to claim
 	RequestTags     []string       `json:",omitempty"` // set of ACL tags this node wants to claim
+	WoLMACs         []string       `json:",omitempty"` // MAC address(es) to send Wake-on-LAN packets to wake this node (lowercase hex w/ colons)
 	Services        []Service      `json:",omitempty"` // services advertised by this machine
 	Services        []Service      `json:",omitempty"` // services advertised by this machine
 	NetInfo         *NetInfo       `json:",omitempty"`
 	NetInfo         *NetInfo       `json:",omitempty"`
 	SSH_HostKeys    []string       `json:"sshHostKeys,omitempty"` // if advertised
 	SSH_HostKeys    []string       `json:"sshHostKeys,omitempty"` // if advertised

+ 2 - 0
tailcfg/tailcfg_clone.go

@@ -131,6 +131,7 @@ func (src *Hostinfo) Clone() *Hostinfo {
 	*dst = *src
 	*dst = *src
 	dst.RoutableIPs = append(src.RoutableIPs[:0:0], src.RoutableIPs...)
 	dst.RoutableIPs = append(src.RoutableIPs[:0:0], src.RoutableIPs...)
 	dst.RequestTags = append(src.RequestTags[:0:0], src.RequestTags...)
 	dst.RequestTags = append(src.RequestTags[:0:0], src.RequestTags...)
+	dst.WoLMACs = append(src.WoLMACs[:0:0], src.WoLMACs...)
 	dst.Services = append(src.Services[:0:0], src.Services...)
 	dst.Services = append(src.Services[:0:0], src.Services...)
 	dst.NetInfo = src.NetInfo.Clone()
 	dst.NetInfo = src.NetInfo.Clone()
 	dst.SSH_HostKeys = append(src.SSH_HostKeys[:0:0], src.SSH_HostKeys...)
 	dst.SSH_HostKeys = append(src.SSH_HostKeys[:0:0], src.SSH_HostKeys...)
@@ -169,6 +170,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
 	GoVersion       string
 	GoVersion       string
 	RoutableIPs     []netip.Prefix
 	RoutableIPs     []netip.Prefix
 	RequestTags     []string
 	RequestTags     []string
+	WoLMACs         []string
 	Services        []Service
 	Services        []Service
 	NetInfo         *NetInfo
 	NetInfo         *NetInfo
 	SSH_HostKeys    []string
 	SSH_HostKeys    []string

+ 1 - 0
tailcfg/tailcfg_test.go

@@ -57,6 +57,7 @@ func TestHostinfoEqual(t *testing.T) {
 		"GoVersion",
 		"GoVersion",
 		"RoutableIPs",
 		"RoutableIPs",
 		"RequestTags",
 		"RequestTags",
+		"WoLMACs",
 		"Services",
 		"Services",
 		"NetInfo",
 		"NetInfo",
 		"SSH_HostKeys",
 		"SSH_HostKeys",

+ 2 - 0
tailcfg/tailcfg_view.go

@@ -310,6 +310,7 @@ func (v HostinfoView) GoArchVar() string                      { return v.ж.GoAr
 func (v HostinfoView) GoVersion() string                      { return v.ж.GoVersion }
 func (v HostinfoView) GoVersion() string                      { return v.ж.GoVersion }
 func (v HostinfoView) RoutableIPs() views.Slice[netip.Prefix] { return views.SliceOf(v.ж.RoutableIPs) }
 func (v HostinfoView) RoutableIPs() views.Slice[netip.Prefix] { return views.SliceOf(v.ж.RoutableIPs) }
 func (v HostinfoView) RequestTags() views.Slice[string]       { return views.SliceOf(v.ж.RequestTags) }
 func (v HostinfoView) RequestTags() views.Slice[string]       { return views.SliceOf(v.ж.RequestTags) }
+func (v HostinfoView) WoLMACs() views.Slice[string]           { return views.SliceOf(v.ж.WoLMACs) }
 func (v HostinfoView) Services() views.Slice[Service]         { return views.SliceOf(v.ж.Services) }
 func (v HostinfoView) Services() views.Slice[Service]         { return views.SliceOf(v.ж.Services) }
 func (v HostinfoView) NetInfo() NetInfoView                   { return v.ж.NetInfo.View() }
 func (v HostinfoView) NetInfo() NetInfoView                   { return v.ж.NetInfo.View() }
 func (v HostinfoView) SSH_HostKeys() views.Slice[string]      { return views.SliceOf(v.ж.SSH_HostKeys) }
 func (v HostinfoView) SSH_HostKeys() views.Slice[string]      { return views.SliceOf(v.ж.SSH_HostKeys) }
@@ -355,6 +356,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
 	GoVersion       string
 	GoVersion       string
 	RoutableIPs     []netip.Prefix
 	RoutableIPs     []netip.Prefix
 	RequestTags     []string
 	RequestTags     []string
+	WoLMACs         []string
 	Services        []Service
 	Services        []Service
 	NetInfo         *NetInfo
 	NetInfo         *NetInfo
 	SSH_HostKeys    []string
 	SSH_HostKeys    []string