diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfd06aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +reporter/target +build_* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..013ed3f --- /dev/null +++ b/.github/workflows/test.yml @@ -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' diff --git a/.gitignore b/.gitignore index f41f9a9..a6bbafc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ build_* releases.json docker-compose-tmp.yml - \ No newline at end of file + +# Rust reporter build output +/reporter/target/ + diff --git a/Dockerfile b/Dockerfile index a98ed99..9ae8897 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4df899 --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 3f31f42..fb78a4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/reporter/.dockerignore b/reporter/.dockerignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/reporter/.dockerignore @@ -0,0 +1 @@ +target diff --git a/reporter/Cargo.lock b/reporter/Cargo.lock new file mode 100644 index 0000000..48eebf9 --- /dev/null +++ b/reporter/Cargo.lock @@ -0,0 +1,920 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tailscale-status-reporter" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/reporter/Cargo.toml b/reporter/Cargo.toml new file mode 100644 index 0000000..8760b8d --- /dev/null +++ b/reporter/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tailscale-status-reporter" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0" +publish = false + +[dependencies] +reqwest = { version = "0.13", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "time", "signal"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["net", "io-util", "test-util"] } + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/reporter/Dockerfile b/reporter/Dockerfile new file mode 100644 index 0000000..ccaf434 --- /dev/null +++ b/reporter/Dockerfile @@ -0,0 +1,9 @@ +FROM rust:1.99.0-alpine3.23 AS build +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release --locked + +FROM scratch +COPY --from=build /build/target/release/tailscale-status-reporter /tailscale-status-reporter +ENTRYPOINT ["/tailscale-status-reporter"] diff --git a/reporter/src/error.rs b/reporter/src/error.rs new file mode 100644 index 0000000..93ff8fb --- /dev/null +++ b/reporter/src/error.rs @@ -0,0 +1,40 @@ +use std::fmt; + +#[derive(Debug)] +pub enum Error { + InvalidLoginUrl, + InvalidManagerUrl, + InvalidManagerOrigin, + InvalidStatus, + LocalApiUnavailable, + LocalApiResponse(reqwest::StatusCode), + LocalApiRead, + LocalApiTooLarge, + PayloadSerialization, + PayloadTooLarge, + ManagerUnavailable, + ManagerResponse(reqwest::StatusCode), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLoginUrl => f.write_str("invalid Tailscale login URL"), + Self::InvalidManagerUrl => f.write_str("invalid DAPPMANAGER_URL"), + Self::InvalidManagerOrigin => { + f.write_str("DAPPMANAGER_URL must be an HTTP origin without credentials") + } + Self::InvalidStatus => f.write_str("invalid LocalAPI status"), + Self::LocalApiUnavailable => f.write_str("LocalAPI unavailable"), + Self::LocalApiResponse(status) => write!(f, "LocalAPI returned HTTP {status}"), + Self::LocalApiRead => f.write_str("cannot read LocalAPI"), + Self::LocalApiTooLarge => f.write_str("LocalAPI response too large"), + Self::PayloadSerialization => f.write_str("cannot serialize status"), + Self::PayloadTooLarge => f.write_str("status payload exceeds data-send limit"), + Self::ManagerUnavailable => f.write_str("Dappmanager unavailable"), + Self::ManagerResponse(status) => write!(f, "Dappmanager returned HTTP {status}"), + } + } +} + +impl std::error::Error for Error {} diff --git a/reporter/src/lib.rs b/reporter/src/lib.rs new file mode 100644 index 0000000..c5851ef --- /dev/null +++ b/reporter/src/lib.rs @@ -0,0 +1,210 @@ +mod error; +mod urls; + +use std::{path::Path, time::Duration}; + +use reqwest::{Client, redirect::Policy}; +use serde::{Deserialize, Serialize}; +use tokio::time::Instant; + +pub use error::Error; +use urls::optional_login_url; +pub use urls::{LoginUrl, ManagerEndpoint}; + +pub const POLL_INTERVAL: Duration = Duration::from_secs(2); +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +pub const DEFAULT_SOCKET: &str = "/run/tailscale/tailscaled.sock"; +pub const DEFAULT_MANAGER: &str = "http://dappmanager.dappnode"; +const STATUS_URL: &str = "http://local-tailscaled.sock/localapi/v0/status?peers=false"; +const MAX_STATUS_BYTES: usize = 64 * 1024; +const MAX_PAYLOAD_BYTES: usize = 700; + +#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum State { + NoState, + NeedsLogin, + NeedsMachineAuth, + Stopped, + Starting, + Running, + #[serde(other)] + Unavailable, +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + version: u8, + state: State, + online: bool, + auth_url: Option, +} + +#[derive(Deserialize)] +struct LocalStatus { + #[serde(rename = "BackendState")] + state: State, + #[serde(rename = "AuthURL", default, deserialize_with = "optional_login_url")] + auth_url: Option, + #[serde(rename = "Self")] + self_status: Option, +} + +#[derive(Deserialize)] +struct SelfStatus { + #[serde(rename = "Online", default)] + online: bool, +} + +impl Snapshot { + pub fn unavailable() -> Self { + Self { + version: 1, + state: State::Unavailable, + online: false, + auth_url: None, + } + } + + fn from_json(body: &[u8]) -> Result { + let status: LocalStatus = serde_json::from_slice(body).map_err(|_| Error::InvalidStatus)?; + let state = status.state; + let auth_url = status.auth_url.filter(|_| state == State::NeedsLogin); + Ok(Self { + version: 1, + state, + online: state == State::Running && status.self_status.is_some_and(|s| s.online), + auth_url, + }) + } + + fn payload(&self, updated_at: u64) -> Result, Error> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Payload<'a> { + #[serde(flatten)] + snapshot: &'a Snapshot, + updated_at: u64, + } + let body = serde_json::to_vec(&Payload { + snapshot: self, + updated_at, + }) + .map_err(|_| Error::PayloadSerialization)?; + if body.len() > MAX_PAYLOAD_BYTES { + return Err(Error::PayloadTooLarge); + } + Ok(body) + } +} + +fn http_client() -> reqwest::ClientBuilder { + Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(REQUEST_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .retry(reqwest::retry::never()) +} + +pub struct Reporter { + local: Client, + manager: Client, + endpoint: ManagerEndpoint, + delivered: Option<(Snapshot, Instant)>, + delivery_failed: bool, + status_failed: bool, +} + +impl Reporter { + pub fn new(socket: &Path, endpoint: ManagerEndpoint) -> Result { + Ok(Self { + local: http_client().unix_socket(socket).build()?, + manager: http_client().build()?, + endpoint, + delivered: None, + delivery_failed: false, + status_failed: false, + }) + } + + async fn read_status(&self) -> Result { + let mut response = self + .local + .get(STATUS_URL) + .send() + .await + .map_err(|_| Error::LocalApiUnavailable)?; + if !response.status().is_success() { + return Err(Error::LocalApiResponse(response.status())); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| Error::LocalApiRead)? { + if body.len() + chunk.len() > MAX_STATUS_BYTES { + return Err(Error::LocalApiTooLarge); + } + body.extend_from_slice(&chunk); + } + Snapshot::from_json(&body) + } + + pub async fn publish(&mut self, snapshot: Snapshot, updated_at: u64) -> Result<(), Error> { + let body = snapshot.payload(updated_at)?; + let response = self + .manager + .post(self.endpoint.as_url().clone()) + .header(reqwest::header::CONTENT_TYPE, "text/plain") + .body(body) + .send() + .await + .map_err(|_| Error::ManagerUnavailable)?; + // Do not follow redirects, print response bodies, or log private URLs. + if !response.status().is_success() { + return Err(Error::ManagerResponse(response.status())); + } + self.delivered = Some((snapshot, Instant::now())); + Ok(()) + } + + pub async fn tick(&mut self, updated_at: u64) { + let snapshot = match self.read_status().await { + Ok(snapshot) => { + if self.status_failed { + eprintln!("Tailscale status is available again."); + } + self.status_failed = false; + snapshot + } + Err(_) => { + if !self.status_failed { + eprintln!("Tailscale status unavailable; clearing reported login link."); + } + self.status_failed = true; + Snapshot::unavailable() + } + }; + let should_send = self.delivered.as_ref().is_none_or(|(previous, sent_at)| { + previous != &snapshot || sent_at.elapsed() >= HEARTBEAT_INTERVAL + }); + if should_send { + match self.publish(snapshot, updated_at).await { + Ok(()) => { + if self.delivery_failed { + eprintln!("Tailscale status reporting to Dappmanager resumed."); + } + self.delivery_failed = false; + } + Err(_) => { + if !self.delivery_failed { + eprintln!("Cannot report Tailscale status to Dappmanager; retrying."); + } + self.delivery_failed = true; + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/reporter/src/main.rs b/reporter/src/main.rs new file mode 100644 index 0000000..5c6823f --- /dev/null +++ b/reporter/src/main.rs @@ -0,0 +1,69 @@ +use std::{env, path::PathBuf, process::ExitCode, time::SystemTime}; + +use tailscale_status_reporter::{ + DEFAULT_MANAGER, DEFAULT_SOCKET, ManagerEndpoint, POLL_INTERVAL, Reporter, Snapshot, +}; +use tokio::{ + signal::unix::{SignalKind, signal}, + time::{Duration, MissedTickBehavior, interval, timeout}, +}; + +fn unix_time() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let socket = env::var_os("TS_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET)); + let manager = env::var("DAPPMANAGER_URL").unwrap_or_else(|_| DEFAULT_MANAGER.to_owned()); + let endpoint = match manager.parse::() { + Ok(endpoint) => endpoint, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + let Ok(mut reporter) = Reporter::new(&socket, endpoint) else { + eprintln!("Cannot initialize reporter HTTP clients."); + return ExitCode::FAILURE; + }; + let (Ok(mut terminate), Ok(mut interrupt)) = ( + signal(SignalKind::terminate()), + signal(SignalKind::interrupt()), + ) else { + eprintln!("Cannot initialize reporter shutdown handling."); + return ExitCode::FAILURE; + }; + let shutdown = async move { + tokio::select! { + _ = terminate.recv() => {}, + _ = interrupt.recv() => {}, + } + }; + tokio::pin!(shutdown); + let mut poll = interval(POLL_INTERVAL); + poll.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + tokio::select! { + () = &mut shutdown => break, + _ = poll.tick() => {}, + } + tokio::select! { + () = &mut shutdown => break, + () = reporter.tick(unix_time()) => {}, + } + } + // A final clear is best effort. Freshness/container checks are still required + // in Dappmanager because SIGKILL and network outages cannot deliver it. + let _ = timeout( + Duration::from_secs(2), + reporter.publish(Snapshot::unavailable(), unix_time()), + ) + .await; + ExitCode::SUCCESS +} diff --git a/reporter/src/tests.rs b/reporter/src/tests.rs new file mode 100644 index 0000000..c28aa38 --- /dev/null +++ b/reporter/src/tests.rs @@ -0,0 +1,128 @@ +use super::*; +use serde_json::{Value, json}; + +fn snapshot(state: &str, url: Value) -> Snapshot { + Snapshot::from_json( + &serde_json::to_vec(&json!({ + "BackendState": state, "AuthURL": url, "Self": {"Online": true} + })) + .unwrap(), + ) + .unwrap() +} + +#[test] +fn reports_login_url_only_while_needing_login() { + let url = "https://login.tailscale.com/a/synthetic?x=1&y=a%2Bb"; + let needs_login = snapshot("NeedsLogin", json!(url)); + assert_eq!(needs_login.auth_url.as_ref().map(AsRef::as_ref), Some(url)); + assert!(!needs_login.online); + for state in [ + "NoState", + "NeedsMachineAuth", + "Stopped", + "Starting", + "Running", + "FutureState", + ] { + assert!(snapshot(state, json!(url)).auth_url.is_none()); + } + assert!(snapshot("Running", json!(url)).online); +} + +#[test] +fn missing_or_null_self_is_valid_while_unauthenticated() { + for input in [ + r#"{"BackendState":"NeedsLogin"}"#, + r#"{"BackendState":"NeedsLogin","Self":null,"AuthURL":null}"#, + ] { + let snapshot = Snapshot::from_json(input.as_bytes()).unwrap(); + assert!(snapshot.state == State::NeedsLogin); + assert!(snapshot.auth_url.is_none()); + assert!(!snapshot.online); + } +} + +#[test] +fn unknown_states_fail_closed() { + assert!(snapshot("FutureState", Value::Null) == Snapshot::unavailable()); +} + +#[test] +fn rejects_malformed_status_instead_of_reusing_a_link() { + for input in [ + "", + "garbage", + "{}", + "null", + "[]", + "{} {}", + r#"{"BackendState":123}"#, + r#"{"BackendState":"Running","Self":{"Online":"true"}}"#, + r#"{"BackendState":"NeedsLogin","AuthURL":123}"#, + ] { + assert!(matches!( + Snapshot::from_json(input.as_bytes()), + Err(Error::InvalidStatus) + )); + } +} + +#[test] +fn rejects_unsafe_and_oversized_urls_without_losing_login_state() { + for url in [ + "http://login.tailscale.com/a/test", + "https://login.tailscale.com.attacker.test/a/test", + "https://login.tailscale.com@attacker.test/a/test", + "https://attacker.test/a/test", + "https://login.tailscale.com/admin/machines", + "https://login.tailscale.com/a/", + "https://login.tailscale.com/a/test#fragment", + "https://login.tailscale.com/a/test\n", + "https://login.tailscale.com/a/test\\evil", + "https://login.tailscale.com/a/\"test", + "https://login.tailscale.com/a/é", + &format!("https://login.tailscale.com/a/{}", "x".repeat(512)), + ] { + let snapshot = snapshot("NeedsLogin", json!(url)); + assert!(snapshot.state == State::NeedsLogin); + assert!(snapshot.auth_url.is_none()); + } +} + +#[test] +fn largest_payload_fits_existing_data_send_limit() { + let prefix = "https://login.tailscale.com/a/"; + let url = format!("{prefix}{}", "x".repeat(512 - prefix.len())); + let snapshot = snapshot("NeedsLogin", json!(url)); + assert!(snapshot.auth_url.is_some()); + let body = snapshot.payload(u64::MAX).unwrap(); + assert!(body.len() <= MAX_PAYLOAD_BYTES); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["authUrl"], url); + assert_eq!(payload["updatedAt"], u64::MAX); + assert_eq!(payload["version"], 1); + assert_eq!(payload.as_object().unwrap().len(), 5); +} + +#[test] +fn manager_endpoint_requires_an_internal_http_origin() { + assert_eq!( + DEFAULT_MANAGER + .parse::() + .unwrap() + .as_url() + .as_str(), + "http://dappmanager.dappnode/data-send?key=tailscale.status" + ); + for invalid in [ + "invalid", + "https://dappmanager.dappnode", + "http://user:secret@host", + "http://host/path", + "http://host?query", + "http://host#fragment", + ] { + assert!(invalid.parse::().is_err()); + } +} diff --git a/reporter/src/urls.rs b/reporter/src/urls.rs new file mode 100644 index 0000000..ee03b36 --- /dev/null +++ b/reporter/src/urls.rs @@ -0,0 +1,89 @@ +use std::str::FromStr; + +use reqwest::Url; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::Error; + +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct LoginUrl(String); + +impl AsRef for LoginUrl { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl FromStr for LoginUrl { + type Err = Error; + + fn from_str(raw: &str) -> Result { + // Check the wire representation before URL parsing can normalize it. + // Preserve valid query strings exactly; never truncate a login URL. + if raw.len() > 512 + || !raw.starts_with("https://login.tailscale.com/a/") + || !raw.is_ascii() + || raw + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + || raw.contains(['\\', '"', '<', '>']) + { + return Err(Error::InvalidLoginUrl); + } + let url = Url::parse(raw).map_err(|_| Error::InvalidLoginUrl)?; + if url.scheme() != "https" + || url.host_str() != Some("login.tailscale.com") + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.fragment().is_some() + || !url.path().starts_with("/a/") + || url.path().len() <= 3 + { + return Err(Error::InvalidLoginUrl); + } + Ok(Self(raw.to_owned())) + } +} + +pub(crate) fn optional_login_url<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let raw = Option::::deserialize(deserializer)?; + // Invalid links must not discard an otherwise useful NeedsLogin status. + Ok(raw.and_then(|url| url.parse().ok())) +} + +pub struct ManagerEndpoint(Url); + +impl ManagerEndpoint { + pub(crate) fn as_url(&self) -> &Url { + &self.0 + } +} + +impl FromStr for ManagerEndpoint { + type Err = Error; + + fn from_str(base: &str) -> Result { + let base = Url::parse(base).map_err(|_| Error::InvalidManagerUrl)?; + if base.scheme() != "http" + || base.host_str().is_none() + || !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + || base.path() != "/" + { + return Err(Error::InvalidManagerOrigin); + } + let mut endpoint = base + .join("data-send") + .map_err(|_| Error::InvalidManagerOrigin)?; + endpoint + .query_pairs_mut() + .append_pair("key", "tailscale.status"); + Ok(Self(endpoint)) + } +} diff --git a/reporter/tests/reporting.rs b/reporter/tests/reporting.rs new file mode 100644 index 0000000..dded5d9 --- /dev/null +++ b/reporter/tests/reporting.rs @@ -0,0 +1,388 @@ +use std::{ + process::{Child, Command, Stdio}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use serde_json::{Value, json}; +use tailscale_status_reporter::Reporter; +use tempfile::TempDir; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, UnixListener}, + task::JoinHandle, + time::{sleep, timeout}, +}; + +const LOGIN_URL: &str = "https://login.tailscale.com/a/synthetic?first=1&second=a%2Bb"; + +#[derive(Clone)] +struct Reply { + status: u16, + body: Vec, + delay: Duration, +} + +impl Reply { + fn json(value: Value) -> Self { + Self { + status: 200, + body: serde_json::to_vec(&value).unwrap(), + delay: Duration::ZERO, + } + } +} + +struct Request { + headers: String, + body: Vec, +} + +struct Server { + reply: Arc>, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn handle( + mut stream: S, + reply: Arc>, + requests: Arc>>, +) { + let mut bytes = Vec::new(); + let header_end = loop { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0 && bytes.len() < 16384); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break end + 4; + } + }; + let headers = String::from_utf8(bytes[..header_end].to_vec()).unwrap(); + let content_length: usize = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + } + requests.lock().unwrap().push(Request { + headers, + body: bytes[header_end..].to_vec(), + }); + let reply = reply.lock().unwrap().clone(); + sleep(reply.delay).await; + let location = if (300..400).contains(&reply.status) { + "Location: /unexpected-redirect\r\n" + } else { + "" + }; + let headers = format!( + "HTTP/1.1 {} Fixture\r\n{location}Content-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reply.body.len() + ); + // Timed-out clients can disconnect before the fixture writes its response. + if stream.write_all(headers.as_bytes()).await.is_ok() { + let _ = stream.write_all(&reply.body).await; + } +} + +async fn local_server() -> (Server, TempDir) { + let dir = tempfile::tempdir_in("/tmp").unwrap(); + let listener = UnixListener::bind(dir.path().join("tailscaled.sock")).unwrap(); + let reply = Arc::new(Mutex::new(Reply::json(json!({ + "BackendState": "NeedsLogin", "AuthURL": LOGIN_URL, "Self": null + })))); + let requests = Arc::new(Mutex::new(Vec::new())); + let task = tokio::spawn({ + let reply = reply.clone(); + let requests = requests.clone(); + async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + handle(stream, reply.clone(), requests.clone()).await; + } + } + }); + ( + Server { + reply, + requests, + task, + }, + dir, + ) +} + +async fn manager_server() -> (Server, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let reply = Arc::new(Mutex::new(Reply::json(json!({})))); + let requests = Arc::new(Mutex::new(Vec::new())); + let task = tokio::spawn({ + let reply = reply.clone(); + let requests = requests.clone(); + async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + handle(stream, reply.clone(), requests.clone()).await; + } + } + }); + ( + Server { + reply, + requests, + task, + }, + origin, + ) +} + +fn payload(server: &Server, index: usize) -> Value { + serde_json::from_slice(&server.requests.lock().unwrap()[index].body).unwrap() +} + +#[tokio::test] +async fn sends_private_url_in_text_body_and_clears_it_on_login() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + reporter.tick(100).await; + assert_eq!( + payload(&manager, 0), + json!({ + "version": 1, "state": "NeedsLogin", "online": false, "authUrl": LOGIN_URL, "updatedAt": 100 + }) + ); + let headers = manager.requests.lock().unwrap()[0] + .headers + .to_ascii_lowercase(); + assert!(headers.starts_with("post /data-send?key=tailscale.status http/1.1")); + assert!(headers.contains("content-type: text/plain\r\n")); + assert!(!headers.contains("synthetic")); + let local_headers = local.requests.lock().unwrap()[0] + .headers + .to_ascii_lowercase(); + assert!(local_headers.starts_with("get /localapi/v0/status?peers=false http/1.1")); + assert!(local_headers.contains("host: local-tailscaled.sock\r\n")); + + *local.reply.lock().unwrap() = Reply::json(json!({ + "BackendState": "Running", "AuthURL": LOGIN_URL, "Self": {"Online": true} + })); + reporter.tick(102).await; + assert_eq!(payload(&manager, 1)["state"], "Running"); + assert_eq!(payload(&manager, 1)["online"], true); + assert!(payload(&manager, 1)["authUrl"].is_null()); + reporter.tick(104).await; + assert_eq!(manager.requests.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn retries_rejected_delivery_and_publishes_rotated_link() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + manager.reply.lock().unwrap().status = 503; + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + reporter.tick(100).await; + manager.reply.lock().unwrap().status = 200; + reporter.tick(102).await; + assert_eq!(payload(&manager, 1)["authUrl"], LOGIN_URL); + *local.reply.lock().unwrap() = Reply::json(json!({ + "BackendState":"NeedsLogin", "AuthURL":"https://login.tailscale.com/a/replaced" + })); + reporter.tick(104).await; + assert_eq!( + payload(&manager, 2)["authUrl"], + "https://login.tailscale.com/a/replaced" + ); +} + +#[tokio::test] +async fn malformed_oversized_and_failed_status_clear_the_link() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + for bad_reply in [ + Reply { + status: 200, + body: b"not JSON".to_vec(), + delay: Duration::ZERO, + }, + Reply { + status: 200, + body: vec![b'x'; 65537], + delay: Duration::ZERO, + }, + Reply { + status: 500, + body: Vec::new(), + delay: Duration::ZERO, + }, + ] { + *local.reply.lock().unwrap() = + Reply::json(json!({"BackendState":"NeedsLogin", "AuthURL":LOGIN_URL})); + reporter.tick(100).await; + *local.reply.lock().unwrap() = bad_reply; + reporter.tick(102).await; + let last = manager.requests.lock().unwrap().len() - 1; + assert_eq!(payload(&manager, last)["state"], "Unavailable"); + assert!(payload(&manager, last)["authUrl"].is_null()); + } +} + +#[tokio::test] +async fn survives_socket_disappearance_and_reconnects_after_daemon_restart() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + let socket = dir.path().join("tailscaled.sock"); + let mut reporter = Reporter::new(&socket, origin.parse().unwrap()).unwrap(); + reporter.tick(100).await; + local.task.abort(); + std::fs::remove_file(&socket).unwrap(); + reporter.tick(102).await; + assert_eq!(payload(&manager, 1)["state"], "Unavailable"); + let listener = UnixListener::bind(&socket).unwrap(); + let restarted = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + handle(stream, local.reply.clone(), local.requests.clone()).await; + }); + reporter.tick(104).await; + restarted.await.unwrap(); + assert_eq!(payload(&manager, 2)["authUrl"], LOGIN_URL); +} + +#[tokio::test] +async fn localapi_requests_have_a_deadline() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + local.reply.lock().unwrap().delay = Duration::from_secs(30); + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + timeout(Duration::from_secs(5), reporter.tick(100)) + .await + .unwrap(); + assert_eq!(payload(&manager, 0)["state"], "Unavailable"); +} + +#[tokio::test] +async fn manager_redirects_never_receive_a_second_copy_of_the_link() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + manager.reply.lock().unwrap().status = 307; + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + reporter.tick(100).await; + assert_eq!(manager.requests.lock().unwrap().len(), 1); + manager.reply.lock().unwrap().status = 200; + reporter.tick(102).await; + assert_eq!(manager.requests.lock().unwrap().len(), 2); + assert_eq!(payload(&manager, 1)["authUrl"], LOGIN_URL); + assert_eq!(local.requests.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn manager_requests_have_a_deadline() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + manager.reply.lock().unwrap().delay = Duration::from_secs(30); + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + timeout(Duration::from_secs(5), reporter.tick(100)) + .await + .unwrap(); + assert_eq!(manager.requests.lock().unwrap().len(), 1); + assert!(!local.requests.lock().unwrap().is_empty()); +} + +struct Process(Child); + +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[tokio::test] +async fn binary_clears_link_and_exits_on_sigterm_without_logging_it() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + let mut process = Process( + Command::new(env!("CARGO_BIN_EXE_tailscale-status-reporter")) + .env("TS_SOCKET", dir.path().join("tailscaled.sock")) + .env("DAPPMANAGER_URL", origin) + .env("HTTP_PROXY", "http://127.0.0.1:1") + .env("ALL_PROXY", "http://127.0.0.1:1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(), + ); + timeout(Duration::from_secs(5), async { + while manager.requests.lock().unwrap().is_empty() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(payload(&manager, 0)["authUrl"], LOGIN_URL); + assert!( + Command::new("kill") + .args(["-TERM", &process.0.id().to_string()]) + .status() + .unwrap() + .success() + ); + timeout(Duration::from_secs(4), async { + while process.0.try_wait().unwrap().is_none() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(process.0.wait().unwrap().success()); + assert_eq!(payload(&manager, 1)["state"], "Unavailable"); + assert!(payload(&manager, 1)["authUrl"].is_null()); + let mut logs = String::new(); + std::io::Read::read_to_string(&mut process.0.stderr.take().unwrap(), &mut logs).unwrap(); + std::io::Read::read_to_string(&mut process.0.stdout.take().unwrap(), &mut logs).unwrap(); + assert!(!logs.contains("synthetic")); + assert!(!local.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn heartbeat_republishes_unchanged_status() { + let (local, dir) = local_server().await; + let (manager, origin) = manager_server().await; + let mut reporter = + Reporter::new(&dir.path().join("tailscaled.sock"), origin.parse().unwrap()).unwrap(); + reporter.tick(100).await; + // Move only the reporter's monotonic clock; no wall-clock timestamp can + // accidentally suppress a heartbeat after a clock correction. + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(31)).await; + tokio::time::resume(); + reporter.tick(131).await; + assert_eq!(payload(&manager, 1)["authUrl"], LOGIN_URL); + assert_eq!(payload(&manager, 1)["updatedAt"], 131); + assert_eq!(local.requests.lock().unwrap().len(), 2); +} diff --git a/setup-wizard.yml b/setup-wizard.yml index 38c6b70..35305ac 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 to sign in + from VPN → Tailscale in a compatible Dappmanager version. To switch from + a rejected key to browser sign-in, clear the key and restart the package. + 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).