From 023b3cd4a8db3b96c6637d764f679af4c8c34cda Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:33:53 +0200 Subject: [PATCH 1/6] Docs: fix the port 53 and OpenPLi guides --- documentation/free-up-port-53.md | 30 ++++++++++++++++++++++++++---- documentation/standard-setup.md | 8 +++++--- documentation/tailscale-on-arm.md | 25 +++++++++++++------------ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/documentation/free-up-port-53.md b/documentation/free-up-port-53.md index dd088bfd..5f02d63d 100644 --- a/documentation/free-up-port-53.md +++ b/documentation/free-up-port-53.md @@ -4,11 +4,11 @@ A DNS service such as Pi-hole or AdGuard Home needs port 53. This page applies o ## Why port 53 is in use -On Debian-based systems that use `systemd-resolved`, such as Ubuntu Server 22.04 and 24.04, a DNS stub listener runs by default. It listens on `127.0.0.53:53` and answers DNS queries from local applications. A container that publishes port 53 on all host addresses then fails to start, because the port is already taken. +On Debian-based systems that use `systemd-resolved`, such as Ubuntu Server 22.04 and 24.04, a DNS stub listener runs by default. It listens on `127.0.0.53:53` and `127.0.0.54:53` and answers DNS queries from local applications. A container that publishes port 53 on all host addresses then fails to start, because the port is already taken. The `DNSStubListener` option in `/etc/systemd/resolved.conf` controls this listener: -- `DNSStubListener=yes`: `systemd-resolved` listens on `127.0.0.53:53`. This is the default. +- `DNSStubListener=yes`: `systemd-resolved` listens on `127.0.0.53:53` and `127.0.0.54:53`. This is the default. - `DNSStubListener=no`: `systemd-resolved` does not listen on port 53, so another DNS service can use it. ## Steps @@ -25,13 +25,35 @@ The `DNSStubListener` option in `/etc/systemd/resolved.conf` controls this liste DNSStubListener=no ``` -3. Restart the service: +3. Point `/etc/resolv.conf` to the file that lists your upstream DNS servers: ```bash + sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf + ``` + + Do this before the next step. On Ubuntu, `/etc/resolv.conf` points to the stub at `127.0.0.53`. That stub stops answering after the restart, so the host would lose DNS. + +4. Restart the service: + + ```bash + sudo systemctl restart systemd-resolved + ``` + +5. Check that the host still resolves names: + + ```bash + getent hosts github.com + ``` + + The command prints an IP address and the name. If it prints nothing, undo the change and enable the stub listener again: + + ```bash + sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf + sudo sed -i 's/^DNSStubListener=no/#DNSStubListener=yes/' /etc/systemd/resolved.conf sudo systemctl restart systemd-resolved ``` -4. Check that port 53 is free: +6. Check that port 53 is free: ```bash sudo ss -tuln | grep ':53 ' diff --git a/documentation/standard-setup.md b/documentation/standard-setup.md index ad156413..3ac1be3e 100644 --- a/documentation/standard-setup.md +++ b/documentation/standard-setup.md @@ -29,20 +29,22 @@ The application uses `network_mode: service:tailscale` and starts only after the | `IMAGE_URL` | Image of the application. | | `SERVICEPORT` | Port used by the optional `ports` block. The port for Tailscale Serve is set in `compose.yaml`. | | `DNS_SERVER` | DNS server used by the optional `dns` block. | -| `TS_AUTHKEY` | Your Tailscale auth key. Only needed for the first start. | +| `TS_AUTHKEY` | Your Tailscale auth key. Used only when the container is not logged in, for example at the first start or after you delete `./ts/state`. | | `TZ` | Time zone, passed to the application when its image supports it. | +Some stacks need secrets that you choose. Compose stops with an error that starts with `required variable X is missing a value` until you set them. The service README lists them under "Before you start". + ## Data All data stays in the service directory, next to `compose.yaml`. | Path | Content | | ------------------- | ----------------------------------------- | -| `./config` | Tailscale configuration files. | +| `./config` | Holds `serve.json`, the Serve configuration. Compose rewrites it from the `configs` block in `compose.yaml` each time it starts the container, so change `compose.yaml` instead. | | `./ts/state` | Tailscale state, including the device key. | | `./-data/` | Data of the application. | -Keep `./ts/state` when you recreate the stack. Without it, the device joins your Tailnet again as a new device. +Keep `./ts/state` when you recreate the stack. Without it, the device needs a valid auth key and joins your Tailnet as a new device. ## DNS diff --git a/documentation/tailscale-on-arm.md b/documentation/tailscale-on-arm.md index 464e4fb5..fb7c2393 100644 --- a/documentation/tailscale-on-arm.md +++ b/documentation/tailscale-on-arm.md @@ -1,4 +1,4 @@ -# Setting up Tailscale on OpenPLi (ARM Linux) +# Install Tailscale on an OpenPLi set-top box (without Docker) This page describes how to install and configure **Tailscale** on an OpenPLi set-top box using the ARM version of Linux. Because OpenPLi is a lightweight distribution, the usual package manager method may not work. Instead, you may need to use Tailscale’s static binaries and configure an init.d service for automatic startup. @@ -20,11 +20,10 @@ curl -fsSL https://tailscale.com/install.sh | sh ## 2. Download the ARM static binaries -Go to [Tailscale Stable Releases](https://pkgs.tailscale.com/stable/#static) and download the ARM package. -For example: +Go to [Tailscale Stable Releases](https://pkgs.tailscale.com/stable/#static) and download the archive for your architecture: `arm`, `arm64`, `mips`, or `mipsle`. The file names follow the pattern `tailscale__.tgz`, where `` is the current version shown on that page. For an ARM device, for example: ```sh -wget https://pkgs.tailscale.com/stable/tailscale_1.86.2_arm.tgz +wget https://pkgs.tailscale.com/stable/tailscale__arm.tgz ``` --- @@ -34,11 +33,13 @@ wget https://pkgs.tailscale.com/stable/tailscale_1.86.2_arm.tgz Extract the archive and copy the executables into `/usr/sbin`: ```sh -tar zxvf tailscale_1.86.2_arm.tgz -cp tailscale_1.86.2_arm/tailscal* /usr/sbin/ +tar zxvf tailscale_*_arm.tgz +cp tailscale_*_arm/tailscal* /usr/sbin/ chmod +x /usr/sbin/tailscal* ``` +If you downloaded another architecture, replace `arm` in the file names. + This provides both `tailscale` (CLI) and `tailscaled` (daemon). --- @@ -50,7 +51,7 @@ Since OpenPLi does not use `systemd`, we need to create an **init.d service** to Create the service script: ```sh -cat << EOF >/etc/init.d/tailscaled +cat << 'EOF' > /etc/init.d/tailscaled #!/bin/sh DAEMON=/usr/sbin/tailscaled PIDFILE=/var/run/tailscaled.pid @@ -59,20 +60,20 @@ DAEMON_OPTS="--state=/var/lib/tailscale/tailscaled.state --socket=/var/run/tails case "$1" in start) echo "Starting tailscaled" - start-stop-daemon --start --quiet --background --make-pidfile --pidfile $PIDFILE --exec $DAEMON -- $DAEMON_OPTS + start-stop-daemon --start --quiet --background --make-pidfile --pidfile "$PIDFILE" --exec "$DAEMON" -- $DAEMON_OPTS ;; stop) echo "Stopping tailscaled" - start-stop-daemon --stop --quiet ---retry=TERM/9/KILL/11 --pidfile $PIDFILE - $DAEMON --cleanup - rm -f $PIDFILE + start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE" + "$DAEMON" --cleanup + rm -f "$PIDFILE" ;; restart) $0 stop $0 start ;; status) - if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "tailscaled is running" else echo "tailscaled is not running" From cf06055630a0da307a79f805e60c63c802e4957e Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:34:43 +0200 Subject: [PATCH 2/6] Docs: correct when Compose writes serve.json and where 127.0.0.54 applies Compose writes serve.json when it creates the container or starts a stopped one. docker compose restart keeps the current file. The second stub address 127.0.0.54 exists since systemd 250, so not on Ubuntu 22.04. --- documentation/free-up-port-53.md | 4 ++-- documentation/standard-setup.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/free-up-port-53.md b/documentation/free-up-port-53.md index 5f02d63d..6ea92059 100644 --- a/documentation/free-up-port-53.md +++ b/documentation/free-up-port-53.md @@ -4,11 +4,11 @@ A DNS service such as Pi-hole or AdGuard Home needs port 53. This page applies o ## Why port 53 is in use -On Debian-based systems that use `systemd-resolved`, such as Ubuntu Server 22.04 and 24.04, a DNS stub listener runs by default. It listens on `127.0.0.53:53` and `127.0.0.54:53` and answers DNS queries from local applications. A container that publishes port 53 on all host addresses then fails to start, because the port is already taken. +On Debian-based systems that use `systemd-resolved`, such as Ubuntu Server 22.04 and 24.04, a DNS stub listener runs by default. It listens on `127.0.0.53:53` and, since systemd 250 (for example on Ubuntu 24.04), also on `127.0.0.54:53`. It answers DNS queries from local applications. A container that publishes port 53 on all host addresses then fails to start, because the port is already taken. The `DNSStubListener` option in `/etc/systemd/resolved.conf` controls this listener: -- `DNSStubListener=yes`: `systemd-resolved` listens on `127.0.0.53:53` and `127.0.0.54:53`. This is the default. +- `DNSStubListener=yes`: `systemd-resolved` listens on port 53 of these addresses. This is the default. - `DNSStubListener=no`: `systemd-resolved` does not listen on port 53, so another DNS service can use it. ## Steps diff --git a/documentation/standard-setup.md b/documentation/standard-setup.md index 3ac1be3e..0761f4f9 100644 --- a/documentation/standard-setup.md +++ b/documentation/standard-setup.md @@ -40,7 +40,7 @@ All data stays in the service directory, next to `compose.yaml`. | Path | Content | | ------------------- | ----------------------------------------- | -| `./config` | Holds `serve.json`, the Serve configuration. Compose rewrites it from the `configs` block in `compose.yaml` each time it starts the container, so change `compose.yaml` instead. | +| `./config` | Holds `serve.json`, the Serve configuration. Compose writes it from the `configs` block in `compose.yaml` when it creates the container or starts a stopped one, so change `compose.yaml` instead. `docker compose restart` keeps the current file. | | `./ts/state` | Tailscale state, including the device key. | | `./-data/` | Data of the application. | From 9f573ba83ec4bf85c29ff63e6326285f69fb3b7a Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:50:23 +0200 Subject: [PATCH 3/6] Docs: wait for tailscaled to stop in the OpenPLi init script BusyBox's start-stop-daemon ignores --retry and returns at once. restart then started a second tailscaled while the first was still shutting down, the second one exited, and nothing was left running. stop now waits until the old process is gone and kills it after 15 seconds. The standard setup page also says that the Tailnet DNS name already includes .ts.net. --- documentation/standard-setup.md | 2 +- documentation/tailscale-on-arm.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/documentation/standard-setup.md b/documentation/standard-setup.md index 0761f4f9..c23778ff 100644 --- a/documentation/standard-setup.md +++ b/documentation/standard-setup.md @@ -15,7 +15,7 @@ The application uses `network_mode: service:tailscale` and starts only after the ## Tailnet access -- **Web interface.** [Tailscale Serve](https://tailscale.com/kb/1312/serve) publishes the web interface at `https://..ts.net` and forwards it to the internal port of the application. `` is the value of `SERVICE` and `` is your [Tailnet name](https://tailscale.com/kb/1217/tailnet-name). +- **Web interface.** [Tailscale Serve](https://tailscale.com/kb/1312/serve) publishes the web interface at `https://..ts.net` and forwards it to the internal port of the application. `` is the value of `SERVICE`, and `.ts.net` is your [Tailnet DNS name](https://tailscale.com/kb/1217/tailnet-name), for example `tail1234.ts.net`. - **Requirements.** Your Tailnet needs [MagicDNS](https://tailscale.com/kb/1081/magicdns) and [HTTPS certificates](https://tailscale.com/kb/1153/enabling-https) enabled. The first request can take up to a minute, because Tailscale requests the certificate at that moment. - **Tailnet only.** Tailscale Funnel is disabled, so the service is not reachable from the public internet. - **Other ports.** Any other port the application listens on is reachable at the Tailscale IP address of the device, when your Tailnet policy allows it. diff --git a/documentation/tailscale-on-arm.md b/documentation/tailscale-on-arm.md index fb7c2393..9729d4a7 100644 --- a/documentation/tailscale-on-arm.md +++ b/documentation/tailscale-on-arm.md @@ -64,7 +64,18 @@ case "$1" in ;; stop) echo "Stopping tailscaled" + PID="$(cat "$PIDFILE" 2>/dev/null)" start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE" + # BusyBox ignores --retry and returns at once, so wait here until the process is gone + i=0 + while [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; do + if [ "$i" -ge 15 ]; then + kill -9 "$PID" 2>/dev/null + break + fi + sleep 1 + i=$((i + 1)) + done "$DAEMON" --cleanup rm -f "$PIDFILE" ;; From b9e572422407529e2e3c773850130b399d2861b8 Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:13:06 +0200 Subject: [PATCH 4/6] Docs: say how to apply a changed Serve configuration A changed configs block reaches serve.json only when Compose recreates the container. docker compose restart and a plain docker compose up -d keep the current file. --- documentation/standard-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/standard-setup.md b/documentation/standard-setup.md index c23778ff..c438453e 100644 --- a/documentation/standard-setup.md +++ b/documentation/standard-setup.md @@ -40,7 +40,7 @@ All data stays in the service directory, next to `compose.yaml`. | Path | Content | | ------------------- | ----------------------------------------- | -| `./config` | Holds `serve.json`, the Serve configuration. Compose writes it from the `configs` block in `compose.yaml` when it creates the container or starts a stopped one, so change `compose.yaml` instead. `docker compose restart` keeps the current file. | +| `./config` | Holds `serve.json`, the Serve configuration. Compose writes it from the `configs` block in `compose.yaml` when it creates the container or starts a stopped one, so change `compose.yaml` instead and apply it with `docker compose up -d --force-recreate`. `docker compose restart` and a plain `docker compose up -d` keep the current file. | | `./ts/state` | Tailscale state, including the device key. | | `./-data/` | Data of the application. | From 6e38706bdc4e6b837bd0d3be975b0c69ce5c1768 Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:27:06 +0200 Subject: [PATCH 5/6] Docs: send TERM when start-stop-daemon cannot stop tailscaled A start-stop-daemon that rejects --retry exits without sending a signal. The init script then waited 15 seconds and killed tailscaled. It now sends TERM itself when start-stop-daemon fails, so tailscaled shuts down cleanly. --- documentation/tailscale-on-arm.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/tailscale-on-arm.md b/documentation/tailscale-on-arm.md index 9729d4a7..73a1a226 100644 --- a/documentation/tailscale-on-arm.md +++ b/documentation/tailscale-on-arm.md @@ -65,7 +65,10 @@ case "$1" in stop) echo "Stopping tailscaled" PID="$(cat "$PIDFILE" 2>/dev/null)" - start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE" + if ! start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE"; then + # A start-stop-daemon that rejects --retry sends no signal, so send TERM here + [ -n "$PID" ] && kill "$PID" 2>/dev/null + fi # BusyBox ignores --retry and returns at once, so wait here until the process is gone i=0 while [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; do From f27a0e61b28c2d2662f0fad9c316de6d05068849 Mon Sep 17 00:00:00 2001 From: Jack Spiering <46534141+jackspiering@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:49:17 +0200 Subject: [PATCH 6/6] Docs: ignore a stale PID file in the OpenPLi init script If tailscaled crashed and left its PID file behind, stop could signal another process that got the same PID, and status reported it as running. Both now check that the PID belongs to tailscaled. --- documentation/tailscale-on-arm.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/documentation/tailscale-on-arm.md b/documentation/tailscale-on-arm.md index 73a1a226..6b16cef0 100644 --- a/documentation/tailscale-on-arm.md +++ b/documentation/tailscale-on-arm.md @@ -65,9 +65,11 @@ case "$1" in stop) echo "Stopping tailscaled" PID="$(cat "$PIDFILE" 2>/dev/null)" - if ! start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE"; then + # Ignore a stale PID file whose number now belongs to another process + grep -qF "$DAEMON" "/proc/$PID/cmdline" 2>/dev/null || PID="" + if [ -n "$PID" ] && ! start-stop-daemon --stop --quiet --retry=TERM/9/KILL/11 --pidfile "$PIDFILE"; then # A start-stop-daemon that rejects --retry sends no signal, so send TERM here - [ -n "$PID" ] && kill "$PID" 2>/dev/null + kill "$PID" 2>/dev/null fi # BusyBox ignores --retry and returns at once, so wait here until the process is gone i=0 @@ -87,7 +89,7 @@ case "$1" in $0 start ;; status) - if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + if grep -qF "$DAEMON" "/proc/$(cat "$PIDFILE" 2>/dev/null)/cmdline" 2>/dev/null; then echo "tailscaled is running" else echo "tailscaled is not running"