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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.git
reporter/target
build_*
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Reporter checks

on:
pull_request:
push:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install Rust
run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy
- name: Check formatting
run: cargo +1.99.0 fmt --manifest-path reporter/Cargo.toml --check
- name: Check Clippy
run: cargo +1.99.0 clippy --manifest-path reporter/Cargo.toml --locked --all-targets -- -D warnings
- name: Test Unix-socket reporting and shutdown
run: cargo +1.99.0 test --manifest-path reporter/Cargo.toml --locked
- name: Validate Compose
run: docker compose config --quiet
- name: Build Tailscale image
run: docker compose build tailscale
image:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Build reporter image
run: docker buildx build --platform "${{ matrix.platform }}" --load -t tailscale-reporter:test reporter
- name: Verify static executable starts
run: |
output=$(docker run --rm -e DAPPMANAGER_URL=invalid tailscale-reporter:test 2>&1) && exit 1
printf '%s\n' "$output" | grep -Fx 'invalid DAPPMANAGER_URL'
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
build_*
releases.json
docker-compose-tmp.yml


# Rust reporter build output
/reporter/target/

2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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

# Replace the complete system trust store, including individual hashed certificates.
RUN rm -rf /etc/ssl/certs /usr/share/ca-certificates
Expand Down
119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Tailscale for Dappnode

The `tailscale` service runs upstream Tailscale containerboot. The `reporter`
service is a small Rust binary that reads the daemon's structured LocalAPI status
and sends it to Dappmanager. It never reads container logs, starts a Tailscale
login, or changes daemon configuration.

## Signing in

Provide `TS_AUTHKEY` for automatic login, or leave it empty for browser login.
The image allows one hour for startup (`TS_BOOT_TIMEOUT=1h`); an explicit override
is respected. If the window expires, upstream containerboot exits and Docker
restarts the service. `TS_AUTH_ONCE=true` and the existing `state` volume retain
the device identity and reuse saved authentication across restarts.

With the accompanying Dappmanager integration, open **VPN → Tailscale** and
select **Sign in to Tailscale**. This button requires compatible Dappmanager UI
support. Older versions only expose the reporter's JSON in package sent data;
upstream Tailscale also continues printing its normal login instructions.

A rejected auth key does **not** automatically fall back to browser login.
Clear both `TS_AUTHKEY` and its upstream alias `TS_AUTH_KEY`, if set, then restart
the package. This integration does not replace upstream authentication handling.

After login, approve the subnet routes and configure tailnet DNS as described in
the [Dappnode Tailscale guide](https://docs.dappnode.io/docs/user/access-your-dappnode/vpn/tailscale).
Being authenticated and online does not prove subnet routing or DNS works.
This integration preserves the package's existing routing configuration.

## Containers and socket

Only `tailscale` mounts the persistent identity volume at `/var/lib/tailscale`.
A second volume shares `/run/tailscale/tailscaled.sock` with `reporter`. The
reporter mounts this directory read-only, drops all Linux capabilities, and
opens no listening port. It needs to run as root
to access the root-owned socket. It has no Docker socket or TUN access.

A read-only volume mount does **not** make Unix-socket requests read-only. Treat
the reporter as trusted code with LocalAPI access. Its only daemon request is
`GET /localapi/v0/status?peers=false`, with Host `local-tailscaled.sock`. Restarting
either service or replacing the socket is supported; no startup ordering is
required. Do not expose this socket to other packages.

The package uses Dappnode-supported Compose options. Its current validator does
not allow `read_only` (root filesystem) or `sysctls` service properties.

## Reporting contract

The reporter POSTs to:

```text
http://dappmanager.dappnode/data-send?key=tailscale.status
Content-Type: text/plain
```

The body is a compact JSON **string**, compatible with Dappmanager's existing
`data-send` endpoint and its 700-character limit:

```json
{"version":1,"state":"NeedsLogin","online":false,"authUrl":"https://login.tailscale.com/a/EXAMPLE","updatedAt":1793260800}
```

Fields:

| Field | Meaning |
| --- | --- |
| `version` | Contract version, currently `1`. |
| `state` | `NoState`, `NeedsLogin`, `NeedsMachineAuth`, `Stopped`, `Starting`, `Running`, or `Unavailable`. Unknown upstream states become `Unavailable`. |
| `online` | True only when state is `Running` and `Self.Online` is true. |
| `authUrl` | Current official HTTPS login URL when state is `NeedsLogin`; otherwise `null`. Invalid or oversized URLs are omitted as `null`, never truncated. |
| `updatedAt` | Unix epoch **seconds** when the status was observed, including each heartbeat. |

The reporter polls every two seconds, publishes changes immediately, and sends
unchanged status every 30 seconds. Each HTTP request has a three-second timeout.
After failed delivery it samples the daemon again and retries on the next poll;
it does not replay a queue of old URLs. Delivery failures never stop Tailscale.
LocalAPI failure publishes `Unavailable` with a null link. SIGTERM/SIGINT cancels
in-flight work and attempts a final `Unavailable` report for at most two seconds.

Dappmanager must expire reports after **90 seconds**, hide links when either
service is stopped, and independently validate the payload and login URL. Graceful
clearing cannot cover crashes, SIGKILL, or network outages. The timestamp in the
payload is not a Tailscale login-link expiration time.

Both HTTP clients bypass proxy environment variables and refuse redirects.
LocalAPI responses are limited to 64 KiB. Auth URLs are limited to 512 ASCII bytes
and must point to `https://login.tailscale.com/a/...`, without credentials,
fragments, whitespace, or control characters. Headscale/custom login servers are
not supported by this first contract. No auth key, peer list, status document,
or login URL is written to reporter logs. Dappmanager's current `packageSentData`
cache does persist the last payload; consumers must enforce the freshness and
display requirements above.

Reporter environment (normally no overrides are needed):

| Variable | Default |
| --- | --- |
| `TS_SOCKET` | `/run/tailscale/tailscaled.sock` |
| `DAPPMANAGER_URL` | `http://dappmanager.dappnode` (an HTTP origin with no path, query, or credentials) |

If changing `TS_SOCKET`, configure the same path in both services and keep it
inside the shared socket directory. Auth keys belong only in `tailscale`.

## Development

```sh
cargo fmt --manifest-path reporter/Cargo.toml --check
cargo clippy --manifest-path reporter/Cargo.toml --locked --all-targets -- -D warnings
cargo test --manifest-path reporter/Cargo.toml --locked
docker compose config --quiet
docker compose build
```

The Rust tests use synthetic login URLs, a Unix-socket LocalAPI fixture, and an
HTTP Dappmanager fixture. They do not need real credentials or access to a tailnet.
Docker builds compile the reporter natively for the selected platform; the final
image contains only its static executable. CI builds both supported architectures.
An authenticated subnet/DNS check from another tailnet device remains a manual
integration test.
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@ services:
- TS_ROUTES=172.33.0.0/16,10.20.0.0/24
- TS_EXTRA_ARGS=--advertise-exit-node
- TS_DEBUG_FIREWALL_MODE=auto
- TS_SOCKET=/run/tailscale/tailscaled.sock
volumes:
- state:/var/lib/tailscale
- socket:/run/tailscale
cap_add:
- net_admin
- sys_module
devices:
- /dev/net/tun:/dev/net/tun
reporter:
build:
context: reporter
image: reporter.tailscale.dnp.dappnode.eth:0.1.0
restart: unless-stopped
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- socket:/run/tailscale:ro
volumes:
state: null
socket: null
1 change: 1 addition & 0 deletions reporter/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target
Loading
Loading