diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go index 0ae72f295..2c0d15cb8 100644 --- a/hostinfo/hostinfo.go +++ b/hostinfo/hostinfo.go @@ -57,6 +57,7 @@ func New() *tailcfg.Hostinfo { Cloud: string(cloudenv.Get()), NoLogsNoSupport: envknob.NoLogsNoSupport(), AllowsUpdate: envknob.AllowsRemoteUpdate(), + WoLMACs: getWoLMACs(), } } diff --git a/hostinfo/wol.go b/hostinfo/wol.go new file mode 100644 index 000000000..3a30af2fe --- /dev/null +++ b/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 +} diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 79fd66ee9..afe9f56ee 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -9,15 +9,18 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "time" + "github.com/kortschak/wol" "tailscale.com/clientupdate" "tailscale.com/envknob" "tailscale.com/net/sockstats" @@ -30,11 +33,12 @@ import ( 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) { - writeJSON := func(v any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) - } switch r.URL.Path { case "/echo": // Test handler. @@ -50,6 +54,9 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad method", http.StatusMethodNotAllowed) return } + case "/wol": + b.handleC2NWoL(w, r) + return case "/logtail/flush": if r.Method != "POST" { 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.Write(goroutines.ScrubbedGoroutineDump(true)) case "/debug/prefs": - writeJSON(b.Prefs()) + writeJSON(w, b.Prefs()) case "/debug/metrics": w.Header().Set("Content-Type", "text/plain") clientmetric.WritePrometheusExpositionFormat(w) @@ -82,7 +89,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { if err != nil { res.Error = err.Error() } - writeJSON(res) + writeJSON(w, res) case "/debug/logheap": if c2nLogHeap != nil { c2nLogHeap(w, r) @@ -103,7 +110,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 500) return } - writeJSON(res) + writeJSON(w, res) case "/sockstats": if r.Method != "POST" { http.Error(w, "bad method", http.StatusMethodNotAllowed) @@ -270,3 +277,56 @@ func findCmdTailscale() (string, error) { } 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) +} diff --git a/ipn/ipnlocal/peerapi.go b/ipn/ipnlocal/peerapi.go index d9801d90d..fdab1004b 100644 --- a/ipn/ipnlocal/peerapi.go +++ b/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) 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() if st == nil { http.Error(w, "failed to get interfaces state", http.StatusInternalServerError) diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 65ffb363f..999fabe3c 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -118,7 +118,8 @@ type CapabilityVersion int // - 75: 2023-09-12: Client understands NodeAttrDNSForwarderDisableTCPRetries // - 76: 2023-09-20: Client understands ExitNodeDNSResolvers for IsWireGuardOnly nodes // - 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 @@ -735,6 +736,7 @@ type Hostinfo struct { GoVersion string `json:",omitempty"` // Go version binary was built with 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 + 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 NetInfo *NetInfo `json:",omitempty"` SSH_HostKeys []string `json:"sshHostKeys,omitempty"` // if advertised diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index ad56609bc..da5a11b77 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -131,6 +131,7 @@ func (src *Hostinfo) Clone() *Hostinfo { *dst = *src dst.RoutableIPs = append(src.RoutableIPs[:0:0], src.RoutableIPs...) 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.NetInfo = src.NetInfo.Clone() dst.SSH_HostKeys = append(src.SSH_HostKeys[:0:0], src.SSH_HostKeys...) @@ -169,6 +170,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct { GoVersion string RoutableIPs []netip.Prefix RequestTags []string + WoLMACs []string Services []Service NetInfo *NetInfo SSH_HostKeys []string diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index c49b0430c..261008fc6 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -57,6 +57,7 @@ func TestHostinfoEqual(t *testing.T) { "GoVersion", "RoutableIPs", "RequestTags", + "WoLMACs", "Services", "NetInfo", "SSH_HostKeys", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 3f3435dee..74991eb11 100644 --- a/tailcfg/tailcfg_view.go +++ b/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) 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) WoLMACs() views.Slice[string] { return views.SliceOf(v.ж.WoLMACs) } func (v HostinfoView) Services() views.Slice[Service] { return views.SliceOf(v.ж.Services) } func (v HostinfoView) NetInfo() NetInfoView { return v.ж.NetInfo.View() } func (v HostinfoView) SSH_HostKeys() views.Slice[string] { return views.SliceOf(v.ж.SSH_HostKeys) } @@ -355,6 +356,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct { GoVersion string RoutableIPs []netip.Prefix RequestTags []string + WoLMACs []string Services []Service NetInfo *NetInfo SSH_HostKeys []string