Files
2026-07-10 15:02:09 +01:00

7.9 KiB

Remote Connectivity

Exposes the Skald web app on a private mesh network so remote clients (iOS app, NAS, etc.) can connect without port forwarding or internet exposure.


Architecture

core-api                          plugin-tailscale-remote crate
─────────────────────────────     ──────────────────────────────────────────────
trait RemoteAccess            ←── RemotePlugin  (src/lib.rs)
(core_api::remote)                TailscaleSystemProvider  (src/tailscale_sys.rs)  ← default
                                  TailscaleEmbeddedProvider  (src/tailscale.rs)
                                  [feature: remote-tailscale]
  • RemoteAccess trait (core_api::remote): vendor-agnostic interface. The core (Skald, WS handler, etc.) only knows this trait.
  • TailscaleSystemProvider (crates/plugin-tailscale-remote/src/tailscale_sys.rs): recommended provider. Reads the mesh IP via tailscale ip -4 (requires tailscaled running on the host). Binds a standard tokio::net::TcpListener — no experimental dependencies.
  • TailscaleEmbeddedProvider (crates/plugin-tailscale-remote/src/tailscale.rs): embedded alternative using tailscale-rs. Feature-gated: remote-tailscale, off by default (enable via the root crate's embedded-tailscale feature). No system daemon required, but currently pre-1.0 with known DERP/reconnect issues, and it drags the aws-lc-rs C crypto build back into the tree (see Feature Flag). Use only when a daemon cannot be installed (e.g. unrooted NAS).
  • RemotePlugin (crates/plugin-tailscale-remote/src/lib.rs): wires the provider into the plugin lifecycle. Spawns a second Axum server on the mesh interface using the same router as the local server.

RemoteAccess trait

pub trait RemoteAccess: Send + Sync {
    fn provider_name(&self) -> &str;
    async fn device_ip(&self) -> Result<Ipv4Addr>;
    fn is_connected(&self) -> bool;
    async fn shutdown(&self);
}

Stored in Skald::remote: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>. None when the plugin is disabled.

Dual-bind strategy

The plugin (not the WebServer) is responsible for the mesh-facing server:

  1. RemotePlugin::start(state) calls extract_deps(state) once, storing three named fields:
    • port: u16 — TCP port to bind on the mesh interface
    • remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> — slot in Skald to register the active provider
    • router_factory: Arc<dyn Fn() -> Router + Send + Sync> — closure that rebuilds the Axum router
  2. The internal helpers (start_tailscale_sys, start_tailscale) use only those three fields — no Arc<Skald> reference after extraction.
  3. start_tailscale_sys binds tokio::net::TcpListener::bind((mesh_ip, port)).
  4. start_tailscale calls provider.axum_listener(port)tailscale::axum::Listener.
  5. Both call router_factory() to get a fresh router and spawn axum::serve(listener, router) guarded by a CancellationToken.

extract_deps uses std::sync::OnceLock — idempotent across reload() calls. The values are stable for the lifetime of the process (port and static dir come from config, the remote slot is the same Arc).

The local server on 127.0.0.1:PORT is unaffected.


Configuration

Config is stored in the plugins SQLite table (not in config.yml).

Key Type Default Description
provider string "tailscale_sys" tailscale_sys (system daemon, recommended) or tailscale (embedded, no daemon).
auth_key string Tailscale auth key (tskey-auth-...). Only required for the tailscale embedded provider on first join.
hostname string "personal-agent" Hostname on the tailnet. Only used by the embedded tailscale provider.
key_file string "data/tailscale_keys.json" Path for persisting node identity between restarts. Only used by the embedded tailscale provider.

Config is persisted automatically and survives restarts.


Agent Workflow

Requires Tailscale already installed and logged in on the host machine.

1. toggle_item(kind="plugin", id="remote_connectivity", enabled=true)
2. restart
→ On next boot: plugin reads IP from tailscale ip -4, mesh server starts, app reachable at <ts-ip>:3000

No auth key needed — the system daemon is already authenticated.

Using the embedded provider (no daemon)

Requires a binary built with --features embedded-tailscale (off by default; see Feature Flag). Without it, selecting provider="tailscale" fails at start with unknown provider 'tailscale'.

1. configure_plugin "remote_connectivity" {"provider":"tailscale","auth_key":"tskey-auth-...","hostname":"personal-agent"}
2. toggle_item(kind="plugin", id="remote_connectivity", enabled=true)
3. restart
→ On next boot: embedded tailscale connects, mesh server starts, app reachable at <ts-ip>:3000

After first setup, the plugin auto-starts on every boot (persisted enabled=true in DB).

To check status: list_items (type=plugins) → look for remote_connectivity, check running and runtime_status.ip.


Data Streams (iOS → Server)

Remote clients can push typed data over the existing WebSocket connection:

{"type": "data", "stream": "location", "payload": {"lat": 45.1, "lng": 9.2, "accuracy": 10.0}}

Handled in src/frontend/api/ws.rshandle_data_msg(). Dispatched to:

Stream Handler Notes
location state.location_manager.update("remote", ...) Stores in-memory; existing latest() / all() queries work
other warn! log Reserved for future streams (health, etc.)

Limitations

tailscale_sys (system daemon)

  • Requires tailscaled on the host: if the daemon is not running or the device is not logged in, start() fails immediately with a clear error.
  • IP can change: if the device rejoins the tailnet with a different IP, the plugin must be restarted to pick up the new address. The server binds to a specific IP, not 0.0.0.0.

tailscale (embedded, tailscale-rs v0.3)

  • DERP-relay only: direct WireGuard holepunching is not yet implemented (issue #151). Latency is slightly higher than native WireGuard.
  • Known reconnect bugs: after a node restart, existing connections can hang for ~15 s (issue #11). DERP connectivity can be lost after a control plane reconnect (issue #26).
  • Unaudited cryptography: do not use for highly sensitive data until an official audit is complete.
  • Breaking changes: the library is pre-1.0. The TS_RS_EXPERIMENT=this_is_unstable_software env var is required at runtime (set by run.sh).
  • Auth key expiry: auth keys expire. Regenerate and call configure_plugin with the new value if the plugin fails to connect.

Feature Flag

tailscale-rs is compiled only when the remote-tailscale feature is active. It is off by default: the crate pulls the pure-Rust tailscale library, which internally forces the aws-lc-rs crypto backend (a cmake/NASM C build) back into the whole workspace, defeating the ring-only static-binary story (see build-and-distribution.md). The recommended tailscale_sys provider needs none of this and is always compiled.

# crates/plugin-tailscale-remote/Cargo.toml
[features]
default = []                       # embedded provider OFF by default
remote-tailscale = ["dep:tailscale"]

# root Cargo.toml — opt in from the top-level build:
[features]
embedded-tailscale = ["plugin-tailscale-remote/remote-tailscale"]

To build with the embedded provider (accepts the aws-lc-rs C build):

cargo build --features embedded-tailscale

The default build already omits it — no --no-default-features needed.