Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39280e5590 | |||
| d68421ac97 | |||
| 2342beff10 | |||
| a93cdcaa83 | |||
| 38f057db67 | |||
| ee9060e6d0 | |||
| 05e70f127d | |||
| 7d6e339dcb |
12 changed files with 792 additions and 51 deletions
48
README.md
48
README.md
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
126
install.sh
126
install.sh
|
|
@ -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:"*) : ;;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
128
internal/client/files_sync_test.go
Normal file
128
internal/client/files_sync_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
internal/config/config_test.go
Normal file
76
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
111
internal/tsproxy/tsproxy.go
Normal 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
|
||||||
|
}
|
||||||
96
internal/tsproxy/tsproxy_test.go
Normal file
96
internal/tsproxy/tsproxy_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue