diff options
| -rw-r--r-- | cmd/tailscale/cli/cli.go | 2 | ||||
| -rw-r--r-- | cmd/tailscale/cli/wakeonlan.go | 472 | ||||
| -rw-r--r-- | feature/wakeonlan/wakeonlan.go | 213 |
3 files changed, 687 insertions, 0 deletions
diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 8a2c2b9ef..43c3744be 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -218,6 +218,7 @@ var ( maybeServeCmd, maybeCertCmd, maybeUpdateCmd, + maybeWakeOnLANCmd, _ func() *ffcli.Command ) @@ -278,6 +279,7 @@ change in the future. systrayCmd, appcRoutesCmd, waitCmd, + nilOrCall(maybeWakeOnLANCmd), ), FlagSet: rootfs, Exec: func(ctx context.Context, args []string) error { diff --git a/cmd/tailscale/cli/wakeonlan.go b/cmd/tailscale/cli/wakeonlan.go new file mode 100644 index 000000000..5058432b4 --- /dev/null +++ b/cmd/tailscale/cli/wakeonlan.go @@ -0,0 +1,472 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_wakeonelan + +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/atomicfile" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" +) + +func init() { + maybeWakeOnLANCmd = func() *ffcli.Command { return wakeOnLANCmd } +} + +// Cache file top-level structure +type wolTopology struct { + Timestamp time.Time `json:"timestamp"` + Probers map[string]*proberInfo `json:"probers"` // key is string(nodeID) +} + +// Information about a node that was probed +type proberInfo struct { + NodeID tailcfg.StableNodeID `json:"nodeID"` + NodeName string `json:"nodeName"` + TailscaleIP string `json:"tailscaleIP"` + MacAddresses []string `json:"macAddresses"` + CanSee []peerInfo `json:"canSee"` +} + +// Information about a peer visible on local network +type peerInfo struct { + NodeID tailcfg.StableNodeID `json:"nodeID"` + NodeName string `json:"nodeName"` + TailscaleIP string `json:"tailscaleIP"` + Endpoint string `json:"endpoint"` + LatencySeconds float64 `json:"latencySeconds"` +} + +// Response from /v0/check-direct endpoint +type checkDirectResponse struct { + Nodes []directNodeInfo `json:"nodes"` + SelfMacAddresses []string `json:"self_mac_addresses"` +} + +type directNodeInfo struct { + NodeID tailcfg.StableNodeID `json:"nodeID"` + NodeName string `json:"nodeName,omitempty"` + Endpoint string `json:"endpoint"` + OnSameSubnet bool `json:"onSameSubnet"` + LatencySeconds float64 `json:"latencySeconds"` +} + +// Response from /v0/wol endpoint +type wolResponse struct { + SentTo []string `json:"SentTo"` + Errors []string `json:"Errors"` +} + +// Get cache file path (default or from args) +func getWoLCacheFile(args []string) string { + if len(args) > 0 { + return args[0] + } + return getDefaultCacheFile() +} + +func getDefaultCacheFile() string { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = os.Getenv("HOME") + } + return filepath.Join(configDir, "tailscale", "wol-topology.json") +} + +// Call /v0/check-direct on a peer +func callCheckDirect(ctx context.Context, peerAPIURL string) (*checkDirectResponse, error) { + req, err := http.NewRequestWithContext(ctx, "POST", peerAPIURL+"/v0/check-direct", nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + + var result checkDirectResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("unable to decode result: %w", err) + } + + return &result, nil +} + +// Send WoL packet via /v0/wol +func sendWoL(ctx context.Context, peerAPIURL, mac string) (*wolResponse, error) { + data := url.Values{"mac": {mac}} + req, err := http.NewRequestWithContext(ctx, "POST", peerAPIURL+"/v0/wol", + strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + + var result wolResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +// Save topology to file +func saveTopology(path string, topology *wolTopology) error { + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + // Encode to JSON + data, err := json.MarshalIndent(topology, "", " ") + if err != nil { + return err + } + + // Use atomic file write + return atomicfile.WriteFile(path, data, 0o644) +} + +// Load topology from file +func loadTopology(path string) (*wolTopology, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var topology wolTopology + if err := json.Unmarshal(data, &topology); err != nil { + return nil, err + } + + return &topology, nil +} + +// Find node in topology by name or IP +func findNodeInTopology(topology *wolTopology, search string) *proberInfo { + for _, prober := range topology.Probers { + if strings.Contains(prober.NodeName, search) || + prober.TailscaleIP == search || + fmt.Sprint(prober.NodeID) == search { + return prober + } + } + return nil +} + +// Enrich topology with Tailscale IPs from status +func enrichTopologyWithIPs(topology *wolTopology, st *ipnstate.Status) { + // Build a map from StableNodeID to PeerStatus for quick lookup + idToPeer := make(map[tailcfg.StableNodeID]*ipnstate.PeerStatus) + for _, peer := range st.Peer { + idToPeer[peer.ID] = peer + } + + for _, prober := range topology.Probers { + for i := range prober.CanSee { + peer := &prober.CanSee[i] + if statusPeer, ok := idToPeer[peer.NodeID]; ok && len(statusPeer.TailscaleIPs) > 0 { + peer.TailscaleIP = statusPeer.TailscaleIPs[0].String() + } + } + } +} + +var wolProbeCmd = &ffcli.Command{ + Name: "probe", + ShortUsage: "tailscale wakeonlan probe [cache-file]", + ShortHelp: "Probe network topology for Wake-on-LAN", + LongHelp: "Discovers which nodes can wake which other nodes by probing all peers on the network.", + Exec: runWakeOnLANProbe, +} + +func runWakeOnLANProbe(ctx context.Context, args []string) error { + cacheFile := getWoLCacheFile(args) + + st, err := localClient.Status(ctx) + if err != nil { + return fmt.Errorf("failed to get status: %w", err) + } + + printf("Probing network topology...\n") + topology := &wolTopology{ + Timestamp: time.Now(), + Probers: make(map[string]*proberInfo), + } + + for _, peer := range st.Peer { + // Skip nodes not online and mullvad nodes. + if !peer.Online || strings.Contains(peer.DNSName, "mullvad.ts.net") { + continue + } + + // Skip peers without PeerAPI + if peer.PeerAPIURL == nil || len(peer.PeerAPIURL) == 0 { + printf("⊘ %s: no PeerAPI available\n", peer.DNSName) + continue + } + + // Call /v0/check-direct on this peer + resp, err := callCheckDirect(ctx, peer.PeerAPIURL[0]) + if err != nil { + printf("✗ %s: %v\n", peer.DNSName, err) + continue + } + + info := &proberInfo{ + NodeID: peer.ID, + NodeName: peer.DNSName, + TailscaleIP: peer.TailscaleIPs[0].String(), + MacAddresses: resp.SelfMacAddresses, + CanSee: make([]peerInfo, 0, len(resp.Nodes)), + } + + for _, node := range resp.Nodes { + info.CanSee = append(info.CanSee, peerInfo{ + NodeID: node.NodeID, + NodeName: node.NodeName, + TailscaleIP: "", // Need to look up from status + Endpoint: node.Endpoint, + LatencySeconds: node.LatencySeconds, + }) + } + + topology.Probers[fmt.Sprint(peer.ID)] = info + printf("✓ %s: found %d local peers\n", peer.DNSName, len(info.CanSee)) + } + + enrichTopologyWithIPs(topology, st) + + if err := saveTopology(cacheFile, topology); err != nil { + return err + } + + printf("\nTopology saved to %s\n", cacheFile) + return nil +} + +var wolListCmd = &ffcli.Command{ + Name: "list", + ShortUsage: "tailscale wakeonlan list [cache-file]", + ShortHelp: "List nodes that can be woken via Wake-on-LAN", + LongHelp: "Displays cached topology showing which nodes can wake which other nodes.", + Exec: runWakeOnLANList, +} + +func runWakeOnLANList(ctx context.Context, args []string) error { + cacheFile := getWoLCacheFile(args) + topology, err := loadTopology(cacheFile) + if err != nil { + return fmt.Errorf("failed to load topology: %w (try running 'tailscale wakeonlan probe' first)", err) + } + + // 2. Build reverse map: target -> list of probers that can wake it + wakeMap := make(map[tailcfg.StableNodeID][]*proberInfo) + for _, prober := range topology.Probers { + for _, peer := range prober.CanSee { + wakeMap[peer.NodeID] = append(wakeMap[peer.NodeID], prober) + } + } + + // 3. Display formatted output + printf("Wake-on-LAN Topology (cached from %s)\n\n", + topology.Timestamp.Format("2006-01-02 15:04:05")) + + if len(wakeMap) == 0 { + printf("No nodes found that can be woken.\n") + printf("Run 'tailscale wakeonlan probe' to discover topology.\n") + return nil + } + + printf("Nodes that can be woken:\n") + + for nodeID, wakers := range wakeMap { + // Find node info (need MAC addresses) + var nodeInfo *proberInfo + for _, p := range topology.Probers { + if p.NodeID == nodeID { + nodeInfo = p + break + } + } + + if nodeInfo == nil || len(nodeInfo.MacAddresses) == 0 { + continue // Can't wake nodes without MAC addresses + } + + printf(" %s (%s)\n", nodeInfo.NodeName, nodeInfo.TailscaleIP) + printf(" MAC: %s\n", strings.Join(nodeInfo.MacAddresses, ", ")) + + wakeNames := make([]string, 0, len(wakers)) + for _, w := range wakers { + wakeNames = append(wakeNames, strings.TrimSuffix(w.NodeName, ".")) + } + printf(" Can be woken by: %s\n\n", strings.Join(wakeNames, ", ")) + } + + printf("Run 'tailscale wakeonlan probe' to refresh topology.\n") + return nil +} + +var wolWakeupCmd = &ffcli.Command{ + Name: "wakeup", + ShortUsage: "tailscale wakeonlan wakeup [cache-file] <target-node>", + ShortHelp: "Wake up a node using Wake-on-LAN", + LongHelp: "Sends Wake-on-LAN packets to wake up a specific node on the network.", + Exec: runWakeOnLANWakeup, +} + +func runWakeOnLANWakeup(ctx context.Context, args []string) error { + if len(args) < 1 { + return errors.New("missing target node") + } + + var cacheFile string + var targetNode string + if len(args) == 1 { + cacheFile = getDefaultCacheFile() + targetNode = args[0] + } else { + cacheFile = args[0] + targetNode = args[1] + } + + topology, err := loadTopology(cacheFile) + if err != nil { + return fmt.Errorf("failed to load topology: %w (try running 'tailscale wakeonlan probe' first)", err) + } + + target := findNodeInTopology(topology, targetNode) + if target == nil { + return fmt.Errorf("target node %q not found in topology", targetNode) + } + + if len(target.MacAddresses) == 0 { + return fmt.Errorf("no MAC addresses found for %s", target.NodeName) + } + + var wakers []*proberInfo + for _, prober := range topology.Probers { + for _, peer := range prober.CanSee { + if peer.NodeID == target.NodeID { + wakers = append(wakers, prober) + break + } + } + } + + if len(wakers) == 0 { + return fmt.Errorf("no nodes found that can wake %s", target.NodeName) + } + + printf("Waking up %s...\n\n", target.NodeName) + + // Get current status to find PeerAPI URL + st, err := localClient.Status(ctx) + if err != nil { + return fmt.Errorf("Failed to get localClient status: %v", err) + } + + successCount := 0 + for _, waker := range wakers { + // Find peer by StableNodeID + var peer *ipnstate.PeerStatus + for _, p := range st.Peer { + if p.ID == waker.NodeID { + peer = p + break + } + } + + if peer == nil || !peer.Online || len(peer.PeerAPIURL) == 0 { + continue + } + + printf("Sending WoL from %s...\n", waker.NodeName) + + // Call /v0/wol for each MAC address + for _, mac := range target.MacAddresses { + resp, err := sendWoL(ctx, peer.PeerAPIURL[0], mac) + if err != nil { + printf(" ✗ Failed: %v\n", err) + continue + } + + if len(resp.Errors) > 0 { + for _, e := range resp.Errors { + printf(" ✗ Error: %s\n", e) + } + } + + if len(resp.SentTo) > 0 { + for _, iface := range resp.SentTo { + printf(" ✓ Sent on interface %s\n", iface) + } + successCount++ + } + } + } + + if successCount > 0 { + printf("\nWake-on-LAN packets sent successfully from %d peers.\n", successCount) + return nil + } + + return errors.New("failed to send any WoL packets") +} + +var wakeOnLANCmd = &ffcli.Command{ + Name: "wakeonlan", + ShortUsage: "tailscale wakeonlan <probe|list|wakeup> [flags]", + ShortHelp: "Wake-on-LAN network discovery and control", + LongHelp: "Discover network topology and wake up nodes on the same LAN.", + Subcommands: []*ffcli.Command{ + wolProbeCmd, + wolListCmd, + wolWakeupCmd, + }, + Exec: runWakeOnLANNoSubcommand, +} + +func runWakeOnLANNoSubcommand(ctx context.Context, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unknown subcommand: %s", args[0]) + } + return errors.New("wakeonlan requires a subcommand: probe, list, or wakeup") +} diff --git a/feature/wakeonlan/wakeonlan.go b/feature/wakeonlan/wakeonlan.go index 5a567ad44..205033636 100644 --- a/feature/wakeonlan/wakeonlan.go +++ b/feature/wakeonlan/wakeonlan.go @@ -5,20 +5,26 @@ package wakeonlan import ( + "context" "encoding/json" + "errors" "log" "net" "net/http" + "net/netip" "runtime" "sort" "strings" + "time" "unicode" "github.com/kortschak/wol" + "golang.org/x/sync/errgroup" "tailscale.com/envknob" "tailscale.com/feature" "tailscale.com/hostinfo" "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/util/clientmetric" ) @@ -27,6 +33,7 @@ func init() { feature.Register("wakeonlan") ipnlocal.RegisterC2N("POST /wol", handleC2NWoL) ipnlocal.RegisterPeerAPIHandler("/v0/wol", handlePeerAPIWakeOnLAN) + ipnlocal.RegisterPeerAPIHandler("/v0/check-direct", handlePeerAPICheckDirect) hostinfo.RegisterHostinfoNewHook(func(h *tailcfg.Hostinfo) { h.WoLMACs = getWoLMACs() }) @@ -93,8 +100,138 @@ func canWakeOnLAN(h ipnlocal.PeerAPIHandler) bool { return h.IsSelfUntagged() || h.PeerCaps().HasCapability(tailcfg.PeerCapabilityWakeOnLAN) } +func canCheckDirect(h ipnlocal.PeerAPIHandler) bool { + if h.Peer().UnsignedPeerAPIOnly() { + return false + } + return h.IsSelfUntagged() || h.PeerCaps().HasCapability(tailcfg.PeerCapabilityWakeOnLAN) +} + +func handlePeerAPICheckDirect(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { + // metricCheckDirectCalls.Add(1) + + // Check capability + if !canCheckDirect(h) { + http.Error(w, "no check-direct access", http.StatusForbidden) + return + } + + // Validate method + if r.Method != "POST" { + http.Error(w, "bad method", http.StatusMethodNotAllowed) + return + } + + // Get backend and netmap + b := h.LocalBackend() + nm := b.NetMap() + if nm == nil { + http.Error(w, "no netmap available", http.StatusServiceUnavailable) + return + } + + // Get local subnets + localSubnets := getLocalSubnets(b) + if len(localSubnets) == 0 { + // No local network interfaces, return empty results + writeJSON(w, &checkDirectResponse{}) + return + } + + // Find peers with overlapping endpoints (excluding mobile) + var candidateNodes []tailcfg.NodeView + for _, peer := range nm.Peers { + // Skip mobile devices - they can't be woken by WoL + if hostinfo := peer.Hostinfo(); hostinfo.Valid() { + os := hostinfo.OS() + if os == "android" || os == "iOS" { + continue + } + } + + // Check for endpoint overlap + if peerHasOverlappingEndpoint(peer, localSubnets) { + candidateNodes = append(candidateNodes, peer) + } + } + + // If no candidates found, return empty results + if len(candidateNodes) == 0 { + writeJSON(w, &checkDirectResponse{}) + return + } + + // Ping candidates concurrently + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + type nodeResult struct { + node tailcfg.NodeView + result *ipnstate.PingResult + err error + } + + results := make([]nodeResult, len(candidateNodes)) + g, ctx := errgroup.WithContext(ctx) + + for i, node := range candidateNodes { + i, node := i, node // capture loop variables + g.Go(func() error { + // Get first Tailscale IP to ping + addrs := node.Addresses() + if addrs.Len() == 0 { + results[i] = nodeResult{node: node, err: errors.New("no addresses")} + return nil + } + ip := addrs.At(0).Addr() + + // Ping with disco protocol + pr, err := b.Ping(ctx, ip, tailcfg.PingDisco, 0) + results[i] = nodeResult{node: node, result: pr, err: err} + return nil // Individual failures don't abort entire operation + }) + } + + g.Wait() // Wait for all pings to complete (or timeout) + + // Process results and build response + var resp checkDirectResponse + for _, res := range results { + // Skip nodes without direct connections + if res.result == nil || res.result.Endpoint == "" { + continue + } + + // Parse endpoint address + endpointAddrPort, err := netip.ParseAddrPort(res.result.Endpoint) + if err != nil { + continue + } + + onSameSubnet := isOnSameSubnet(endpointAddrPort.Addr(), localSubnets) + if !onSameSubnet { + continue + } + + // Build response entry + info := directNodeInfo{ + NodeID: res.node.StableID(), + NodeName: res.node.Name(), + Endpoint: res.result.Endpoint, + Latency: res.result.LatencySeconds, + } + + resp.Nodes = append(resp.Nodes, info) + } + resp.SelfMacAddresses = getWoLMACs() + + writeJSON(w, resp) +} + var metricWakeOnLANCalls = clientmetric.NewCounter("peerapi_wol") +// metricCheckDirectCalls = clientmetric.NewCounter("peerapi_check_direct") + func handlePeerAPIWakeOnLAN(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { metricWakeOnLANCalls.Add(1) if !canWakeOnLAN(h) { @@ -150,6 +287,82 @@ func handlePeerAPIWakeOnLAN(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r writeJSON(w, res) } +type checkDirectResponse struct { + Nodes []directNodeInfo `json:"nodes"` + SelfMacAddresses []string `json:"self_mac_addresses"` +} + +type directNodeInfo struct { + NodeID tailcfg.StableNodeID `json:"nodeID"` + NodeName string `json:"nodeName,omitempty"` + Endpoint string `json:"endpoint"` + OnSameSubnet bool `json:"onSameSubnet"` + Latency float64 `json:"latencySeconds"` +} + +func getLocalSubnets(b *ipnlocal.LocalBackend) []netip.Prefix { + st := b.NetMon().InterfaceState() + if st == nil || st.InterfaceIPs == nil { + return nil + } + + var subnets []netip.Prefix + for _, prefixes := range st.InterfaceIPs { + for _, prefix := range prefixes { + // Skip loopback + if prefix.Addr().IsLoopback() { + continue + } + // Skip Tailscale CGNAT range (100.64.0.0/10) + if prefix.Addr().IsPrivate() && prefix.Bits() >= 10 { + // Check if it's in the Tailscale CGNAT range + if prefix.Addr().Is4() { + ip := prefix.Addr().As4() + // 100.64.0.0/10 means first byte is 100 and second byte is 64-127 + if ip[0] == 100 && ip[1] >= 64 && ip[1] < 128 { + continue + } + } + } + subnets = append(subnets, prefix) + } + } + return subnets +} + +func peerHasOverlappingEndpoint(peer tailcfg.NodeView, localSubnets []netip.Prefix) bool { + endpoints := peer.Endpoints() + for i := range endpoints.Len() { + epAddrPort := endpoints.At(i) + epAddr := epAddrPort.Addr() + + // Check if this endpoint IP is in any local subnet + for _, localSubnet := range localSubnets { + // Match IP families + if localSubnet.Addr().Is4() != epAddr.Is4() { + continue + } + if localSubnet.Contains(epAddr) { + return true + } + } + } + return false +} + +func isOnSameSubnet(endpointIP netip.Addr, localSubnets []netip.Prefix) bool { + for _, localSubnet := range localSubnets { + // Match IP families + if localSubnet.Addr().Is4() != endpointIP.Is4() { + continue + } + if localSubnet.Contains(endpointIP) { + return true + } + } + return false +} + // TODO(bradfitz): this is all too simplistic and static. It needs to run // continuously in response to netmon events (USB ethernet adapters might get // plugged in) and look for the media type/status/etc. Right now on macOS it |
