Warn when installing on a public-IP box in local mode

A piped `curl | bash` defaults to local mode and previously said nothing about
the transfer port being internet-exposed. Now the installer detects a routable
public IPv4 (excluding RFC1918, loopback, link-local and CGNAT/Tailscale) and,
in local mode, prints the exact ufw/nftables commands to lock 53317 to the
tailnet — plus a container-on-host note, a --pin tip, and an app-layer verify
command (raw TCP/nc lie behind providers that SYN-ACK every port). It never
changes the firewall outside remote mode. README gains a "Public-IP boxes"
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-06-02 21:47:52 +01:00
parent 2c058031fd
commit e57c426327
2 changed files with 84 additions and 2 deletions

View file

@ -62,6 +62,11 @@ interface in the firewall (`ufw`), so the box is reachable over your tailnet onl
not the open internet. Non-interactive installs (e.g. piped `curl | bash`) default not the open internet. Non-interactive installs (e.g. piped `curl | bash`) default
to local; force a choice with `OMARCHY_SEND_MODE=local` or `OMARCHY_SEND_MODE=remote`. to local; force a choice with `OMARCHY_SEND_MODE=local` or `OMARCHY_SEND_MODE=remote`.
If you install in local mode but the box has a **public IP**, the installer detects
it and prints a warning with the exact commands to lock the port down — it never
changes your firewall without remote mode. See
[Public-IP boxes](#public-ip-boxes-firewall-the-port) below.
> The installer is a short shell script fetched over HTTPS; read it first if you > The installer is a short shell script fetched over HTTPS; read it first if you
> prefer — it lives at [`install.sh`](install.sh) in this repo. > prefer — it lives at [`install.sh`](install.sh) in this repo.
@ -140,8 +145,47 @@ 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).
> On a box with a public IP, don't leave `53317` open to the internet — install in #### Public-IP boxes: firewall the port
> **remote** mode (above) to firewall it to the tailnet, and/or set a `--pin`.
The receiver binds **all interfaces**, so on a box with a public IP, port `53317`
is reachable from the open internet while the TUI is running. Don't leave it that
way. Three ways to handle it:
- **Easiest:** install in **remote** mode — `OMARCHY_SEND_MODE=remote bash install.sh`
— and the installer applies the `ufw` rules for you (when a real `tailscale0`
interface is present).
- **Manually**, restrict the port to the tailnet:
```sh
ufw allow in on tailscale0 to any port 53317 # tailnet only
ufw deny 53317 # everything else
```
Or the `nftables` equivalent (inet filter, input chain):
```
iifname "tailscale0" tcp dport 53317 accept
tcp dport 53317 drop
```
- **Inside a container** (e.g. Docker `--network host` with userspace-networking
Tailscale, where there's no `tailscale0` and no `CAP_NET_ADMIN`): you can't
firewall from in there — apply it on the **host**. If the host already
default-denies inbound (only opens e.g. 22/80/443), `53317` is already blocked
from the internet yet still reachable over the tailnet (tailscaled delivers it
via loopback) — nothing more to do.
Always set a **`--pin`** as a second layer regardless.
> **Verifying** the port is closed: don't trust `nc -z`, `telnet`, or
> `/dev/tcp` — some hosting providers (Hostinger, DigitalOcean, …) answer the TCP
> handshake (SYN/ACK) for *every* port at their network edge, so those tools
> report a firewalled port as "open". Only an **app-layer** probe is truthful:
>
> ```sh
> curl -sk https://<public-ip>:53317/api/localsend/v2/info # should time out / hang
> curl -sk https://<tailnet-ip>:53317/api/localsend/v2/info # returns device info
> ```
### Right-click send (Nautilus) ### Right-click send (Nautilus)

View file

@ -270,6 +270,13 @@ if [ -f /.dockerenv ] || grep -qaE 'docker|containerd|kubepods' /proc/1/cgroup 2
IN_CONTAINER=1 IN_CONTAINER=1
fi fi
# A routable public IPv4 means $PORT is reachable from the internet unless
# firewalled. Excludes loopback, link-local, RFC1918 and CGNAT/Tailscale
# (100.64.0.0/10). Empty when the box is purely on private/tailnet addresses.
PUBLIC_IP="$(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
| grep -vE '^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.)' \
| head -n1 || true)"
# ---- remote server: restrict the port to the Tailscale network ----------- # ---- remote server: restrict the port to the Tailscale network -----------
# On a public-IP box, port 53317 would otherwise be reachable from the internet # On a public-IP box, port 53317 would otherwise be reachable from the internet
# (the receiver binds all interfaces). Lock it to the Tailscale interface so it # (the receiver binds all interfaces). Lock it to the Tailscale interface so it
@ -323,6 +330,37 @@ if [ "$MODE" = "remote" ]; then
echo " Tip: a PIN adds a second layer — run with --pin <code> (or set it in Settings)." echo " Tip: a PIN adds a second layer — run with --pin <code> (or set it in Settings)."
fi fi
# ---- local mode on a public-IP box: inform, don't touch the firewall -----
# We never change the firewall outside remote mode, but a public IP means the
# port is internet-exposed while the TUI is open — so surface it with the exact
# commands to lock it down. (Covers the silent `curl | bash` default-to-local
# case, where the interactive remote prompt never ran.)
if [ "$MODE" != "remote" ] && [ -n "$PUBLIC_IP" ]; then
iface="${TS_IFACE:-tailscale0}"
echo
echo "⚠ Heads up: this machine has a public IP ($PUBLIC_IP) and was installed in"
echo " LOCAL mode, so port $PORT was NOT firewalled. The receiver binds all"
echo " interfaces, so $PORT is reachable from the internet while the TUI is open."
echo " The installer won't change your firewall without remote mode — lock it to"
echo " your tailnet yourself (recommended):"
if [ "$IN_CONTAINER" = "1" ]; then
echo " • You're in a container — apply on the HOST, not in here: ufw deny $PORT"
echo " (if the host already default-denies inbound, $PORT is already blocked"
echo " from the internet yet still reachable over the tailnet via loopback)."
elif command -v ufw >/dev/null 2>&1; then
echo " • ufw allow in on $iface to any port $PORT"
echo " • ufw deny $PORT"
else
echo " • nftables (inet filter, input chain):"
echo " iifname \"$iface\" tcp dport $PORT accept"
echo " tcp dport $PORT drop"
fi
echo " Or re-run to firewall it automatically: OMARCHY_SEND_MODE=remote bash install.sh"
echo " And/or set a PIN: omarchy-send --pin <code>"
echo " Verify with an app-layer probe (raw TCP/nc lie behind some providers):"
echo " curl -sk https://<public-ip>:$PORT/api/localsend/v2/info # should time out"
fi
echo echo
case ":$PATH:" in case ":$PATH:" in
*":$BIN_DIR:"*) : ;; *":$BIN_DIR:"*) : ;;