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 <bradfitz@tailscale.com>
andrew/peercap-ipv6-aaaa
Brad Fitzpatrick 1 year ago committed by Brad Fitzpatrick
parent da1b917575
commit b4816e19b6

@ -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(),
} }
} }

@ -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
}

@ -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 (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { func writeJSON(w http.ResponseWriter, v any) {
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
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)
}

@ -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)

@ -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

@ -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

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

@ -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

Loading…
Cancel
Save