Multicast only finds peers on the same LAN. Reach off-LAN boxes by probing them directly over unicast (works over any routable address; Tailscale is the easy, secure choice): - discovery.Probe: unicast POST /register (https->http fallback) with a two-way handshake, so send and receive both work; offline peers age out. - internal/tailscale: Peers() shells `tailscale status --json` for online peers. - config.KnownPeers: persisted manual remotes. - main.go: watchRemotes goroutine probes knownPeers ∪ tailscale peers every 10s, in both the normal TUI path and quick-send. - TUI: `+` on Devices opens an add-remote modal (host/IP/Tailscale name). - install.sh: interactive local/remote install prompt; remote mode locks port 53317 to the Tailscale interface (ufw), with container/userspace-networking detection. README documents remote devices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
77 lines
2 KiB
Go
77 lines
2 KiB
Go
// Package tailscale discovers peer addresses from a local Tailscale daemon, so
|
|
// omarchy-send can reach devices that share a tailnet but not a LAN subnet (and
|
|
// therefore can't be found by multicast). It shells out to the `tailscale` CLI
|
|
// and is a no-op when that isn't present.
|
|
package tailscale
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os/exec"
|
|
)
|
|
|
|
// status is the subset of `tailscale status --json` we care about.
|
|
type status struct {
|
|
Peer map[string]struct {
|
|
TailscaleIPs []string `json:"TailscaleIPs"`
|
|
Online bool `json:"Online"`
|
|
} `json:"Peer"`
|
|
}
|
|
|
|
// Available reports whether the tailscale CLI is on PATH.
|
|
func Available() bool {
|
|
_, err := exec.LookPath("tailscale")
|
|
return err == nil
|
|
}
|
|
|
|
// Peers returns the IPv4 Tailscale address of each online peer in the tailnet.
|
|
// It returns nil (no error) when tailscale isn't installed, isn't running, or
|
|
// the output can't be parsed — callers treat Tailscale discovery as best-effort.
|
|
func Peers(ctx context.Context) []string {
|
|
if !Available() {
|
|
return nil
|
|
}
|
|
out, err := exec.CommandContext(ctx, "tailscale", "status", "--json").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return parsePeers(out)
|
|
}
|
|
|
|
// parsePeers extracts each online peer's first IPv4 address from the JSON of
|
|
// `tailscale status --json`. Split out so it can be tested without the CLI.
|
|
func parsePeers(data []byte) []string {
|
|
var st status
|
|
if err := json.Unmarshal(data, &st); err != nil {
|
|
return nil
|
|
}
|
|
var hosts []string
|
|
for _, p := range st.Peer {
|
|
if !p.Online {
|
|
continue
|
|
}
|
|
for _, ip := range p.TailscaleIPs {
|
|
if isIPv4(ip) {
|
|
hosts = append(hosts, ip)
|
|
break // one address per peer is enough to probe
|
|
}
|
|
}
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
// isIPv4 reports whether s looks like a dotted-quad (cheap check — avoids
|
|
// pulling in net just to skip the IPv6 entries Tailscale also reports).
|
|
func isIPv4(s string) bool {
|
|
dots := 0
|
|
for _, c := range s {
|
|
switch {
|
|
case c == '.':
|
|
dots++
|
|
case c >= '0' && c <= '9':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return dots == 3
|
|
}
|