Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Package regression tests

on:
pull_request:
push:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Test authentication recovery, shutdown, and health reporting
run: python3 -m unittest discover -s tests -v
- name: Validate Compose configuration
run: docker compose config --quiet
- name: Build package image
run: |
upstream_version=$(jq -r .upstreamVersion dappnode_package.json)
docker build --build-arg "UPSTREAM_VERSION=$upstream_version" -t tailscale-package:test .
- name: Ensure TUN device is available
run: test -c /dev/net/tun || sudo modprobe tun
- name: Smoke-test isolated container startup
run: sh tests/container-smoke.sh tailscale-package:test
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ FROM tailscale/tailscale:${UPSTREAM_VERSION}
ENV TS_STATE_DIR=/var/lib/tailscale
ENV TS_USERSPACE=false
ENV TS_AUTH_ONCE=true
ENV TS_BOOT_TIMEOUT=1h
ENV TS_DEBUG_FIREWALL_MODE=auto

RUN apk add --no-cache jq

# Replace the complete system trust store, including individual hashed certificates.
RUN rm -rf /etc/ssl/certs /usr/share/ca-certificates
COPY --from=ca-trust /etc/ssl/certs/ /etc/ssl/certs/
COPY --from=ca-trust /usr/share/ca-certificates/ /usr/share/ca-certificates/
COPY --from=ca-trust /etc/ca-certificates.conf /etc/ca-certificates.conf
RUN ln -sf certs/ca-certificates.crt /etc/ssl/cert.pem

COPY --chmod=755 scripts/entrypoint.sh /usr/local/bin/dappnode-entrypoint
COPY --chmod=755 scripts/healthcheck.sh /usr/local/bin/dappnode-healthcheck

HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["/usr/local/bin/dappnode-healthcheck"]
CMD ["/usr/local/bin/dappnode-entrypoint"]
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Tailscale for Dappnode

This package advertises the Dappnode networks `172.33.0.0/16` and `10.20.0.0/24`
and offers exit-node functionality. Approve the advertised routes and configure
DNS in the [Dappnode Tailscale setup guide](https://docs.dappnode.io/docs/user/access-your-dappnode/vpn/tailscale).

## Sign in and recover authentication

Provide `TS_AUTHKEY` in the setup wizard for automatic login, or leave it empty
and open the login URL in the package logs. Browser login has a one-hour startup
window (`TS_BOOT_TIMEOUT=1h`); authentication still requires your approval.
An unauthenticated package reports unhealthy while waiting for that approval.
If the window expires, the package restarts and generates another login request.

The `state` volume at `/var/lib/tailscale` retains the device identity.
`TS_AUTH_ONCE=true` reuses a valid saved login across restarts. Keep this volume
when updating or recovering the package.

If Tailscale explicitly rejects the configured auth key, startup retries once
with both `TS_AUTHKEY` and its upstream alias `TS_AUTH_KEY` removed from the
process environment. The package then waits for browser login instead of
repeatedly submitting the rejected credential. It keeps the same state volume,
routes, DNS preferences, and exit-node configuration. Other startup failures
retain their failure exit status.

Clear or replace a rejected key in **Tailscale package > Config** so it is also
removed from the saved configuration. The running container cannot edit that
configuration itself. To recover manually, clear the key, restart the package,
and open the fresh login URL from its logs. Treat login URLs as private.

## Networking and health

`TS_DEBUG_FIREWALL_MODE=auto` lets Tailscale select the host's available firewall
backend, including nftables. It is set in both the image and Compose defaults
so the image also works when an older configuration omits the variable.
The Compose sysctls enable IPv4 and IPv6 forwarding in the container's network
namespace; they do not change the host's global sysctls.

The image health check requires Tailscale to report `Running`, the local device
to be online, and no Tailscale health warnings. It surfaces authentication and
router/firewall errors even if the container remains running. This does not
prove that a remote client's ACLs, route approvals, or DNS configuration permit
access. After setup, also test a peer connection and access to a Dappnode service
from another tailnet device.

The container's Tailscale IP belongs to the VPN container. Host SSH is reached
through an approved subnet route to the Dappnode host, such as `10.20.0.1`.
Enabling Tailscale SSH inside this container does not provide a host shell.

## Development

Run `python3 -m unittest discover -s tests -v` with Python 3 and `jq` installed.
The tests use synthetic credentials and a mock containerboot; no tailnet login
is needed. The container smoke test runs a real unauthenticated daemon in an
isolated Docker network namespace:

```sh
docker build --build-arg UPSTREAM_VERSION=v1.104.2 -t tailscale-package:test .
sh tests/container-smoke.sh tailscale-package:test
```

See [authenticated routing validation](tests/AUTHENTICATED-VALIDATION.md) for
manual subnet and exit-node results before and after a package restart.
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ services:
UPSTREAM_VERSION: v1.104.2
image: tailscale.dnp.dappnode.eth:0.1.0
restart: unless-stopped
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.forwarding: "1"
environment:
- TS_AUTHKEY=
- TS_ROUTES=172.33.0.0/16,10.20.0.0/24
Expand Down
66 changes: 66 additions & 0 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/bin/sh
set -eu

# With no key, containerboot already supports browser login. Keep its signal
# handling and persisted-state behavior unchanged on that path.
if [ -z "${TS_AUTHKEY:-}${TS_AUTH_KEY:-}" ]; then
exec containerboot "$@"
fi

umask 077
workdir=$(mktemp -d)
mkfifo "$workdir/output"
child=
logger=

cleanup() {
rm -rf "$workdir"
}

stop() {
trap '' TERM INT
if [ -n "$child" ]; then
kill -TERM "$child" 2>/dev/null || :
wait "$child" 2>/dev/null || :
fi
if [ -n "$logger" ]; then
kill -TERM "$logger" 2>/dev/null || :
wait "$logger" 2>/dev/null || :
fi
exit "$1"
}

trap cleanup EXIT
trap 'stop 143' TERM
trap 'stop 130' INT

# Forward logs immediately. Persist only a rejection marker, never an unbounded
# copy of daemon logs or a credential. Match explicit upstream auth errors so
# network failures and unrelated startup errors keep their original behavior.
(
while IFS= read -r line || [ -n "$line" ]; do
printf '%s\n' "$line"
case "$line" in
"backend error: invalid key:"*|"Error: invalid key:"*|*"Received error: invalid key:"*)
: > "$workdir/key-rejected"
;;
esac
done < "$workdir/output"
) &
logger=$!

containerboot "$@" > "$workdir/output" 2>&1 &
child=$!
result=0
wait "$child" || result=$?
wait "$logger" || :

if [ "$result" -ne 0 ] && [ -f "$workdir/key-rejected" ]; then
printf '%s\n' 'The configured Tailscale auth key was rejected. Falling back to browser login; open the login URL in these logs. Replace or clear the rejected key in the package configuration.'
unset TS_AUTHKEY TS_AUTH_KEY
cleanup
trap - EXIT TERM INT
exec containerboot "$@"
fi

exit "$result"
18 changes: 18 additions & 0 deletions scripts/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu

if ! status=$(tailscale status --json 2>/dev/null); then
printf '%s\n' 'Tailscale status is unavailable.' >&2
exit 1
fi

if ! printf '%s\n' "$status" | jq -e '
.BackendState == "Running" and .Self.Online == true
and ((.Health // []) | length == 0)
' > /dev/null; then
printf '%s\n' "$status" | jq -r '
"Tailscale state=\(.BackendState) online=\(.Self.Online // false)",
(.Health // [])[]
' >&2
exit 1
fi
12 changes: 8 additions & 4 deletions setup-wizard.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
version: "2"
fields:
- id: TS_AUTHKEY
required: false
secret: true
target:
type: environment
name: TS_AUTHKEY
service: tailscale
title: Tailscale Authkey
title: Tailscale auth key (optional)
description: >-
The authkey is used to authenticate your
device with the Tailscale network. You can find your authkey in the
Enter an auth key to sign in automatically, or leave this empty and open
the login URL shown in the package logs. If a key is rejected, the package
falls back to browser login. Replace or clear rejected keys in the package
configuration. You can find your auth key in the
[admin console](https://login.tailscale.com/admin/authkeys).
For more information, see our [docs](https://docs.dappnode.io/docs/user/access-your-dappnode/vpn/tailscale).
For more information, see our [docs](https://docs.dappnode.io/docs/user/access-your-dappnode/vpn/tailscale).
47 changes: 47 additions & 0 deletions tests/AUTHENTICATED-VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Authenticated routing validation

Date: 23 October 2026. Implementation commit: `91e2f90d1c34b0414d3efd63785e92ae085c0c6f`.

The PR image was manually tested on `linux/amd64`, using Tailscale **1.104.2**
and the existing authenticated state volume. Requests originated from a separate
authenticated Linux computer. The phone was not used.

| Check | Original baseline | PR image | PR image after restart | Original image restored |
| --- | --- | --- | --- | --- |
| HTTP challenge through `10.20.0.0/24` | Pass | Pass | Pass | Pass |
| HTTP challenge through `172.33.0.0/16` | Pass | Pass | Pass | Pass |
| IPv4 internet egress through the exit node | Pass | Pass | Pass | Pass |
| Authenticated and online, with no Tailscale health warnings | Pass | Pass | Pass | Pass |

The client opened a temporary challenge page on the Dappnode. It fetched
independent endpoints on both private networks and an external HTTPS public-IP
endpoint. The private endpoints observed the router's expected source-NAT
addresses. The external endpoint returned the Dappnode's public IPv4 address.
Packet observation on `tailscale0`, restricted to this client's test traffic,
also confirmed packets to both private endpoints and the external HTTPS
endpoint. The egress result therefore includes packet-path evidence in addition
to matching public IP addresses.

The PR image's Docker health check reported **healthy**. It preserved the
Tailscale addresses, reconnected from saved authentication after restart, and
had zero automatic restarts. The installed entrypoint and health-check hashes
matched the repository scripts.

The original image was restored after testing. Persistent Compose settings,
the named state volume, network addresses, and other service containers were
preserved. Connectivity was verified again after restoration. Temporary probe
listeners and packet observers were then removed.

The client briefly disabled exit-node use during the first PR-image probe,
which produced a nonmatching public IP. After re-enabling it, the PR-image,
restart, and restoration checks all passed. This client-setting interruption
was not counted as a package failure.

## Limits

- This is a manual authenticated test; CI does not reproduce these steps.
- ARM64 execution and IPv6 internet egress were not tested.
- HTTP subnet routing and HTTPS IPv4 egress were exercised; this does not prove
every application, protocol, DNS setup, or tailnet ACL configuration works.
- No real credentials, public IP addresses, or unrelated packet payloads are
included in this report.
50 changes: 50 additions & 0 deletions tests/container-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/bin/sh
set -eu

image=${1:?Usage: container-smoke.sh IMAGE}
container=
cleanup() {
if [ -n "$container" ]; then
docker rm -f -v "$container" > /dev/null
fi
}
trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT

# No tailnet credentials or host network access. All firewall/sysctl changes
# remain inside this disposable container's network namespace.
container=$(docker run -d --network none --cap-add NET_ADMIN \
--device /dev/net/tun:/dev/net/tun \
--sysctl net.ipv4.ip_forward=1 --sysctl net.ipv6.conf.all.forwarding=1 \
-e TS_AUTHKEY= -e TS_ROUTES=172.33.0.0/16,10.20.0.0/24 \
-e TS_EXTRA_ARGS=--advertise-exit-node "$image")

ready=false
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if docker exec "$container" tailscale status --json > /dev/null 2>&1; then
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
docker logs "$container"
exit 1
fi

docker exec "$container" sh -c '
test "$(cat /proc/sys/net/ipv4/ip_forward)" = 1
test "$(cat /proc/sys/net/ipv6/conf/all/forwarding)" = 1
test "$TS_AUTH_ONCE" = true
test "$TS_BOOT_TIMEOUT" = 1h
test "$TS_DEBUG_FIREWALL_MODE" = auto
'
if docker exec "$container" /usr/local/bin/dappnode-healthcheck; then
printf '%s\n' 'An unauthenticated daemon must not report healthy.' >&2
exit 1
fi
docker stop -t 10 "$container" > /dev/null
exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$container")
test "$exit_code" != 137
printf '%s\n' 'Container startup, forwarding, unauthenticated health, and clean shutdown passed.'
Loading