diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ccb5283 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index a98ed99..bcc135b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,10 @@ 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 @@ -15,3 +19,9 @@ 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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..44ad62b --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 3f31f42..693cd09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..55b24e4 --- /dev/null +++ b/scripts/entrypoint.sh @@ -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" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..7defca2 --- /dev/null +++ b/scripts/healthcheck.sh @@ -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 diff --git a/setup-wizard.yml b/setup-wizard.yml index 38c6b70..a3dccda 100644 --- a/setup-wizard.yml +++ b/setup-wizard.yml @@ -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). \ No newline at end of file + For more information, see our [docs](https://docs.dappnode.io/docs/user/access-your-dappnode/vpn/tailscale). diff --git a/tests/AUTHENTICATED-VALIDATION.md b/tests/AUTHENTICATED-VALIDATION.md new file mode 100644 index 0000000..963f5a9 --- /dev/null +++ b/tests/AUTHENTICATED-VALIDATION.md @@ -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. diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh new file mode 100644 index 0000000..9ac21b0 --- /dev/null +++ b/tests/container-smoke.sh @@ -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.' diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..9bcf1e5 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,207 @@ +import json +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BOOT = r''' +import json, os, pathlib, signal, sys, time +calls = pathlib.Path(os.environ['TEST_CALLS']) +previous = calls.read_text().splitlines() if calls.exists() else [] +with calls.open('a') as f: + f.write(json.dumps({'key': os.getenv('TS_AUTHKEY'), 'alias': os.getenv('TS_AUTH_KEY'), + 'args': sys.argv[1:], 'pid': os.getpid(), + 'stateDir': os.getenv('TS_STATE_DIR'), + 'timeout': os.getenv('TS_BOOT_TIMEOUT')}) + '\n') +results = json.loads(os.environ['TEST_RESULTS']) +if len(previous) >= len(results): + sys.exit(99) +result = results[len(previous)] +if result.get('wait'): + def stop(*_): + pathlib.Path(os.environ['TEST_STOPPED']).write_text('stopped') + sys.exit(0) + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print('READY', flush=True) + while True: + signal.pause() +print(result.get('log', ''), flush=True) +sys.exit(result.get('exit', 0)) +''' +STATUS = r''' +import os, sys +assert sys.argv[1:] == ['status', '--json'] +print(os.environ.get('TEST_STATUS', '')) +sys.exit(int(os.environ.get('TEST_STATUS_EXIT', '0'))) +''' + + +class PackageTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.bin = self.dir / 'bin' + self.bin.mkdir() + for name, content in [('containerboot', BOOT), ('tailscale', STATUS)]: + path = self.bin / name + path.write_text('#!' + sys.executable + '\n' + content) + path.chmod(0o755) + self.state = self.dir / 'state' + self.state.mkdir() + (self.state / 'tailscaled.state').write_text('synthetic saved identity') + self.scratch = self.dir / 'scratch' + self.scratch.mkdir() + self.env = {k: v for k, v in os.environ.items() + if not k.startswith(('TS_', 'TEST_'))} + self.env.update({ + 'PATH': str(self.bin) + os.pathsep + os.environ['PATH'], + 'TMPDIR': str(self.scratch), + 'TEST_CALLS': str(self.dir / 'calls'), + 'TEST_STOPPED': str(self.dir / 'stopped'), + 'TS_STATE_DIR': str(self.state), + 'TS_BOOT_TIMEOUT': '1h', + }) + + def calls(self): + return [json.loads(line) for line in (self.dir / 'calls').read_text().splitlines()] + + def boot(self, results, **extra): + env = {**self.env, 'TEST_RESULTS': json.dumps(results), **extra} + return subprocess.run(['sh', str(ROOT / 'scripts/entrypoint.sh'), '--test-arg'], + env=env, capture_output=True, text=True, timeout=5) + + def test_browser_login_without_key_uses_upstream_directly(self): + result = self.boot([{'log': 'browser login'}]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls()), 1) + self.assertIsNone(self.calls()[0]['key']) + + def test_valid_or_saved_login_does_not_retry(self): + result = self.boot([{'log': 'saved login reused'}], TS_AUTHKEY='synthetic-key') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls()), 1) + self.assertEqual(self.calls()[0]['key'], 'synthetic-key') + + def test_rejected_keys_fall_back_once_without_either_alias(self): + result = self.boot([ + {'exit': 1, 'log': 'backend error: invalid key: API key does not exist'}, + {'log': 'browser login URL'}, + ], TS_AUTHKEY='synthetic-key', TS_AUTH_KEY='synthetic-alias') + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls() + self.assertEqual(len(calls), 2) + self.assertIsNone(calls[1]['key']) + self.assertIsNone(calls[1]['alias']) + self.assertEqual(calls[1]['args'], ['--test-arg']) + self.assertEqual(calls[1]['timeout'], '1h') + self.assertIn('Falling back to browser login', result.stdout) + self.assertNotIn('synthetic-key', result.stdout + result.stderr) + self.assertEqual((self.state / 'tailscaled.state').read_text(), 'synthetic saved identity') + self.assertEqual(list(self.scratch.iterdir()), []) + + def test_expired_key_using_upstream_alias_recovers(self): + result = self.boot([ + {'exit': 1, 'log': '2026/10/23 Received error: invalid key: key has expired'}, + {'log': 'waiting for browser login'}, + ], TS_AUTH_KEY='synthetic-expired-key') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls()), 2) + self.assertIsNone(self.calls()[1]['alias']) + + def test_consumed_key_recovers(self): + result = self.boot([ + {'exit': 1, 'log': 'Error: invalid key: key already used'}, + {'exit': 0}, + ], TS_AUTHKEY='synthetic-consumed-key') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls()), 2) + + def test_network_and_tls_errors_do_not_discard_key_or_retry(self): + for log in ['control: connection timed out', 'x509: certificate signed by unknown authority']: + with self.subTest(log=log): + (self.dir / 'calls').unlink(missing_ok=True) + result = self.boot([{'exit': 23, 'log': log}], TS_AUTHKEY='synthetic-key') + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(len(self.calls()), 1) + self.assertNotIn('Falling back', result.stdout) + + def test_fallback_failure_keeps_exit_status_without_looping(self): + result = self.boot([ + {'exit': 1, 'log': 'backend error: invalid key: key has expired'}, + {'exit': 17, 'log': 'startup timed out'}, + ], TS_AUTHKEY='synthetic-key') + self.assertEqual(result.returncode, 17, result.stderr) + self.assertEqual(len(self.calls()), 2) + + def test_success_does_not_retry_even_if_old_error_is_logged(self): + result = self.boot([{'log': 'backend error: invalid key: historical warning'}], + TS_AUTHKEY='synthetic-key') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls()), 1) + + def test_term_reaches_daemon_and_does_not_start_fallback(self): + env = {**self.env, 'TEST_RESULTS': json.dumps([{'wait': True}]), + 'TS_AUTHKEY': 'synthetic-key'} + proc = subprocess.Popen(['sh', str(ROOT / 'scripts/entrypoint.sh')], + env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, start_new_session=True) + try: + self.assertTrue(select.select([proc.stdout], [], [], 5)[0], 'daemon did not start') + self.assertEqual(proc.stdout.readline().strip(), 'READY') + proc.send_signal(signal.SIGTERM) + output, _ = proc.communicate(timeout=5) + self.assertEqual(proc.returncode, 143, output) + self.assertTrue((self.dir / 'stopped').exists()) + self.assertEqual(len(self.calls()), 1) + self.assertEqual(list(self.scratch.iterdir()), []) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + + def health(self, status, exit_code=0): + return subprocess.run(['sh', str(ROOT / 'scripts/healthcheck.sh')], + env={**self.env, 'TEST_STATUS': json.dumps(status), + 'TEST_STATUS_EXIT': str(exit_code)}, + capture_output=True, text=True, timeout=5) + + def test_health_accepts_online_running_with_no_warnings(self): + for health in [None, []]: + result = self.health({'BackendState': 'Running', 'Self': {'Online': True}, 'Health': health}) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_health_rejects_login_wait_offline_and_router_errors(self): + for status in [ + {'BackendState': 'NeedsLogin'}, + {'BackendState': 'Running', 'Self': {'Online': False}}, + {'BackendState': 'Running', 'Self': {'Online': True}, 'Health': ['firewall setup failed']}, + ]: + with self.subTest(status=status): + result = self.health(status) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Tailscale state=', result.stderr) + if status.get('Health'): + self.assertIn('firewall setup failed', result.stderr) + + def test_health_rejects_unavailable_daemon(self): + result = self.health({}, exit_code=1) + self.assertNotEqual(result.returncode, 0) + self.assertIn('unavailable', result.stderr) + + def test_health_rejects_malformed_status(self): + result = subprocess.run(['sh', str(ROOT / 'scripts/healthcheck.sh')], + env={**self.env, 'TEST_STATUS': 'not json'}, + capture_output=True, text=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main()