Compare commits

...

8 commits
v0.1.9 ... main

Author SHA1 Message Date
39280e5590 README: clarify not affiliated with or endorsed by LocalSend
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:30:37 +01:00
d68421ac97 install.sh: CLI file sends + OSF shorthand in agent context
The installed AGENTS.md / CLAUDE.md now document the headless file-send
form and teach agents that "OSF" is user shorthand for omarchy-send
("OSF report.pdf to gav" → omarchy-send -to gav report.pdf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:25 +01:00
2342beff10 Send files and folders headlessly with -to
`omarchy-send -to <alias> <paths…>` now sends files without the TUI —
from scripts, cron, or AI agents — alongside or instead of -message.
Folders are sent whole with their structure recreated on the receiver,
and a result line is printed per file. Paths without -to still open the
TUI quick-send as before.

The new SendFilesSync mirrors SendMessageSync: it blocks until the batch
lands and returns errors directly (including ErrPinRequired). A missing
path is a hard error up front — a script wants a non-zero exit, not a
silent skip — while a file that vanishes mid-batch is skipped and
reported, like the TUI path. The -dir override also rides through the
new config.ExpandHome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:25 +01:00
a93cdcaa83 Expand ~ in receiveDir everywhere it becomes a filesystem path
A receiveDir stored as "~/Omarchy-Send" (hand-edited, or typed into the
Settings tab) was treated as a relative path, so the receiver silently
created a literal "~" directory under its cwd and wrote incoming files
there. The TUI expanded ~ for display only, which hid the problem.

The canonical ExpandHome now lives in config and is applied in Load
(normalised value is persisted back), in the Settings-tab save, and by
the TUI's display helper, which now delegates to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:13 +01:00
38f057db67 install.sh: offer to enable tailscaled's SOCKS5 proxy ([y/N])
When userspace-networking Tailscale is detected with no proxy at
localhost:1055, don't just print the fix — offer to apply it: patch the
flag into any writable launcher script that starts tailscaled (e.g. a
container entrypoint, so it persists across restarts) and restart the
daemon with its existing flags plus --socks5-server. When the installer
lacks the rights to restart it (tailscaled is usually root's), it
patches the launcher and says to restart the container/box instead.

Defaults to no, and non-interactive installs never touch the daemon;
OMARCHY_SEND_FIX_TAILSCALE=yes|no pre-answers the prompt. The restart
warning notes it briefly drops the tailnet, including tailscale SSH.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:07:33 +01:00
ee9060e6d0 install.sh: don't die when no tailscale interface exists
Under set -euo pipefail, the TS_IFACE detection pipeline killed the
whole script when grep matched nothing — i.e. on every box without a
tailscaleN interface (containers under userspace networking, or no
tailscale at all). Everything after the agent-context step was silently
skipped: firewall advice, the public-IP warning, the new proxy check.
Latent since v0.1.8; first surfaced on a real container install. Guard
the receiveDir extraction the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +01:00
05e70f127d install.sh: userspace-proxy check + CLI-send agent context
Detect a userspace-networking tailscaled with no SOCKS5 proxy at
localhost:1055 and print exactly how to enable it (the box can receive
but not send to the tailnet until then); confirm when the proxy is
already there.

Teach the installed agent context (AGENTS.md + the managed ~/.claude/
CLAUDE.md section) that messages can be SENT from the CLI — agents knew
where received files land but not that omarchy-send -to/-message exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:14 +01:00
7d6e339dcb Route tailnet traffic via tailscaled's SOCKS5 proxy on userspace boxes
Under tailscaled --tun=userspace-networking (no TUN device — the default
in unprivileged containers), processes cannot dial tailnet addresses at
all: inbound connections are proxied to loopback by tailscaled, but
outbound 100.64.0.0/10 dials have no route. omarchy-send could receive
on such boxes but never send, probe, or discover over the tailnet.

New internal/tsproxy auto-detects this: when no local interface carries
a tailnet address but a SOCKS5 proxy answers at localhost:1055 (the
conventional --socks5-server address), tailnet-bound connections route
through it. LAN traffic is never proxied; explicit HTTPS_PROXY/
HTTP_PROXY/NO_PROXY win over detection, restoring the env-var support
the custom transports had silently dropped.

Also guard NotePeer against the loopback trap the same proxying causes:
inbound registers all appear to come from 127.0.0.1, and recording that
over a peer's routable address made a TUI "send" loop back to our own
receiver while looking delivered.

Live-verified from an omaterm container (userspace tailscaled): headless
send discovered and messaged a tailnet peer with no env vars set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:14 +01:00
12 changed files with 792 additions and 51 deletions

View file

@ -101,24 +101,30 @@ omarchy-send --no-notify # don't raise desktop notifications on incomin
### Headless send (no TUI) ### Headless send (no TUI)
Send a one-off message to a peer by name, with no terminal UI — handy from Send a one-off message, files, or folders to a peer by name, with no terminal
scripts, cron, or an SSH session with no TTY: UI — handy from scripts, cron, AI agents, or an SSH session with no TTY:
```sh ```sh
omarchy-send --to "Strong Onion" --message "hello" omarchy-send --to "Strong Onion" --message "hello"
omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s
omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer requires a PIN omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer requires a PIN
omarchy-send --to "Strong Onion" report.pdf photos/ # files and folders
omarchy-send --to "Strong Onion" --message "build log attached" build.log
``` ```
The target is matched against the peer's display name, case-insensitively. The The target is matched against the peer's display name, case-insensitively. The
command discovers the peer over multicast and, like the TUI, directly probes command discovers the peer over multicast and, like the TUI, directly probes
your known peers and online Tailscale peers (waiting up to `--wait`, default your known peers and online Tailscale peers (waiting up to `--wait`, default
15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a 15s) — so a remote box added with `+` in the TUI, or any tailnet peer, is a
valid `--to` target from a script too. It sends the message, prints a one-line valid `--to` target from a script too. It sends the message and/or files,
result, and exits non-zero if the peer isn't found or the send fails. It starts prints a result line per file, and exits non-zero if the peer isn't found or
discovery only — not the receiver — so it's safe to run while another the send fails. It starts discovery only — not the receiver — so it's safe to
`omarchy-send` instance is up. Both `--to` and `--message` are required; file run while another `omarchy-send` instance is up.
sending stays in the TUI for now.
`--to` requires a `--message`, file/folder paths, or both (flags must come
before the paths). A folder is sent whole and its structure recreated on the
receiver. Paths *without* `--to` instead open the TUI with them pre-staged
(quick-send) — that form needs a TTY.
### Sending files ### Sending files
@ -153,6 +159,32 @@ The receiver already listens on all interfaces, so it's reachable at its Tailsca
IP with nothing else to configure. Sending and receiving both work, because the IP with nothing else to configure. Sending and receiving both work, because the
probe is a two-way handshake (each side learns the other). probe is a two-way handshake (each side learns the other).
#### Containers / userspace-networking tailscaled
When tailscaled runs with `--tun=userspace-networking` (typical in unprivileged
containers — no TUN device), processes cannot dial tailnet addresses directly;
outbound tailnet traffic only works through tailscaled's built-in SOCKS5 proxy.
omarchy-send handles this automatically: when the box has no tailnet address on
a local interface but a proxy answers at `localhost:1055`, connections to
`100.64.0.0/10` destinations are routed through it — LAN traffic is never
proxied, and explicit `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables override
the auto-detection. The one thing the box must provide is the proxy itself:
```sh
tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …
```
The installer detects this situation and **offers to apply the fix** ([y/N]):
it adds the flag to the launcher script that starts tailscaled (a container
entrypoint, say) and restarts the daemon — or, when it lacks the rights to
restart it, patches the launcher and tells you to restart the container/box.
Non-interactive installs never touch the daemon; pre-answer with
`OMARCHY_SEND_FIX_TAILSCALE=yes` (or `no`) to skip the prompt.
Note that inbound connections on such boxes appear to come from `127.0.0.1`
(tailscaled re-dials loopback); omarchy-send keeps a peer's routable address
rather than letting those registers overwrite it.
#### Public-IP boxes: firewall the port #### Public-IP boxes: firewall the port
The receiver binds **all interfaces**, so on a box with a public IP, port `53317` The receiver binds **all interfaces**, so on a box with a public IP, port `53317`
@ -266,5 +298,5 @@ desktop client and are not controlled by the sender.
MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of MIT — see [LICENSE](LICENSE). Omarchy-Send is an independent implementation of
the published [LocalSend protocol](https://github.com/localsend/protocol); it is the published [LocalSend protocol](https://github.com/localsend/protocol); it is
not affiliated with the LocalSend project. The terminal UI is built on the not affiliated with or endorsed by the LocalSend project. The terminal UI is built on the
[Charm](https://github.com/charmbracelet) libraries (also MIT). [Charm](https://github.com/charmbracelet) libraries (also MIT).

View file

@ -162,7 +162,7 @@ func main() {
noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files") noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files")
// Headless one-shot send (no TUI): -to <alias> -message <text>. // Headless one-shot send (no TUI): -to <alias> -message <text>.
toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); requires -message") toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); combine with -message and/or file paths")
messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to") messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to")
sendPINFlag = flag.String("send-pin", "", "headless send: PIN to present if the target peer requires one") sendPINFlag = flag.String("send-pin", "", "headless send: PIN to present if the target peer requires one")
waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered") waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered")
@ -187,7 +187,7 @@ func main() {
cfg.Port = *portFlag cfg.Port = *portFlag
} }
if *dirFlag != "" { if *dirFlag != "" {
cfg.ReceiveDir = *dirFlag cfg.ReceiveDir = config.ExpandHome(*dirFlag)
} }
if *pinFlag != "" { if *pinFlag != "" {
cfg.PIN = *pinFlag cfg.PIN = *pinFlag
@ -202,29 +202,27 @@ func main() {
cfg.NoNotify = true cfg.NoNotify = true
} }
// Headless one-shot send: resolve the target by alias over discovery, send, // Headless one-shot send: resolve the target by alias over discovery, send
// and exit — no TUI, no terminal required. Suitable for scripts and cron. // a message and/or positional file/folder paths, and exit — no TUI, no
// terminal required. Suitable for scripts, cron, and AI agents.
if *toFlag != "" || *messageFlag != "" { if *toFlag != "" || *messageFlag != "" {
if *toFlag == "" || *messageFlag == "" { paths := absPaths(flag.Args())
fmt.Fprintln(os.Stderr, "headless send needs both -to <alias> and -message <text>") if *toFlag == "" {
fmt.Fprintln(os.Stderr, "headless send needs -to <alias> (plus -message <text> and/or file paths)")
os.Exit(2) os.Exit(2)
} }
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag)) if *messageFlag == "" && len(paths) == 0 {
fmt.Fprintln(os.Stderr, "headless send needs -message <text>, file/folder paths, or both")
os.Exit(2)
}
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag, paths))
} }
// Quick-send: any positional arguments are file/folder paths to send (the // Quick-send: any positional arguments are file/folder paths to send (the
// Nautilus right-click integration calls `omarchy-send <paths…>`). Open the // Nautilus right-click integration calls `omarchy-send <paths…>`). Open the
// TUI with them pre-staged, on the device list. // TUI with them pre-staged, on the device list.
if args := flag.Args(); len(args) > 0 { if args := flag.Args(); len(args) > 0 {
paths := make([]string, 0, len(args)) os.Exit(runQuickSend(cfg, absPaths(args)))
for _, a := range args {
if abs, err := filepath.Abs(a); err == nil {
paths = append(paths, abs)
} else {
paths = append(paths, a)
}
}
os.Exit(runQuickSend(cfg, paths))
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -321,12 +319,27 @@ func runQuickSend(cfg config.Config, paths []string) int {
return 0 return 0
} }
// absPaths resolves each path to absolute (best-effort; a path that fails to
// resolve is passed through as-is and will fail with a clear error later).
func absPaths(args []string) []string {
paths := make([]string, 0, len(args))
for _, a := range args {
if abs, err := filepath.Abs(a); err == nil {
paths = append(paths, abs)
} else {
paths = append(paths, a)
}
}
return paths
}
// runHeadlessSend discovers the peer whose alias matches target (case- // runHeadlessSend discovers the peer whose alias matches target (case-
// insensitively), sends it a plain-text message, and returns a process exit // insensitively), sends it the given file/folder paths and/or a plain-text
// code. It deliberately starts only discovery — not the HTTP receiver — so it // message, and returns a process exit code. It deliberately starts only
// can run alongside an already-running instance without fighting over the // discovery — not the HTTP receiver — so it can run alongside an
// listen port. Status goes to stderr; the success line goes to stdout. // already-running instance without fighting over the listen port. Status goes
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int { // to stderr; the success lines go to stdout.
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration, paths []string) int {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -364,16 +377,50 @@ func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait ti
} }
sender := client.New(cfg.DeviceInfo()) sender := client.New(cfg.DeviceInfo())
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
reportErr := func(err error) {
switch { switch {
case errors.Is(err, transfer.ErrPinRequired): case errors.Is(err, transfer.ErrPinRequired):
fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias) fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias)
default: default:
fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err) fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err)
} }
return 1
} }
fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP) if len(paths) > 0 {
fmt.Fprintf(os.Stderr, "Sending to %q (%s)… (waiting for the peer to accept)\n", peer.Info.Alias, peer.IP)
sent := 0
err := sender.SendFilesSync(ctx, peer, paths, sendPIN, func(name string, size int64) {
sent++
fmt.Printf(" sent %s (%s)\n", name, humanBytes(size))
})
if err != nil {
reportErr(err)
return 1
}
fmt.Printf("%d file(s) sent to %q (%s).\n", sent, peer.Info.Alias, peer.IP)
}
if message != "" {
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
reportErr(err)
return 1
}
fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP)
}
return 0 return 0
} }
// humanBytes renders a byte count as a short human-readable size.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

View file

@ -269,7 +269,7 @@ mkdir -p "$CFG_DIR"
RECV_DIR="$HOME/Omarchy-Send" RECV_DIR="$HOME/Omarchy-Send"
if [ -f "$CFG_DIR/config.json" ]; then if [ -f "$CFG_DIR/config.json" ]; then
_rd="$(grep -oE '"receiveDir"[[:space:]]*:[[:space:]]*"[^"]*"' "$CFG_DIR/config.json" \ _rd="$(grep -oE '"receiveDir"[[:space:]]*:[[:space:]]*"[^"]*"' "$CFG_DIR/config.json" \
| sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1)" | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' | head -n1 || true)"
[ -n "${_rd:-}" ] && RECV_DIR="$_rd" [ -n "${_rd:-}" ] && RECV_DIR="$_rd"
fi fi
@ -282,6 +282,10 @@ this machine. It speaks the LocalSend protocol, so phones, desktops, and other
servers can send **files** and **plain-text messages** to this machine over the servers can send **files** and **plain-text messages** to this machine over the
local network or a Tailscale tailnet. It can also send outbound. local network or a Tailscale tailnet. It can also send outbound.
**Shorthand:** "OSF" means omarchy-send. When the user says e.g. "OSF
report.pdf to gav" or "OSF that over to the laptop", run the headless CLI send
described below — `omarchy-send -to "<alias>" <paths…>`.
**Where received files live —** incoming files are saved under the *receive **Where received files live —** incoming files are saved under the *receive
directory*: directory*:
@ -305,6 +309,21 @@ foreground TUI, not a background daemon. Start it with:
On a headless box, run it inside a TTY (tmux, or `ssh -t`). It listens on TCP On a headless box, run it inside a TTY (tmux, or `ssh -t`). It listens on TCP
port **53317**. Auto-accept and an optional PIN live in the config / Settings tab. port **53317**. Auto-accept and an optional PIN live in the config / Settings tab.
**You can SEND messages AND files from the CLI** — no TUI, no TTY, works from
scripts and agents. To message another device (e.g. to notify the user on
their desktop), or to send files/folders to it:
omarchy-send -to "<device alias>" -message "<text>"
omarchy-send -to "<device alias>" <file-or-folder>…
omarchy-send -to "<device alias>" -message "<text>" <file>…
Flags must come before the paths. A folder is sent whole (structure recreated
on the receiver). Add `-send-pin <pin>` if the target requires a PIN, and
`-wait 30s` to allow longer for discovery. Works over the LAN and Tailscale
alike; exit code 0 means delivered. The receiving device must be running its
receiver (this TUI, or LocalSend) and may prompt its user to accept. Paths
*without* `-to` open the TUI instead, which needs a TTY.
**Config:** `~/.config/omarchy-send/config.json` **Config:** `~/.config/omarchy-send/config.json`
(keys: `alias`, `receiveDir`, `port`, `autoAccept`, `pin`, `knownPeers`, …). (keys: `alias`, `receiveDir`, `port`, `autoAccept`, `pin`, `knownPeers`, …).
@ -334,7 +353,14 @@ land in `@@RECV_DIR@@`** (authoritative: the `receiveDir` key in
`~/.config/omarchy-send/config.json`). Files still transferring carry a `.part` `~/.config/omarchy-send/config.json`). Files still transferring carry a `.part`
suffix — skip them. Text messages appear in the TUI's Messages tab, not on disk. suffix — skip them. Text messages appear in the TUI's Messages tab, not on disk.
Receiving requires the TUI running (`omarchy-send`; use tmux or `ssh -t` when Receiving requires the TUI running (`omarchy-send`; use tmux or `ssh -t` when
headless). Full notes: `~/.config/omarchy-send/AGENTS.md`. headless). **You can SEND messages and files to another device from the CLI**
(no TUI/TTY, fine for scripts and agents):
`omarchy-send -to "<alias>" -message "<text>"` and/or
`omarchy-send -to "<alias>" <file-or-folder>…` (flags before paths; add
`-send-pin <pin>` if the target requires one); exit 0 = delivered. Works
over LAN and Tailscale. **"OSF" is user shorthand for omarchy-send** — "OSF
<file> to <alias>" means run that CLI send. Full notes:
`~/.config/omarchy-send/AGENTS.md`.
<!-- END omarchy-send (managed by installer) --> <!-- END omarchy-send (managed by installer) -->
BLK BLK
sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$blk" sed -i "s|@@RECV_DIR@@|$RECV_DIR|g" "$blk"
@ -353,7 +379,7 @@ echo " Added an Omarchy-Send section to $CLAUDE_MD."
# #
# Tailscale interface: usually tailscale0, but absent when tailscaled runs in # Tailscale interface: usually tailscale0, but absent when tailscaled runs in
# userspace-networking mode (the default inside containers) — don't hardcode it. # 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)" TS_IFACE="$(ip -o link show 2>/dev/null | grep -oE 'tailscale[0-9]+' | head -n1 || true)"
# Container? Under Docker host-networking the port binds the *host's* stack, and # Container? Under Docker host-networking the port binds the *host's* stack, and
# the firewall belongs on the host, not in this namespace. # the firewall belongs on the host, not in this namespace.
@ -453,6 +479,100 @@ if [ "$MODE" != "remote" ] && [ -n "$PUBLIC_IP" ]; then
echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out" echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out"
fi fi
# ---- userspace-networking Tailscale: outbound proxy check -----------------
# Under tailscaled --tun=userspace-networking (no TUN device — the default in
# unprivileged containers), processes cannot dial OUT to tailnet addresses at
# all; tailscaled's SOCKS5 proxy is the only outbound path. omarchy-send
# auto-detects and uses it at localhost:1055 — so check it's there and say
# exactly what to do when it isn't (receive works either way; send doesn't).
if command -v tailscale >/dev/null 2>&1 && [ -z "$TS_IFACE" ] \
&& tailscale status >/dev/null 2>&1; then
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then
exec 3>&- || true
echo
echo "==> Userspace-networking Tailscale detected; SOCKS5 proxy found at"
echo " localhost:1055 — sends to tailnet devices will route through it"
echo " automatically. Nothing to do."
else
echo
echo "⚠ Tailscale is running in userspace-networking mode (no TUN interface)"
echo " and no SOCKS5 proxy is listening on localhost:1055. This box can"
echo " RECEIVE over the tailnet, but CANNOT SEND to tailnet devices until"
echo " tailscaled runs with its proxy enabled."
# Offer to apply the fix: add --socks5-server=localhost:1055 to whatever
# launcher starts tailscaled (e.g. a container entrypoint) and restart it.
# Defaults to NO — non-interactive installs never restart someone else's
# daemon. OMARCHY_SEND_FIX_TAILSCALE=yes|no skips the prompt.
FIX_TS="${OMARCHY_SEND_FIX_TAILSCALE:-}"
case "$FIX_TS" in yes | no) : ;; *)
FIX_TS="no"
if { exec 3<>/dev/tty; } 2>/dev/null; then
printf ' Enable it now? The tailscaled launcher gets the flag added and\n tailscaled is restarted — this briefly drops the tailnet, including\n any tailscale SSH session. [y/N] ' >&3 || true
IFS= read -r _fx <&3 || _fx=""
exec 3>&- 3<&- || true
case "$_fx" in y | Y | yes | Yes | YES) FIX_TS="yes" ;; esac
fi
;;
esac
if [ "$FIX_TS" = "yes" ]; then
# 1. Persist: patch every writable launcher script that starts tailscaled
# in userspace mode (idempotent — skips ones already carrying the flag).
PATCHED=""
while IFS= read -r _f; do
[ -n "$_f" ] || continue
if grep -q "socks5-server" "$_f"; then PATCHED="$_f"; continue; fi
if [ -w "$_f" ] &&
sed -i "s|--tun=userspace-networking|--tun=userspace-networking --socks5-server=localhost:1055|" "$_f" 2>/dev/null; then
PATCHED="$_f"
echo " Patched launcher: $_f"
fi
done < <(grep -rlse '--tun=userspace-networking' \
"$HOME/.local/bin" /usr/local/bin /usr/local/sbin 2>/dev/null || true)
[ -z "$PATCHED" ] &&
echo " No writable tailscaled launcher found — the restart below won't survive a reboot."
# 2. Restart tailscaled now with its current flags + the proxy. Needs the
# rights of whoever owns the daemon (root in most containers).
RESTARTED=0
_pid="$(pgrep -x tailscaled | head -n1 || true)"
if [ -n "$_pid" ] && mapfile -d '' _args <"/proc/$_pid/cmdline" 2>/dev/null &&
[ "${#_args[@]}" -gt 0 ]; then
case " ${_args[*]} " in *socks5-server*) : ;; *) _args+=("--socks5-server=localhost:1055") ;; esac
SUDO=""
[ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
if [ -z "$SUDO" ] || sudo -n true 2>/dev/null; then
$SUDO pkill -x tailscaled 2>/dev/null || true
sleep 1
# shellcheck disable=SC2086 # $SUDO is deliberately word-split (empty or "sudo -n")
($SUDO nohup "${_args[@]}" >"${TMPDIR:-/tmp}/tailscaled-restart.log" 2>&1 &)
sleep 3
if (exec 3<>/dev/tcp/127.0.0.1/1055) 2>/dev/null; then RESTARTED=1; fi
fi
fi
if [ "$RESTARTED" = "1" ]; then
echo " Done — SOCKS5 proxy is up. Tailnet sends now work, no env vars needed"
[ -n "$PATCHED" ] && echo " (and the patched launcher keeps it working across restarts)."
elif [ -n "$PATCHED" ]; then
echo " Launcher patched, but tailscaled couldn't be restarted from here"
echo " (needs root). Restart the container/box and the fix applies itself."
else
echo " Couldn't patch or restart automatically. Add this flag wherever"
echo " tailscaled is launched, keeping its existing flags:"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
fi
else
echo " Skipped. To fix manually, add this flag wherever tailscaled is"
echo " launched (keeping its existing --state/--socket flags):"
echo " tailscaled --tun=userspace-networking --socks5-server=localhost:1055 …"
echo " omarchy-send then uses the proxy automatically — no env vars needed."
echo " (Or re-run the installer with OMARCHY_SEND_FIX_TAILSCALE=yes.)"
fi
fi
fi
echo echo
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) : ;; *":$BIN_DIR:"*) : ;;

View file

@ -27,6 +27,7 @@ import (
"omarchy-send/internal/discovery" "omarchy-send/internal/discovery"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/transfer" "omarchy-send/internal/transfer"
"omarchy-send/internal/tsproxy"
) )
// errOpen wraps a failure to open a source file, so the send loop can skip just // errOpen wraps a failure to open a source file, so the send loop can skip just
@ -60,6 +61,10 @@ func New(self protocol.DeviceInfo) *Sender {
// and waiting for response headers after the body is sent. // and waiting for response headers after the body is sent.
Timeout: 0, Timeout: 0,
Transport: &http.Transport{ Transport: &http.Transport{
// Proxy env vars + tailnet SOCKS5 auto-detection (see
// discovery) so transfers also work from userspace-networking
// Tailscale boxes.
Proxy: tsproxy.ProxyFunc,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
@ -132,6 +137,74 @@ func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error {
return err return err
} }
// SendFilesSync uploads the given file/folder paths to peer and blocks until
// the whole batch is done, returning the error directly — including
// transfer.ErrPinRequired when the peer needs a PIN. Unlike Send it reports
// nothing on Events(); it exists for the headless one-shot send path, where
// there is no TUI to consume events (the progress events uploadFile emits are
// harmlessly dropped). onDone, when non-nil, is called after each file lands,
// so the CLI can print per-file progress lines.
func (s *Sender) SendFilesSync(ctx context.Context, peer discovery.Peer, paths []string, pin string, onDone func(name string, size int64)) error {
// Explicitly named paths that don't exist are a hard error up front — in
// the TUI a stat failure is just an event on one staged entry, but a script
// passing a wrong path wants a non-zero exit, not a silent skip.
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
return err
}
}
items := s.expand(paths)
if len(items) == 0 {
return errors.New("nothing to send: no readable files under the given paths")
}
files := make(map[string]protocol.FileMetadata, len(items))
pathByID := make(map[string]string, len(items))
for _, it := range items {
id := randID()
files[id] = protocol.FileMetadata{
ID: id,
FileName: it.name,
Size: it.size,
FileType: mimeType(it.path),
}
pathByID[id] = it.path
}
base := s.url(peer)
prepResp, err := s.prepareUpload(ctx, base, files, pin)
if err != nil {
return err
}
// An empty token map (204) means the peer accepted but wants nothing
// uploaded — e.g. it already has the files. That's success: the loop below
// simply finds no tokens to push.
var skipped []string
for id, token := range prepResp.Files {
meta := files[id]
key := prepResp.SessionID + ":" + id
if err := s.uploadFile(ctx, base, prepResp.SessionID, id, token, key, pathByID[id], meta); err != nil {
// A failure to open a local file is specific to that file (it
// vanished or lost permissions since staging) — skip it and keep
// the batch going, like the TUI path does. Anything else means the
// peer/session is gone and can't be resumed, so abort the batch.
if errors.Is(err, errOpen) {
skipped = append(skipped, meta.FileName)
continue
}
return fmt.Errorf("upload %q: %w", meta.FileName, err)
}
if onDone != nil {
onDone(meta.FileName, meta.Size)
}
}
if len(skipped) > 0 {
return fmt.Errorf("could not read: %s", strings.Join(skipped, ", "))
}
return nil
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
// A new transfer to a peer supersedes any still-running one to the same // A new transfer to a peer supersedes any still-running one to the same
// peer: cancel it so a half-finished old batch can't carry on once the user // peer: cancel it so a half-finished old batch can't carry on once the user

View file

@ -0,0 +1,128 @@
package client
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// SendFilesSync delivers a file and a folder over loopback HTTP and returns
// nil, with the contents arriving intact — the synchronous path used by
// headless `-to <alias> <paths…>` sends.
func TestSendFilesSyncSuccess(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53993, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
// One loose file plus a folder, so the relative-name path is covered too.
srcDir := t.TempDir()
loose := filepath.Join(srcDir, "report.pdf")
content := bytes.Repeat([]byte("headless-payload-"), 5000) // ~85KB
if err := os.WriteFile(loose, content, 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
folder := filepath.Join(srcDir, "Trip")
if err := os.MkdirAll(filepath.Join(folder, "day1"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
nested := filepath.Join(folder, "day1", "img.jpg")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatalf("write nested: %v", err)
}
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
var done []string
err := sender.SendFilesSync(ctx, peer, []string{loose, folder}, "", func(name string, size int64) {
done = append(done, name)
})
if err != nil {
t.Fatalf("SendFilesSync: %v", err)
}
if len(done) != 2 {
t.Fatalf("onDone called %d times, want 2 (%v)", len(done), done)
}
got, err := os.ReadFile(filepath.Join(recvDir, "report.pdf"))
if err != nil {
t.Fatalf("read received: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("content mismatch: %d vs %d bytes", len(got), len(content))
}
if _, err := os.Stat(filepath.Join(recvDir, "Trip", "day1", "img.jpg")); err != nil {
t.Fatalf("folder structure not recreated: %v", err)
}
}
// A missing path is a hard error before any network work — a script passing a
// wrong path wants a non-zero exit, not a silent skip.
func TestSendFilesSyncMissingPath(t *testing.T) {
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: protocol.DeviceInfo{Protocol: "http", Port: 1}, IP: "127.0.0.1"}
err := sender.SendFilesSync(context.Background(), peer, []string{"/no/such/file"}, "", nil)
if err == nil || !os.IsNotExist(errors.Unwrap(err)) && !os.IsNotExist(err) {
t.Fatalf("err = %v, want not-exist", err)
}
}
// A peer that requires a PIN rejects a PIN-less file send with ErrPinRequired,
// so the CLI can tell the user to pass -send-pin; with the PIN it goes through.
func TestSendFilesSyncPinRequired(t *testing.T) {
recvDir := t.TempDir()
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53994, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true, PIN: "2468"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
src := filepath.Join(t.TempDir(), "note.txt")
if err := os.WriteFile(src, []byte("pinned"), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
err := sender.SendFilesSync(ctx, peer, []string{src}, "", nil)
if !errors.Is(err, transfer.ErrPinRequired) {
t.Fatalf("err = %v, want ErrPinRequired", err)
}
if err := sender.SendFilesSync(ctx, peer, []string{src}, "2468", nil); err != nil {
t.Fatalf("SendFilesSync with PIN: %v", err)
}
if _, err := os.Stat(filepath.Join(recvDir, "note.txt")); err != nil {
t.Fatalf("file not received: %v", err)
}
}

View file

@ -6,11 +6,31 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/security" "omarchy-send/internal/security"
) )
// ExpandHome resolves a leading "~" or "~/" to the user's home directory, so a
// receiveDir stored as "~/Omarchy-Send" (hand-edited, or typed into the
// Settings tab) means what the user means — and is not treated as a relative
// path that silently creates a literal "~" directory under the process cwd.
func ExpandHome(p string) string {
if p == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
}
// Config is the persisted user configuration. // Config is the persisted user configuration.
type Config struct { type Config struct {
Alias string `json:"alias"` Alias string `json:"alias"`
@ -106,6 +126,9 @@ func Load() (Config, error) {
if cfg.ReceiveDir == "" { if cfg.ReceiveDir == "" {
cfg.ReceiveDir = d.ReceiveDir cfg.ReceiveDir = d.ReceiveDir
} }
// Normalise a ~-form receive dir to absolute; Load persists below, so the
// stored value is unambiguous from then on.
cfg.ReceiveDir = ExpandHome(cfg.ReceiveDir)
if cfg.DeviceType == "" { if cfg.DeviceType == "" {
cfg.DeviceType = d.DeviceType cfg.DeviceType = d.DeviceType
} }

View file

@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// ExpandHome resolves the ~-forms a user may type or hand-edit, and leaves
// everything else untouched.
func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~": home,
"~/Omarchy-Send": filepath.Join(home, "Omarchy-Send"),
"~/a/b": filepath.Join(home, "a", "b"),
"/abs/path": "/abs/path",
"relative/path": "relative/path",
"~user/not-ours": "~user/not-ours", // ~user expansion is not supported
"mid/~/not-leading": "mid/~/not-leading",
"": "",
}
for in, want := range cases {
if got := ExpandHome(in); got != want {
t.Errorf("ExpandHome(%q) = %q, want %q", in, got, want)
}
}
}
// A config file whose receiveDir was stored as "~/…" is normalised to an
// absolute path by Load — the regression that sent files into a literal "~"
// directory under the process cwd.
func TestLoadExpandsTildeReceiveDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cfgHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", cfgHome)
dir := filepath.Join(cfgHome, "omarchy-send")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
seed := map[string]any{"alias": "t", "receiveDir": "~/Omarchy-Send"}
data, _ := json.Marshal(seed)
if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(home, "Omarchy-Send")
if cfg.ReceiveDir != want {
t.Fatalf("ReceiveDir = %q, want %q", cfg.ReceiveDir, want)
}
// And the normalised value is what got persisted back.
raw, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatalf("read back: %v", err)
}
var onDisk map[string]any
if err := json.Unmarshal(raw, &onDisk); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if onDisk["receiveDir"] != want {
t.Fatalf("persisted receiveDir = %q, want %q", onDisk["receiveDir"], want)
}
}

View file

@ -18,6 +18,7 @@ import (
"omarchy-send/internal/dbg" "omarchy-send/internal/dbg"
"omarchy-send/internal/protocol" "omarchy-send/internal/protocol"
"omarchy-send/internal/tsproxy"
) )
// EventKind distinguishes peer lifecycle events. // EventKind distinguishes peer lifecycle events.
@ -73,6 +74,11 @@ func New(self protocol.DeviceInfo) *Discoverer {
client: &http.Client{ client: &http.Client{
Timeout: 3 * time.Second, Timeout: 3 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
// Proxy env vars are honoured like the default transport, and
// tailnet destinations are auto-routed through the local
// tailscaled SOCKS5 proxy on userspace-networking boxes (no
// TUN), where direct outbound tailnet dials cannot work.
Proxy: tsproxy.ProxyFunc,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}, },
}, },
@ -338,6 +344,12 @@ func (d *Discoverer) Probe(ctx context.Context, host string) error {
return lastErr return lastErr
} }
// isLoopback reports whether ip parses as a loopback address (e.g. 127.0.0.1).
func isLoopback(ip string) bool {
p := net.ParseIP(ip)
return p != nil && p.IsLoopback()
}
// hostPort splits an optional :port off host, defaulting to the LocalSend port. // 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. // It handles bare IPv6 by requiring the [::]:port form for a custom port.
func hostPort(host string) (string, int) { func hostPort(host string) (string, int) {
@ -361,7 +373,13 @@ func (d *Discoverer) NotePeer(info protocol.DeviceInfo, ip string) {
d.mu.Lock() d.mu.Lock()
prev, existed := d.peers[info.Fingerprint] prev, existed := d.peers[info.Fingerprint]
changed := !existed || prev.IP != ip || prev.Info.Alias != info.Alias // Never downgrade a routable address to loopback: behind a
// userspace-networking tailscaled, inbound registers all appear to come
// from 127.0.0.1 — recording that would make us "reply" to ourselves.
if existed && isLoopback(ip) && !isLoopback(prev.IP) {
peer.IP = prev.IP
}
changed := !existed || prev.IP != peer.IP || prev.Info.Alias != info.Alias
d.peers[info.Fingerprint] = peer d.peers[info.Fingerprint] = peer
d.mu.Unlock() d.mu.Unlock()

View file

@ -93,6 +93,32 @@ func TestProbeUnreachableErrors(t *testing.T) {
} }
} }
// A register arriving via a userspace-networking tailscaled proxy appears to
// come from 127.0.0.1; that must not overwrite a peer's known routable address
// (sending to it would loop back to our own receiver).
func TestNotePeerKeepsRoutableOverLoopback(t *testing.T) {
d := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
info := protocol.DeviceInfo{Alias: "gav", Fingerprint: "gav-fp", Port: 53317}
d.NotePeer(info, "100.91.41.111")
d.NotePeer(info, "127.0.0.1") // inbound register through the local proxy
if got := d.Snapshot()[0].IP; got != "100.91.41.111" {
t.Errorf("IP downgraded to %q, want 100.91.41.111 kept", got)
}
// A first sight at loopback is still recorded (nothing better known)…
d2 := New(protocol.DeviceInfo{Fingerprint: "self-fp"})
d2.NotePeer(info, "127.0.0.1")
if got := d2.Snapshot()[0].IP; got != "127.0.0.1" {
t.Errorf("first-sight IP = %q, want 127.0.0.1", got)
}
// …and upgrades to the routable address as soon as one is learned.
d2.NotePeer(info, "100.91.41.111")
if got := d2.Snapshot()[0].IP; got != "100.91.41.111" {
t.Errorf("IP = %q, want upgrade to 100.91.41.111", got)
}
}
func TestHostPortDefaults(t *testing.T) { func TestHostPortDefaults(t *testing.T) {
if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort { if h, p := hostPort("colossus"); h != "colossus" || p != protocol.DefaultPort {
t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort) t.Errorf("hostPort(bare) = %q,%d; want colossus,%d", h, p, protocol.DefaultPort)

111
internal/tsproxy/tsproxy.go Normal file
View file

@ -0,0 +1,111 @@
// Package tsproxy routes tailnet-bound HTTP connections through the local
// tailscaled SOCKS5 proxy when — and only when — the box needs it. On a box
// whose tailscaled runs with --tun=userspace-networking (e.g. an unprivileged
// container), there is no TUN interface, so ordinary outbound dials to
// 100.64.0.0/10 addresses cannot route; tailscaled's --socks5-server is the
// only outbound path. On a normal box the tailnet address is a local interface
// address and no proxy is involved.
package tsproxy
import (
"net"
"net/http"
"net/url"
"sync"
"time"
"omarchy-send/internal/dbg"
)
// conventionalAddr is where tailscaled's SOCKS5 proxy conventionally listens
// (--socks5-server=localhost:1055, the address used throughout Tailscale's
// userspace-networking docs). An explicit HTTPS_PROXY/HTTP_PROXY overrides it.
const conventionalAddr = "127.0.0.1:1055"
// detectTTL bounds how long a detection result is trusted, so a tailscaled
// (re)started after us is picked up without restarting omarchy-send.
const detectTTL = 30 * time.Second
// tailnetCIDR is Tailscale's CGNAT range; every tailnet IPv4 falls in it.
var tailnetCIDR = func() *net.IPNet {
_, n, _ := net.ParseCIDR("100.64.0.0/10")
return n
}()
// envProxy resolves the proxy environment variables; a seam for tests (the
// stdlib caches the env process-wide on first use).
var envProxy = http.ProxyFromEnvironment
// ProxyFunc is an http.Transport.Proxy implementation. Explicit proxy
// environment variables (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) always win, like the
// default transport; otherwise requests to tailnet addresses are routed via
// the local tailscaled SOCKS5 proxy when the box can't dial them directly.
func ProxyFunc(req *http.Request) (*url.URL, error) {
if u, err := envProxy(req); err != nil || u != nil {
return u, err
}
if isTailnetHost(req.URL.Hostname()) {
return detect(), nil
}
return nil, nil
}
// isTailnetHost reports whether host is an IP in the Tailscale CGNAT range.
func isTailnetHost(host string) bool {
ip := net.ParseIP(host)
return ip != nil && tailnetCIDR.Contains(ip)
}
var (
mu sync.Mutex
checked time.Time
cached *url.URL // non-nil when tailnet dials must go through the proxy
)
// detect decides (with a short cache) whether tailnet destinations need the
// local SOCKS5 proxy: only when no local interface carries a tailnet address
// (i.e. userspace networking — a TUN box can dial directly) AND something is
// listening at the conventional proxy address.
func detect() *url.URL {
mu.Lock()
defer mu.Unlock()
if time.Since(checked) < detectTTL {
return cached
}
checked = time.Now()
prev := cached
cached = nil
if !hasLocalTailnetAddr() && listening(conventionalAddr) {
cached = &url.URL{Scheme: "socks5", Host: conventionalAddr}
}
if (cached == nil) != (prev == nil) {
dbg.Logf("tsproxy: tailnet SOCKS5 proxy at %s: %v", conventionalAddr, cached != nil)
}
return cached
}
// hasLocalTailnetAddr reports whether any local interface has a tailnet
// address — true on kernel-TUN tailscale boxes, false under userspace
// networking (the box's own tailnet IP is not a local address there).
func hasLocalTailnetAddr() bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && tailnetCIDR.Contains(ipn.IP) {
return true
}
}
return false
}
// listening reports whether a TCP listener answers at addr.
func listening(addr string) bool {
c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
if err != nil {
return false
}
_ = c.Close()
return true
}

View file

@ -0,0 +1,96 @@
package tsproxy
import (
"net/http"
"net/url"
"testing"
"time"
)
func TestIsTailnetHost(t *testing.T) {
cases := []struct {
host string
want bool
}{
{"100.90.62.102", true}, // tailnet (CGNAT range)
{"100.64.0.1", true}, // range start
{"100.127.255.254", true} /* range end */, {"100.128.0.1", false}, // just past the /10
{"192.168.1.46", false}, // LAN
{"127.0.0.1", false}, // loopback
{"colossus", false}, // hostname, not an IP
{"", false},
}
for _, c := range cases {
if got := isTailnetHost(c.host); got != c.want {
t.Errorf("isTailnetHost(%q) = %v, want %v", c.host, got, c.want)
}
}
}
// stubEnvProxy replaces the env-var proxy lookup for a test. The stdlib's
// ProxyFromEnvironment caches the environment process-wide on first use, so
// t.Setenv can't drive these tests reliably.
func stubEnvProxy(t *testing.T, u *url.URL) {
t.Helper()
orig := envProxy
envProxy = func(*http.Request) (*url.URL, error) { return u, nil }
t.Cleanup(func() { envProxy = orig })
}
// Explicit proxy env vars must win over auto-detection, like the default
// transport.
func TestProxyFuncEnvOverride(t *testing.T) {
stubEnvProxy(t, &url.URL{Scheme: "socks5", Host: "127.0.0.1:9999"})
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr}) // detection would say 1055
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Host != "127.0.0.1:9999" {
t.Errorf("proxy = %v, want explicit socks5://127.0.0.1:9999", u)
}
}
// Non-tailnet destinations never get the auto-detected proxy.
func TestProxyFuncNonTailnetDirect(t *testing.T) {
stubEnvProxy(t, nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
req, _ := http.NewRequest(http.MethodGet, "https://192.168.1.46:53317/info", nil)
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u != nil {
t.Errorf("proxy = %v, want nil (direct) for a LAN destination", u)
}
}
// A tailnet destination uses the cached detection result; with the cache
// primed to "proxy present" the URL comes back, and direct otherwise.
func TestProxyFuncTailnetUsesDetection(t *testing.T) {
stubEnvProxy(t, nil)
req, _ := http.NewRequest(http.MethodGet, "https://100.90.62.102:53317/info", nil)
prime(&url.URL{Scheme: "socks5", Host: conventionalAddr})
u, err := ProxyFunc(req)
if err != nil {
t.Fatalf("ProxyFunc: %v", err)
}
if u == nil || u.Scheme != "socks5" || u.Host != conventionalAddr {
t.Errorf("proxy = %v, want socks5://%s", u, conventionalAddr)
}
prime(nil)
if u, _ := ProxyFunc(req); u != nil {
t.Errorf("proxy = %v, want nil when no proxy detected", u)
}
}
// prime seeds the detection cache for tests.
func prime(u *url.URL) {
mu.Lock()
defer mu.Unlock()
checked = time.Now()
cached = u
}

View file

@ -734,7 +734,9 @@ func (m Model) saveEdit() (tea.Model, tea.Cmd) {
m.cfg.DeviceModel = alias m.cfg.DeviceModel = alias
} }
if dir != "" { if dir != "" {
m.cfg.ReceiveDir = dir // Expand a typed ~-form immediately so the live server and the saved
// config both carry the absolute path.
m.cfg.ReceiveDir = config.ExpandHome(dir)
} }
m.cfg.PIN = pin m.cfg.PIN = pin
_ = m.cfg.Save() _ = m.cfg.Save()
@ -1354,21 +1356,10 @@ func collapseHome(p string) string {
} }
// expandHome resolves a leading ~ (or ~/) to the user's home directory. It is // expandHome resolves a leading ~ (or ~/) to the user's home directory. It is
// the inverse of collapseHome and tolerates the ~-form a user may type into the // the inverse of collapseHome; the canonical implementation lives in config so
// receive-dir setting. // every consumer of ReceiveDir expands the same way.
func expandHome(p string) string { func expandHome(p string) string {
if p == "~" { return config.ExpandHome(p)
if home, err := os.UserHomeDir(); err == nil {
return home
}
return p
}
if strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
} }
func truncate(s string, n int) string { func truncate(s string, n int) string {