Add remote-peer support over Tailscale and unicast probing
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>
This commit is contained in:
parent
5dfaab4018
commit
2c058031fd
12 changed files with 547 additions and 14 deletions
40
README.md
40
README.md
|
|
@ -10,6 +10,10 @@ LocalSend mobile and desktop apps on the same LAN, including their default
|
|||
|
||||
- **Discovery** — multicast announce/listen on `224.0.0.167:53317` plus the HTTP
|
||||
`/register` handshake, with peer aging.
|
||||
- **Remote devices** — reach boxes that aren't on your LAN (multicast can't find
|
||||
them) by probing them directly over unicast. If [Tailscale](https://tailscale.com)
|
||||
is running, online tailnet peers are discovered automatically; you can also add
|
||||
a device by host/IP/name with the `+` key, saved for next time.
|
||||
- **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
|
||||
written to the receive directory, with live progress.
|
||||
- **Send** — pick a peer, then find what to send with a built-in **recursive
|
||||
|
|
@ -47,9 +51,16 @@ curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.
|
|||
```
|
||||
|
||||
This downloads the right binary for your architecture into `~/.local/bin`, and on
|
||||
Omarchy also adds a floating Walker entry (search **Omarchy-Send**). Override the
|
||||
location with `BIN_DIR=/usr/local/bin`, or pin a version with
|
||||
`OMARCHY_SEND_VERSION=v0.1.0`.
|
||||
Omarchy also adds a floating Walker entry (search **Omarchy-Send**) and the
|
||||
Nautilus right-click integration. Override the location with `BIN_DIR=/usr/local/bin`,
|
||||
or pin a version with `OMARCHY_SEND_VERSION=v0.1.0`.
|
||||
|
||||
**Local or remote?** When run interactively the installer asks whether this is a
|
||||
**local** machine (home/LAN) or a **remote server** (public IP). Local installs as
|
||||
above. For a remote server it additionally locks port `53317` to the Tailscale
|
||||
interface in the firewall (`ufw`), so the box is reachable over your tailnet only —
|
||||
not the open internet. Non-interactive installs (e.g. piped `curl | bash`) default
|
||||
to local; force a choice with `OMARCHY_SEND_MODE=local` or `OMARCHY_SEND_MODE=remote`.
|
||||
|
||||
> The installer is a short shell script fetched over HTTPS; read it first if you
|
||||
> prefer — it lives at [`install.sh`](install.sh) in this repo.
|
||||
|
|
@ -111,6 +122,27 @@ Staging a folder sends it whole (its structure is recreated on the receiver).
|
|||
Matching is case-insensitive, and noisy directories (`.git`, `node_modules`,
|
||||
caches, dotfiles…) are skipped to keep the index fast.
|
||||
|
||||
### Remote devices (over Tailscale)
|
||||
|
||||
Multicast discovery only finds peers on the same LAN. To send to / receive from a
|
||||
box elsewhere, omarchy-send probes it directly over unicast — which works over
|
||||
anything routable, [Tailscale](https://tailscale.com) being the easy, secure choice
|
||||
(stable addresses, end-to-end encryption, no port-forwarding):
|
||||
|
||||
1. `tailscale up` on both devices (one-time).
|
||||
2. Either let omarchy-send **auto-discover** online tailnet peers (it probes them
|
||||
every few seconds; any running omarchy-send/LocalSend appears in Devices), or
|
||||
press **`+`** on the Devices tab and enter a host, IP, or Tailscale name (e.g.
|
||||
`colossus`). Added devices are saved to `knownPeers` in the config and re-probed
|
||||
on every launch.
|
||||
|
||||
The receiver already listens on all interfaces, so it's reachable at its Tailscale
|
||||
IP with nothing else to configure. Sending and receiving both work, because the
|
||||
probe is a two-way handshake (each side learns the other).
|
||||
|
||||
> On a box with a public IP, don't leave `53317` open to the internet — install in
|
||||
> **remote** mode (above) to firewall it to the tailnet, and/or set a `--pin`.
|
||||
|
||||
### Right-click send (Nautilus)
|
||||
|
||||
On an Omarchy desktop, the installer adds a **"Send via Omarchy-Send"** entry to
|
||||
|
|
@ -154,7 +186,7 @@ omarchy-send --auto-accept --pin 2468
|
|||
### Keys
|
||||
|
||||
- `1`–`5` or `tab` — switch between Devices / Transfers / Manage / Messages / Settings
|
||||
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `r` refresh · `/` filter
|
||||
- Peers: `enter` send to the selected peer · `m` message · `v` send clipboard · `+` add a remote device · `r` refresh · `/` filter
|
||||
- PIN-protected peers: messages prompt for the PIN and retry, just like file sends
|
||||
- Send finder: type to fuzzy-filter · `enter` stage file/folder · `ctrl+d` folders-only · `ctrl+s` send · `ctrl+u` up a dir · `esc` back
|
||||
- Incoming prompt: `y` accept · `n` reject
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ import (
|
|||
"omarchy-send/internal/discovery"
|
||||
"omarchy-send/internal/notify"
|
||||
"omarchy-send/internal/server"
|
||||
"omarchy-send/internal/tailscale"
|
||||
"omarchy-send/internal/transfer"
|
||||
"omarchy-send/internal/tui"
|
||||
)
|
||||
|
|
@ -34,6 +36,85 @@ type controller struct {
|
|||
sender *client.Sender
|
||||
srv *server.Server
|
||||
notify *atomic.Bool // live gate for desktop notifications (toggled from Settings)
|
||||
rem *remotes // live set of directly-probed (known/remote) hosts
|
||||
}
|
||||
|
||||
// remotes is the live set of hosts probed directly over unicast: known peers
|
||||
// loaded from config plus any added at runtime in the TUI. Guarded because the
|
||||
// watcher goroutine and the controller's AddKnownPeer both touch it.
|
||||
type remotes struct {
|
||||
mu sync.Mutex
|
||||
hosts []string
|
||||
}
|
||||
|
||||
func (r *remotes) list() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.hosts...)
|
||||
}
|
||||
|
||||
// add appends host if not already present, returning true if it was new.
|
||||
func (r *remotes) add(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, h := range r.hosts {
|
||||
if h == host {
|
||||
return false
|
||||
}
|
||||
}
|
||||
r.hosts = append(r.hosts, host)
|
||||
return true
|
||||
}
|
||||
|
||||
// AddKnownPeer registers a remote host and probes it immediately so it shows up
|
||||
// without waiting for the next watcher tick. Persisting it to config is the
|
||||
// TUI's job; this only updates the live set.
|
||||
func (c controller) AddKnownPeer(host string) {
|
||||
if c.rem != nil {
|
||||
c.rem.add(host)
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
_ = c.disc.Probe(ctx, host)
|
||||
}()
|
||||
}
|
||||
|
||||
// watchRemotes periodically probes the known-peer set plus any online Tailscale
|
||||
// peers, so devices that multicast can't reach (different subnet / over the
|
||||
// tailnet) still appear in the list — and age out when they stop answering.
|
||||
func watchRemotes(ctx context.Context, disc *discovery.Discoverer, rem *remotes) {
|
||||
probeAll := func() {
|
||||
seen := map[string]bool{}
|
||||
hosts := rem.list()
|
||||
hosts = append(hosts, tailscale.Peers(ctx)...)
|
||||
for _, h := range hosts {
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
seen[h] = true
|
||||
go func(host string) {
|
||||
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
_ = disc.Probe(pctx, host)
|
||||
}(h)
|
||||
}
|
||||
}
|
||||
probeAll() // immediate, so remotes appear without waiting a tick
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
probeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c controller) Announce() { c.disc.Announce() }
|
||||
|
|
@ -186,7 +267,9 @@ func main() {
|
|||
// only carries the user preference here.
|
||||
notifyOn := &atomic.Bool{}
|
||||
notifyOn.Store(!cfg.NoNotify)
|
||||
ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn}
|
||||
rem := &remotes{hosts: cfg.KnownPeers}
|
||||
ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn, rem: rem}
|
||||
go watchRemotes(ctx, disc, rem)
|
||||
|
||||
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
|
||||
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
|
||||
|
|
@ -222,7 +305,9 @@ func runQuickSend(cfg config.Config, paths []string) int {
|
|||
|
||||
// No receiver in quick-send mode, so nothing to notify about.
|
||||
notifyOff := &atomic.Bool{}
|
||||
ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff}
|
||||
rem := &remotes{hosts: cfg.KnownPeers}
|
||||
ctrl := controller{disc: disc, sender: sender, srv: nil, notify: notifyOff, rem: rem}
|
||||
go watchRemotes(ctx, disc, rem) // so a remote box is a valid quick-send target too
|
||||
|
||||
p := tea.NewProgram(tui.New(cfg, ctrl, tui.WithStagedFiles(paths)), tea.WithAltScreen())
|
||||
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
|
||||
|
|
|
|||
102
install.sh
102
install.sh
|
|
@ -14,11 +14,15 @@
|
|||
# Environment overrides:
|
||||
# BIN_DIR=/usr/local/bin install location (default ~/.local/bin)
|
||||
# OMARCHY_SEND_VERSION=v0.1.0 pin a release (default: latest)
|
||||
# OMARCHY_SEND_MODE=local|remote skip the local/remote prompt (default: ask,
|
||||
# or local when non-interactive)
|
||||
#
|
||||
# Behaviour:
|
||||
# - Headless system: installs the plain `omarchy-send` TUI binary.
|
||||
# - Omarchy desktop: additionally adds a Walker entry that launches it as a
|
||||
# floating TUI (via the stock TUI.float app-id), like the Wi-Fi TUI.
|
||||
# - Local machine (home/LAN): installs the TUI; on Omarchy also adds a Walker
|
||||
# entry + the Nautilus right-click integration.
|
||||
# - Remote server (public IP): same install, then restricts port 53317 to the
|
||||
# Tailscale interface in the firewall, so it's reachable over the tailnet
|
||||
# only — not the open internet.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -27,6 +31,7 @@ BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
|||
APP_DIR="$HOME/.local/share/applications"
|
||||
BIN="$BIN_DIR/omarchy-send"
|
||||
VERSION="${OMARCHY_SEND_VERSION:-latest}"
|
||||
PORT=53317
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
|
|
@ -36,6 +41,30 @@ if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
|
|||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
fi
|
||||
|
||||
# ---- local vs remote -----------------------------------------------------
|
||||
# A remote (public-IP) server should not expose the transfer port to the
|
||||
# internet. Ask once; default to "local" when non-interactive (e.g. piped
|
||||
# `curl | bash` with no terminal) so a firewall is never changed without intent.
|
||||
# Reads /dev/tty so the prompt still works under curl|bash.
|
||||
MODE="${OMARCHY_SEND_MODE:-}"
|
||||
case "$MODE" in
|
||||
local | remote) : ;; # explicit override, don't ask
|
||||
*)
|
||||
MODE="local"
|
||||
# Try to open the controlling terminal read-write on fd 3. A bare -r test
|
||||
# isn't enough: /dev/tty can exist yet fail to open (no controlling tty —
|
||||
# cron/CI/piped). Only prompt when the open actually succeeds.
|
||||
if { exec 3<>/dev/tty; } 2>/dev/null; then
|
||||
printf 'Install type — [L]ocal machine (home/LAN) or [r]emote server (public IP)? [L/r] ' >&3 || true
|
||||
IFS= read -r _ans <&3 || _ans=""
|
||||
exec 3>&- 3<&- || true
|
||||
case "$_ans" in
|
||||
r | R | remote | Remote | REMOTE) MODE="remote" ;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---- obtain the binary ---------------------------------------------------
|
||||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
|
||||
echo "==> Building omarchy-send from source..."
|
||||
|
|
@ -227,6 +256,73 @@ else
|
|||
echo "==> Headless system — installed as a plain TUI."
|
||||
fi
|
||||
|
||||
# ---- firewall posture ----------------------------------------------------
|
||||
# Shared by the remote-mode lockdown below and the local-mode public-IP warning.
|
||||
#
|
||||
# Tailscale interface: usually tailscale0, but absent when tailscaled runs in
|
||||
# userspace-networking mode (the default inside containers) — don't hardcode it.
|
||||
TS_IFACE="$(ip -o link show 2>/dev/null | grep -oE 'tailscale[0-9]+' | head -n1)"
|
||||
|
||||
# Container? Under Docker host-networking the port binds the *host's* stack, and
|
||||
# the firewall belongs on the host, not in this namespace.
|
||||
IN_CONTAINER=0
|
||||
if [ -f /.dockerenv ] || grep -qaE 'docker|containerd|kubepods' /proc/1/cgroup 2>/dev/null; then
|
||||
IN_CONTAINER=1
|
||||
fi
|
||||
|
||||
# ---- remote server: restrict the port to the Tailscale network -----------
|
||||
# On a public-IP box, port 53317 would otherwise be reachable from the internet
|
||||
# (the receiver binds all interfaces). Lock it to the Tailscale interface so it
|
||||
# only answers over the tailnet. Multicast LAN discovery is link-local and never
|
||||
# routes off-LAN, so nothing else needs opening. Inside a container the firewall
|
||||
# can't be applied from here — userspace-networking has no tailscale0 and host-
|
||||
# networking puts the bind on the host's stack — so we detect that and say so.
|
||||
if [ "$MODE" = "remote" ]; then
|
||||
echo "==> Remote server — restricting port $PORT to the Tailscale network."
|
||||
|
||||
if [ "$IN_CONTAINER" = "1" ] && [ -z "$TS_IFACE" ]; then
|
||||
# Container + userspace-networking Tailscale: no tailscale0, and typically no
|
||||
# CAP_NET_ADMIN to manage netfilter. A firewall can't be applied from in here.
|
||||
echo " Detected: inside a container with userspace-networking Tailscale"
|
||||
echo " (no tailscale0 interface). The receiver binds all interfaces — and under"
|
||||
echo " Docker host-networking that includes the host's PUBLIC interface."
|
||||
echo
|
||||
echo " A firewall CANNOT be applied from in here. Apply it on the HOST:"
|
||||
echo " • if the host already default-denies inbound (e.g. only 22/80/443 open),"
|
||||
echo " $PORT is already blocked from the internet yet still reachable over the"
|
||||
echo " tailnet (tailscaled delivers it via loopback) — nothing more to do."
|
||||
echo " • otherwise, on the host run: ufw deny $PORT"
|
||||
echo " Strongly recommended in this setup: also set a PIN (--pin <code>)."
|
||||
elif [ -n "$TS_IFACE" ] && command -v ufw >/dev/null 2>&1; then
|
||||
SUDO=""
|
||||
[ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
echo " Tailscale interface: $TS_IFACE"
|
||||
echo " Applying firewall rules (may prompt for sudo):"
|
||||
echo " ${SUDO:+$SUDO }ufw allow in on $TS_IFACE to any port $PORT"
|
||||
echo " ${SUDO:+$SUDO }ufw deny $PORT"
|
||||
if $SUDO ufw allow in on "$TS_IFACE" to any port "$PORT" >/dev/null 2>&1 &&
|
||||
$SUDO ufw deny "$PORT" >/dev/null 2>&1; then
|
||||
echo " Done — $PORT answers over Tailscale only."
|
||||
else
|
||||
echo " Could not apply automatically (need root/sudo). Run the two commands above yourself."
|
||||
fi
|
||||
else
|
||||
if [ -z "$TS_IFACE" ]; then
|
||||
echo " NOTE: no tailscale interface found. If tailscale isn't up yet, install it"
|
||||
echo " and run 'tailscale up', then re-run this installer. If it's running"
|
||||
echo " in userspace-networking mode, firewall the port on the host instead."
|
||||
TS_IFACE="tailscale0"
|
||||
fi
|
||||
echo " ufw not found. Apply the equivalent in your firewall:"
|
||||
echo " • allow inbound TCP $PORT only on the '$TS_IFACE' interface"
|
||||
echo " • deny inbound $PORT on all other interfaces"
|
||||
echo " nftables example (inet filter, input chain):"
|
||||
echo " iifname \"$TS_IFACE\" tcp dport $PORT accept"
|
||||
echo " tcp dport $PORT drop"
|
||||
fi
|
||||
echo " Tip: a PIN adds a second layer — run with --pin <code> (or set it in Settings)."
|
||||
fi
|
||||
|
||||
echo
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*) : ;;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ type Config struct {
|
|||
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals)
|
||||
NoNotify bool `json:"noNotify"` // don't raise desktop notifications on incoming messages/files
|
||||
|
||||
// KnownPeers are hosts (name, IP, or host:port) probed directly over unicast
|
||||
// so peers off the local subnet — e.g. reached over Tailscale — show up even
|
||||
// though multicast discovery can't find them.
|
||||
KnownPeers []string `json:"knownPeers,omitempty"`
|
||||
|
||||
// TLS identity for encrypted (HTTPS) mode, generated once and persisted.
|
||||
CertPEM string `json:"certPem"`
|
||||
KeyPEM string `json:"keyPem"`
|
||||
|
|
|
|||
|
|
@ -295,6 +295,61 @@ func (d *Discoverer) reply(ip string, port int, proto string) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Probe contacts host directly over unicast — bypassing multicast — and records
|
||||
// it as a peer on success. It POSTs our info to the peer's /register (so the
|
||||
// peer also learns us) and reads the peer's info from the reply. host may carry
|
||||
// a port; otherwise the default LocalSend port is used. https is tried first,
|
||||
// then http. Used for known/remote peers (e.g. reached over Tailscale) that
|
||||
// multicast can't find. Re-probing a live peer refreshes its LastSeen so it is
|
||||
// not reaped; a peer that stops answering ages out normally.
|
||||
func (d *Discoverer) Probe(ctx context.Context, host string) error {
|
||||
h, port := hostPort(host)
|
||||
body, err := json.Marshal(d.selfCopy().WithAnnounce(false))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastErr error
|
||||
for _, scheme := range []string{"https", "http"} {
|
||||
url := fmt.Sprintf("%s://%s/api/localsend/v2/register", scheme, net.JoinHostPort(h, strconv.Itoa(port)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
var info protocol.DeviceInfo
|
||||
derr := json.NewDecoder(resp.Body).Decode(&info)
|
||||
_ = resp.Body.Close()
|
||||
if derr != nil || info.Fingerprint == "" {
|
||||
lastErr = fmt.Errorf("probe %s: no usable device info", url)
|
||||
continue
|
||||
}
|
||||
dbg.Logf("probe %s -> alias=%q fp=%s", url, info.Alias, info.Fingerprint)
|
||||
d.NotePeer(info, h) // reach it back at the host we dialed
|
||||
return nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("could not reach %s", host)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// hostPort splits an optional :port off host, defaulting to the LocalSend port.
|
||||
// It handles bare IPv6 by requiring the [::]:port form for a custom port.
|
||||
func hostPort(host string) (string, int) {
|
||||
if h, p, err := net.SplitHostPort(host); err == nil {
|
||||
if n, err := strconv.Atoi(p); err == nil {
|
||||
return h, n
|
||||
}
|
||||
return h, protocol.DefaultPort
|
||||
}
|
||||
return host, protocol.DefaultPort
|
||||
}
|
||||
|
||||
// NotePeer records a peer (from multicast or from an inbound /register) and
|
||||
// emits PeerFound on first sight or when its address changes. Safe for
|
||||
// concurrent use; ignores our own fingerprint.
|
||||
|
|
|
|||
64
internal/discovery/probe_test.go
Normal file
64
internal/discovery/probe_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"omarchy-send/internal/protocol"
|
||||
)
|
||||
|
||||
// TestProbeRegistersPeer drives Probe against a stub /register that behaves like
|
||||
// a real peer: it records the caller and returns its own DeviceInfo. The https
|
||||
// attempt fails against the plain-http test server and Probe falls back to http.
|
||||
func TestProbeRegistersPeer(t *testing.T) {
|
||||
peerInfo := protocol.DeviceInfo{Alias: "Remote", Fingerprint: "remote-fp", Port: 53317, Protocol: "http"}
|
||||
var sawOurInfo bool
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/localsend/v2/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in protocol.DeviceInfo
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err == nil && in.Fingerprint == "self-fp" {
|
||||
sawOurInfo = true
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(peerInfo)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "http://") // host:port
|
||||
|
||||
d := New(protocol.DeviceInfo{Fingerprint: "self-fp", Alias: "Self"})
|
||||
if err := d.Probe(context.Background(), host); err != nil {
|
||||
t.Fatalf("Probe failed: %v", err)
|
||||
}
|
||||
if !sawOurInfo {
|
||||
t.Error("peer did not receive our device info in the register body")
|
||||
}
|
||||
peers := d.Snapshot()
|
||||
if len(peers) != 1 || peers[0].Info.Fingerprint != "remote-fp" {
|
||||
t.Fatalf("peer not recorded as expected: %+v", peers)
|
||||
}
|
||||
if wantIP := strings.Split(host, ":")[0]; peers[0].IP != wantIP {
|
||||
t.Errorf("peer IP = %q, want %q (the host we dialed)", peers[0].IP, wantIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUnreachableErrors(t *testing.T) {
|
||||
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
|
||||
// 127.0.0.1:1 — nothing listening; both https and http should fail fast.
|
||||
if err := d.Probe(context.Background(), "127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected an error probing an unreachable host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostPortDefaults(t *testing.T) {
|
||||
if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort {
|
||||
t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort)
|
||||
}
|
||||
if h, p := hostPort("100.64.0.2:9999"); h != "100.64.0.2" || p != 9999 {
|
||||
t.Errorf("hostPort(host:port) = %q,%d; want 100.64.0.2,9999", h, p)
|
||||
}
|
||||
}
|
||||
77
internal/tailscale/tailscale.go
Normal file
77
internal/tailscale/tailscale.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// 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
|
||||
}
|
||||
46
internal/tailscale/tailscale_test.go
Normal file
46
internal/tailscale/tailscale_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package tailscale
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePeersOnlineIPv4Only(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"Peer": {
|
||||
"key1": {"TailscaleIPs": ["100.64.0.1", "fd7a:115c::1"], "Online": true},
|
||||
"key2": {"TailscaleIPs": ["100.64.0.2"], "Online": false},
|
||||
"key3": {"TailscaleIPs": ["fd7a:115c::3"], "Online": true},
|
||||
"key4": {"TailscaleIPs": ["100.64.0.4"], "Online": true}
|
||||
}
|
||||
}`)
|
||||
got := parsePeers(data)
|
||||
sort.Strings(got)
|
||||
want := []string{"100.64.0.1", "100.64.0.4"} // online + has IPv4; offline and v6-only excluded
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parsePeers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePeersBadJSON(t *testing.T) {
|
||||
if got := parsePeers([]byte("not json")); got != nil {
|
||||
t.Errorf("bad JSON should yield nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIPv4(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"100.64.0.1": true,
|
||||
"1.2.3.4": true,
|
||||
"fd7a:115c::1": false,
|
||||
"1.2.3": false,
|
||||
"": false,
|
||||
"abc": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := isIPv4(in); got != want {
|
||||
t.Errorf("isIPv4(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,15 +64,15 @@ func TestManageDeleteSingleViaConfirm(t *testing.T) {
|
|||
m, dir := manageModel(t, "keep.txt", "drop.txt")
|
||||
// Cursor starts on the newest (drop.txt was written last). Marking it and
|
||||
// confirming should remove exactly that file.
|
||||
m = key(m, " ") // mark file under cursor
|
||||
m = key(m, " ") // mark file under cursor
|
||||
if len(m.marked) != 1 {
|
||||
t.Fatalf("expected 1 marked, got %d", len(m.marked))
|
||||
}
|
||||
m = key(m, "d") // request delete -> confirm card
|
||||
m = key(m, "d") // request delete -> confirm card
|
||||
if !m.confirmDel {
|
||||
t.Fatal("expected confirm card to be showing")
|
||||
}
|
||||
m = key(m, "y") // confirm
|
||||
m = key(m, "y") // confirm
|
||||
if m.confirmDel {
|
||||
t.Error("confirm card should be dismissed after delete")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type Controller interface {
|
|||
SetReceiveDir(string)
|
||||
SetPIN(string)
|
||||
SetNotify(bool)
|
||||
AddKnownPeer(host string) // probe a remote host directly (off-LAN / Tailscale)
|
||||
}
|
||||
|
||||
type screen int
|
||||
|
|
@ -123,6 +124,10 @@ type Model struct {
|
|||
sendPaths []string
|
||||
pendingMsg string
|
||||
|
||||
// Add-remote-peer modal (Devices tab): host/IP/Tailscale-name entry.
|
||||
addingPeer bool
|
||||
peerInput textinput.Model
|
||||
|
||||
// Messages tab + compose modal.
|
||||
msgList list.Model
|
||||
messages []server.ReceivedMessage
|
||||
|
|
@ -199,6 +204,11 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
|
|||
compose.CharLimit = 2000
|
||||
compose.Width = 48
|
||||
|
||||
peerInput := textinput.New()
|
||||
peerInput.Placeholder = "host, IP, or Tailscale name"
|
||||
peerInput.CharLimit = 256
|
||||
peerInput.Width = 48
|
||||
|
||||
mkInput := func(placeholder string, limit int) textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
|
|
@ -229,6 +239,7 @@ func New(cfg config.Config, ctrl Controller, opts ...Option) Model {
|
|||
editInputs: editInputs,
|
||||
msgList: ml,
|
||||
composeInput: compose,
|
||||
peerInput: peerInput,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&m)
|
||||
|
|
@ -321,6 +332,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if m.composing {
|
||||
return m.updateCompose(msg)
|
||||
}
|
||||
if m.addingPeer {
|
||||
return m.updateAddPeer(msg)
|
||||
}
|
||||
if m.readingMsg != nil {
|
||||
switch msg.String() {
|
||||
case "esc", "q", "enter":
|
||||
|
|
@ -387,6 +401,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.refreshManage()
|
||||
}
|
||||
return m, nil
|
||||
case "+":
|
||||
// Add a remote device by host/IP/Tailscale-name (off-LAN peer).
|
||||
if m.screen == screenPeers {
|
||||
m.addingPeer = true
|
||||
m.peerInput.SetValue("")
|
||||
m.peerInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
case "r":
|
||||
if m.screen == screenPeers && m.ctrl != nil {
|
||||
m.ctrl.Announce()
|
||||
|
|
@ -547,6 +570,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
|
||||
if m.addingPeer {
|
||||
var cmd tea.Cmd
|
||||
m.peerInput, cmd = m.peerInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.pending == nil && m.screen == screenPicker {
|
||||
var cmd tea.Cmd
|
||||
m.fzfQuery, cmd = m.fzfQuery.Update(msg)
|
||||
|
|
@ -597,6 +625,33 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
// updateAddPeer handles the add-remote-device modal. On enter it persists the
|
||||
// host to config's known-peers and asks the controller to probe it now.
|
||||
func (m Model) updateAddPeer(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.addingPeer = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
host := strings.TrimSpace(m.peerInput.Value())
|
||||
if host != "" {
|
||||
if !contains(m.cfg.KnownPeers, host) {
|
||||
m.cfg.KnownPeers = append(m.cfg.KnownPeers, host)
|
||||
_ = m.cfg.Save()
|
||||
}
|
||||
if m.ctrl != nil {
|
||||
m.ctrl.AddKnownPeer(host)
|
||||
}
|
||||
m.notice = "added remote " + host + " — probing…"
|
||||
}
|
||||
m.addingPeer = false
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.peerInput, cmd = m.peerInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// deleteSelectedMessage drops the highlighted message from the list.
|
||||
func (m *Model) deleteSelectedMessage() {
|
||||
it, ok := m.msgList.SelectedItem().(msgItem)
|
||||
|
|
@ -848,6 +903,9 @@ func (m Model) View() string {
|
|||
if m.composing {
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.composeView()))
|
||||
}
|
||||
if m.addingPeer {
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.addPeerView()))
|
||||
}
|
||||
if m.readingMsg != nil {
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, cardStyle.Render(m.readMessageView()))
|
||||
}
|
||||
|
|
@ -984,6 +1042,18 @@ func (m Model) pinView() string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) addPeerView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Add remote device"))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(headerStyle.Render("A device off your LAN — e.g. a Tailscale name or IP.\nIt's probed directly (no multicast) and saved."))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.peerInput.View())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(footerStyle.Render("enter add · esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) acceptView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Incoming files"))
|
||||
|
|
@ -1233,6 +1303,8 @@ func (m Model) footerText() string {
|
|||
return m.notice
|
||||
case m.composing:
|
||||
return "enter send · esc cancel"
|
||||
case m.addingPeer:
|
||||
return "enter add remote · esc cancel"
|
||||
case m.readingMsg != nil:
|
||||
return "y copy · esc/enter close"
|
||||
case m.confirmDel:
|
||||
|
|
@ -1244,7 +1316,7 @@ func (m Model) footerText() string {
|
|||
case m.screen == screenPeers && m.quickSend:
|
||||
return fmt.Sprintf("enter send %d item(s) to selected device · r refresh · q cancel", len(m.staged))
|
||||
case m.screen == screenPeers:
|
||||
return "enter send-to · m message · v send-clipboard · r refresh · / filter · 1-5 · q quit"
|
||||
return "enter send-to · m message · v clipboard · + add remote · r refresh · / filter · q quit"
|
||||
case m.screen == screenTransfers:
|
||||
return "c clear finished · 1-5 switch · q quit"
|
||||
case m.screen == screenManage:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func (f *fakeCtrl) SetAlias(string) {}
|
|||
func (f *fakeCtrl) SetReceiveDir(string) {}
|
||||
func (f *fakeCtrl) SetPIN(string) {}
|
||||
func (f *fakeCtrl) SetNotify(bool) {}
|
||||
func (f *fakeCtrl) AddKnownPeer(string) {}
|
||||
|
||||
// writeTree lays out a small fixture tree under a temp dir for walkIndex tests.
|
||||
func writeTree(t *testing.T) string {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ type fileItem struct {
|
|||
}
|
||||
|
||||
func (i fileItem) Title() string { return i.name }
|
||||
func (i fileItem) Description() string { return "" }
|
||||
func (i fileItem) Description() string { return "" }
|
||||
func (i fileItem) FilterValue() string { return i.name }
|
||||
|
||||
// receivedFiles lists the top-level entries in dir, newest first. In-progress
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue