# LLMHosts Relay Tunnel — M1 through M5 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a self-hosted Rust relay tunnel client that ships inside the pip wheel, making `llmhosts up` connect through `wss://llmhosts.com/relay` with zero user config. Tailscale/Cloudflare become fallbacks.

**Architecture:** Rust client library (`relay_core/`) compiled via pyo3+maturin, wrapped by `llmhosts/tunnel/relay.py`, integrated into the existing TunnelManager cascade. Server binary (`relay_server/`) runs on VPS. Protocol: WSS → Noise NK encryption → yamux multiplexing → HTTP forwarding.

**Tech Stack:** Rust (tokio, tokio-tungstenite, snow, yamux, hyper, pyo3), Python (asyncio, FastAPI), maturin build system.

**Reference:** `docs/architecture/adr-009-relay-tunnel.md` (500 lines, accepted 2026-03-08)

---

## Milestone 1: relay_core Skeleton + pyo3 Bridge + Tunnel Integration (Issue #439)

### Task 1.1: Scaffold relay_core Rust crate

**Files:**
- Create: `relay_core/Cargo.toml`
- Create: `relay_core/pyproject.toml`
- Create: `relay_core/src/lib.rs`
- Create: `relay_core/.gitignore`

**Step 1: Create Cargo.toml**

```toml
[package]
name = "relay_core"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
name = "_relay_core"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.25", features = ["extension-module"] }
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
tracing = "0.1"
url = "2"

[profile.release]
strip = "symbols"
lto = true
codegen-units = 1
```

Note: Only WebSocket deps for M1. Noise (`snow`) added in M2, `yamux` in M3.

**Step 2: Create pyproject.toml**

```toml
[build-system]
requires = ["maturin>=1.0"]
build-backend = "maturin"

[tool.maturin]
features = ["pyo3/extension-module"]
```

**Step 3: Create src/lib.rs — minimal pyo3 module**

```rust
use pyo3::prelude::*;
use pyo3::exceptions::PyRuntimeError;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

/// Relay client that connects to the LLMHosts relay server via WebSocket.
///
/// Usage from Python:
///     from _relay_core import RelayClient
///     client = RelayClient("wss://...", "token", 11400)
///     await client.connect()
///     assert client.is_connected
///     await client.disconnect()
#[pyclass]
struct RelayClient {
    relay_url: String,
    device_token: String,
    local_port: u16,
    connected: Arc<AtomicBool>,
}

#[pymethods]
impl RelayClient {
    #[new]
    fn new(relay_url: String, device_token: String, local_port: u16) -> Self {
        Self {
            relay_url,
            device_token,
            local_port,
            connected: Arc::new(AtomicBool::new(false)),
        }
    }

    /// Connect to the relay server via WebSocket.
    fn connect<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let url = self.relay_url.clone();
        let token = self.device_token.clone();
        let connected = self.connected.clone();

        pyo3_async_runtimes::tokio::future_into_py(py, async move {
            use tokio_tungstenite::connect_async;
            use tokio_tungstenite::tungstenite::client::IntoClientRequest;

            let mut request = url.into_client_request()
                .map_err(|e| PyRuntimeError::new_err(format!("Invalid URL: {e}")))?;

            request.headers_mut().insert(
                "Authorization",
                format!("Bearer {token}").parse()
                    .map_err(|e| PyRuntimeError::new_err(format!("Invalid token: {e}")))?,
            );

            let (_ws_stream, _response) = connect_async(request)
                .await
                .map_err(|e| PyRuntimeError::new_err(format!("WebSocket connect failed: {e}")))?;

            connected.store(true, Ordering::SeqCst);
            Ok(())
        })
    }

    /// Disconnect from the relay server.
    fn disconnect<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let connected = self.connected.clone();
        pyo3_async_runtimes::tokio::future_into_py(py, async move {
            connected.store(false, Ordering::SeqCst);
            Ok(())
        })
    }

    /// Whether the client is currently connected.
    #[getter]
    fn is_connected(&self) -> bool {
        self.connected.load(Ordering::SeqCst)
    }

    /// The relay URL this client connects to.
    #[getter]
    fn relay_url(&self) -> &str {
        &self.relay_url
    }

    /// The local port being forwarded.
    #[getter]
    fn local_port(&self) -> u16 {
        self.local_port
    }
}

#[pymodule]
fn _relay_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<RelayClient>()?;
    m.add("RELAY_VERSION", env!("CARGO_PKG_VERSION"))?;
    m.add("RUST_AVAILABLE", true)?;
    Ok(())
}
```

**Step 4: Create .gitignore**

```
target/
_relay_core*.so
_relay_core*.pyd
_relay_core*.dylib
*.egg-info/
```

**Step 5: Verify Rust compiles**

Run: `cd relay_core && cargo check`
Expected: Compile success (may need `pyo3-async-runtimes` dep — add if needed)

**Step 6: Build with maturin**

Run: `cd relay_core && maturin develop`
Expected: Builds .so file, importable from Python

**Step 7: Verify Python import**

Run: `python3 -c "from _relay_core import RelayClient, RELAY_VERSION; print(f'relay_core v{RELAY_VERSION} loaded')"`
Expected: `relay_core v0.1.0 loaded`

**Step 8: Commit**

```bash
git add relay_core/
git commit -m "feat(relay): scaffold relay_core Rust crate with pyo3 bridge (M1.1)"
```

---

### Task 1.2: Add TunnelProvider.RELAY to models

**Files:**
- Modify: `llmhosts/tunnel/models.py`

**Step 1: Write the failing test**

Add to `tests/test_tunnel.py` in `TestTunnelModels`:

```python
def test_relay_provider_exists(self):
    """TunnelProvider must have RELAY variant."""
    assert hasattr(TunnelProvider, "RELAY")
    assert TunnelProvider.RELAY.value == "relay"

def test_tunnel_status_relay_provider(self):
    """TunnelStatus should accept relay provider."""
    status = TunnelStatus(provider=TunnelProvider.RELAY, active=True, url="https://dev123.llmhosts.com")
    assert status.provider == TunnelProvider.RELAY
    assert status.display_url == "https://dev123.llmhosts.com"
```

**Step 2: Run test to verify it fails**

Run: `pytest tests/test_tunnel.py::TestTunnelModels::test_relay_provider_exists -v`
Expected: FAIL — `AttributeError: RELAY`

**Step 3: Add RELAY to enum**

In `llmhosts/tunnel/models.py`, add to `TunnelProvider`:

```python
class TunnelProvider(str, Enum):
    TAILSCALE = "tailscale"
    CLOUDFLARE = "cloudflare"
    RELAY = "relay"
    NONE = "none"
```

**Step 4: Run test to verify it passes**

Run: `pytest tests/test_tunnel.py::TestTunnelModels -v`
Expected: All PASS

**Step 5: Commit**

```bash
git add llmhosts/tunnel/models.py tests/test_tunnel.py
git commit -m "feat(relay): add TunnelProvider.RELAY enum value (M1.2)"
```

---

### Task 1.3: Create Python relay wrapper

**Files:**
- Create: `llmhosts/tunnel/relay.py`
- Create: `tests/test_tunnel_relay.py`

**Step 1: Write the failing tests**

Create `tests/test_tunnel_relay.py`:

```python
"""Tests for the LLMHosts Relay tunnel provider."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.tunnel.models import TunnelProvider, TunnelStatus
from llmhosts.tunnel.relay import RELAY_AVAILABLE, RelayTunnel


class TestRelayAvailability:
    """Test relay_core import detection."""

    def test_relay_available_is_bool(self):
        assert isinstance(RELAY_AVAILABLE, bool)


class TestRelayTunnel:
    """Test RelayTunnel provider."""

    def test_init_defaults(self):
        tunnel = RelayTunnel()
        assert tunnel._active is False
        assert tunnel._url is None
        assert tunnel._port == 4000

    @pytest.mark.asyncio
    async def test_start_without_relay_core(self):
        """start() returns error status when relay_core is not available."""
        with patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", False):
            tunnel = RelayTunnel()
            status = await tunnel.start(port=4000, relay_url="wss://example.com/relay", device_token="tok")
            assert status.active is False
            assert "not available" in (status.error or "").lower()

    @pytest.mark.asyncio
    async def test_start_connects_relay(self):
        """start() creates RelayClient and calls connect()."""
        mock_client_cls = MagicMock()
        mock_client = MagicMock()
        mock_client.connect = AsyncMock()
        mock_client.is_connected = True
        mock_client_cls.return_value = mock_client

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClientClass", mock_client_cls),
        ):
            tunnel = RelayTunnel()
            status = await tunnel.start(
                port=11400,
                relay_url="wss://llmhosts.com/api/relay/connect",
                device_token="lh_relay_test",
            )
            assert status.active is True
            assert status.provider == TunnelProvider.RELAY
            mock_client_cls.assert_called_once_with("wss://llmhosts.com/api/relay/connect", "lh_relay_test", 11400)
            mock_client.connect.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_start_handles_connection_failure(self):
        """start() returns error status on connection failure."""
        mock_client_cls = MagicMock()
        mock_client = MagicMock()
        mock_client.connect = AsyncMock(side_effect=RuntimeError("Connection refused"))
        mock_client_cls.return_value = mock_client

        with (
            patch("llmhosts.tunnel.relay.RELAY_AVAILABLE", True),
            patch("llmhosts.tunnel.relay._RelayClientClass", mock_client_cls),
        ):
            tunnel = RelayTunnel()
            status = await tunnel.start(port=4000, relay_url="wss://bad.example.com", device_token="tok")
            assert status.active is False
            assert "Connection refused" in (status.error or "")

    @pytest.mark.asyncio
    async def test_stop_disconnects(self):
        """stop() disconnects the relay client."""
        mock_client = MagicMock()
        mock_client.disconnect = AsyncMock()

        tunnel = RelayTunnel()
        tunnel._client = mock_client
        tunnel._active = True

        await tunnel.stop()
        assert tunnel._active is False
        mock_client.disconnect.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_status_returns_current_state(self):
        """status() returns current tunnel status."""
        tunnel = RelayTunnel()
        tunnel._active = True
        tunnel._url = "https://dev123.llmhosts.com"
        tunnel._port = 11400

        status = await tunnel.status()
        assert status.active is True
        assert status.provider == TunnelProvider.RELAY
        assert status.url == "https://dev123.llmhosts.com"

    @pytest.mark.asyncio
    async def test_get_url(self):
        """get_url() returns relay URL when active."""
        tunnel = RelayTunnel()
        assert await tunnel.get_url() is None

        tunnel._url = "https://dev123.llmhosts.com"
        assert await tunnel.get_url() == "https://dev123.llmhosts.com"
```

**Step 2: Run to verify failure**

Run: `pytest tests/test_tunnel_relay.py -v`
Expected: FAIL — `ModuleNotFoundError: llmhosts.tunnel.relay`

**Step 3: Implement RelayTunnel**

Create `llmhosts/tunnel/relay.py`:

```python
"""LLMHosts Relay tunnel provider.

Wraps the Rust relay_core client (compiled via pyo3+maturin).
Falls back gracefully if the Rust extension is not available.

Pattern follows router_core: try Rust import → set RELAY_AVAILABLE → Python wrapper.
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any

from llmhosts.tunnel.models import TunnelProvider, TunnelStatus

logger = logging.getLogger(__name__)

# Try importing the Rust relay client (same pattern as router/_core.py)
RELAY_AVAILABLE: bool = False
_RelayClientClass: Any = None

try:
    from _relay_core import RelayClient as _RustRelayClient  # type: ignore[import]

    RELAY_AVAILABLE = True
    _RelayClientClass = _RustRelayClient
    logger.debug("Rust relay_core loaded (_relay_core extension)")
except ImportError:
    logger.debug("Rust relay_core not available — relay tunnel disabled")


class RelayTunnel:
    """LLMHosts Relay tunnel provider.

    Connects to the LLMHosts relay server via WebSocket, forwarding
    API requests to the local proxy. Falls through to Tailscale/Cloudflare
    if relay_core is not compiled or connection fails.
    """

    def __init__(self) -> None:
        self._client: Any = None
        self._active: bool = False
        self._url: str | None = None
        self._started_at: datetime | None = None
        self._port: int = 4000

    async def start(
        self,
        port: int = 4000,
        *,
        relay_url: str = "wss://llmhosts.com/api/relay/connect",
        device_token: str = "",
    ) -> TunnelStatus:
        """Connect to the LLMHosts relay server."""
        self._port = port

        if not RELAY_AVAILABLE:
            return TunnelStatus(
                provider=TunnelProvider.RELAY,
                active=False,
                local_port=port,
                error="Relay client not available (relay_core extension not compiled)",
            )

        if not device_token:
            return TunnelStatus(
                provider=TunnelProvider.RELAY,
                active=False,
                local_port=port,
                error="No relay token — authenticate first (llmhosts up)",
            )

        try:
            self._client = _RelayClientClass(relay_url, device_token, port)
            await self._client.connect()

            if self._client.is_connected:
                self._active = True
                self._started_at = datetime.now(tz=timezone.utc)
                # URL will be assigned by server after registration
                # For now, construct from relay_url domain
                self._url = self._derive_device_url(relay_url)
                logger.info("Relay connected: %s", self._url)
                return TunnelStatus(
                    provider=TunnelProvider.RELAY,
                    active=True,
                    url=self._url,
                    local_port=port,
                    started_at=self._started_at,
                )
            else:
                return TunnelStatus(
                    provider=TunnelProvider.RELAY,
                    active=False,
                    local_port=port,
                    error="WebSocket connected but relay handshake failed",
                )
        except Exception as exc:
            logger.warning("Relay connection failed: %s", exc)
            return TunnelStatus(
                provider=TunnelProvider.RELAY,
                active=False,
                local_port=port,
                error=str(exc),
            )

    async def stop(self) -> None:
        """Disconnect from the relay server."""
        if self._client:
            try:
                await self._client.disconnect()
            except Exception as exc:
                logger.warning("Relay disconnect error: %s", exc)
        self._active = False
        self._url = None
        self._client = None

    async def status(self) -> TunnelStatus:
        """Return current relay tunnel status."""
        return TunnelStatus(
            provider=TunnelProvider.RELAY,
            active=self._active,
            url=self._url,
            local_port=self._port,
            started_at=self._started_at,
        )

    async def get_url(self) -> str | None:
        """Return the relay URL if connected."""
        return self._url

    @staticmethod
    def _derive_device_url(relay_url: str) -> str | None:
        """Derive the public device URL from the relay server URL.

        wss://llmhosts.com/api/relay/connect → https://device.llmhosts.com
        In production, the server assigns the actual URL after registration.
        """
        # Placeholder — real URL comes from server after handshake
        return None
```

**Step 4: Run tests**

Run: `pytest tests/test_tunnel_relay.py -v`
Expected: All 7 tests PASS

**Step 5: Commit**

```bash
git add llmhosts/tunnel/relay.py tests/test_tunnel_relay.py
git commit -m "feat(relay): add RelayTunnel provider with Rust fallback pattern (M1.3)"
```

---

### Task 1.4: Wire relay into TunnelDetector

**Files:**
- Modify: `llmhosts/tunnel/detector.py`
- Modify: `tests/test_tunnel.py` (TestTunnelDetector)

**Step 1: Write the failing test**

Add to `tests/test_tunnel.py` in `TestTunnelDetector`:

```python
@pytest.mark.asyncio
async def test_relay_detected_when_available(self):
    """Relay should be first in detection list when relay_core is available."""
    with patch("llmhosts.tunnel.detector.RELAY_AVAILABLE", True):
        providers = await TunnelDetector.detect()
        assert providers[0] == TunnelProvider.RELAY

@pytest.mark.asyncio
async def test_relay_not_detected_when_unavailable(self):
    """Relay should not be in list when relay_core is not compiled."""
    with (
        patch("llmhosts.tunnel.detector.RELAY_AVAILABLE", False),
        patch("llmhosts.tunnel.detector.TunnelDetector._check_tailscale", return_value=False),
        patch("llmhosts.tunnel.detector.TunnelDetector._check_cloudflare", return_value=False),
    ):
        providers = await TunnelDetector.detect()
        assert TunnelProvider.RELAY not in providers
```

**Step 2: Run to verify failure**

Run: `pytest tests/test_tunnel.py::TestTunnelDetector::test_relay_detected_when_available -v`
Expected: FAIL

**Step 3: Modify detector.py**

Add relay detection at the top of the `detect()` method. Import `RELAY_AVAILABLE` from `llmhosts.tunnel.relay`:

```python
# At top of file, add import:
from llmhosts.tunnel.relay import RELAY_AVAILABLE

# In TunnelDetector.detect(), add relay as first check:
@classmethod
async def detect(cls) -> list[TunnelProvider]:
    """Detect available tunnel providers in priority order."""
    available: list[TunnelProvider] = []

    # Priority 1: LLMHosts Relay (built-in, zero config)
    if RELAY_AVAILABLE:
        available.append(TunnelProvider.RELAY)

    # Priority 2: Tailscale (if installed and connected)
    if await cls._check_tailscale():
        available.append(TunnelProvider.TAILSCALE)

    # Priority 3: Cloudflare (if cloudflared installed)
    if await cls._check_cloudflare():
        available.append(TunnelProvider.CLOUDFLARE)

    return available
```

**Step 4: Run tests**

Run: `pytest tests/test_tunnel.py::TestTunnelDetector -v`
Expected: All PASS

**Step 5: Commit**

```bash
git add llmhosts/tunnel/detector.py tests/test_tunnel.py
git commit -m "feat(relay): add relay detection as priority 1 provider (M1.4)"
```

---

### Task 1.5: Wire relay into TunnelManager cascade

**Files:**
- Modify: `llmhosts/tunnel/manager.py`
- Modify: `tests/test_tunnel.py` (TestTunnelManager)

**Step 1: Write failing test**

Add to `tests/test_tunnel.py` in `TestTunnelManager`:

```python
@pytest.mark.asyncio
async def test_relay_provider_in_cascade(self):
    """TunnelManager should try relay first when available."""
    mock_relay = AsyncMock()
    mock_relay.start = AsyncMock(return_value=TunnelStatus(
        provider=TunnelProvider.RELAY, active=True, url="https://dev.llmhosts.com"
    ))

    with (
        patch("llmhosts.tunnel.manager.RelayTunnel", return_value=mock_relay),
        patch("llmhosts.tunnel.manager.TunnelDetector.detect",
              return_value=[TunnelProvider.RELAY, TunnelProvider.TAILSCALE]),
    ):
        mgr = TunnelManager()
        status = await mgr.start()
        assert status.active is True
        assert status.provider == TunnelProvider.RELAY
```

**Step 2: Run to verify failure**

Run: `pytest tests/test_tunnel.py::TestTunnelManager::test_relay_provider_in_cascade -v`
Expected: FAIL

**Step 3: Modify manager.py**

Add relay import and handling to `_start_provider()`:

```python
# Add import at top:
from llmhosts.tunnel.relay import RelayTunnel

# In __init__, add:
self._relay: RelayTunnel | None = None

# In _start_provider(), add RELAY case:
if provider == TunnelProvider.RELAY:
    self._relay = RelayTunnel()
    return await self._relay.start(
        port=config.port,
        relay_url=config.relay_url or "wss://llmhosts.com/api/relay/connect",
        device_token=config.relay_token or "",
    )
```

Also add `relay_url` and `relay_token` fields to `TunnelConfig` in `models.py`:

```python
class TunnelConfig(BaseModel):
    # ... existing fields ...
    relay_url: str | None = None
    relay_token: str | None = None
```

**Step 4: Run tests**

Run: `pytest tests/test_tunnel.py -v`
Expected: All PASS (existing + new)

**Step 5: Commit**

```bash
git add llmhosts/tunnel/manager.py llmhosts/tunnel/models.py tests/test_tunnel.py
git commit -m "feat(relay): wire relay into TunnelManager cascade as priority 1 (M1.5)"
```

---

### Task 1.6: Update exports and up.py integration

**Files:**
- Modify: `llmhosts/tunnel/__init__.py`
- Modify: `llmhosts/up.py`

**Step 1: Update __init__.py exports**

```python
from llmhosts.tunnel.manager import TunnelManager
from llmhosts.tunnel.models import TunnelConfig, TunnelProvider, TunnelStatus
from llmhosts.tunnel.relay import RELAY_AVAILABLE, RelayTunnel

__all__ = [
    "RELAY_AVAILABLE",
    "RelayTunnel",
    "TunnelConfig",
    "TunnelManager",
    "TunnelProvider",
    "TunnelStatus",
]
```

**Step 2: Update up.py to pass relay config**

In `_start_tunnel()`, pass relay token from credentials:

```python
async def _start_tunnel(self) -> None:
    # ... existing plan gate check ...

    relay_token = ""
    relay_url = "wss://llmhosts.com/api/relay/connect"

    # If we have credentials, try to get relay token
    if self._credentials and hasattr(self._credentials, "relay_token"):
        relay_token = self._credentials.relay_token or ""

    config = TunnelConfig(
        port=self._port,
        funnel=True,
        auth_required=False,
        relay_url=relay_url,
        relay_token=relay_token,
    )

    status = await self._tunnel_mgr.start(config)
    # ... rest unchanged ...
```

**Step 3: Run full test suite**

Run: `pytest tests/ -v --timeout=60`
Expected: All existing tests still pass

**Step 4: Commit**

```bash
git add llmhosts/tunnel/__init__.py llmhosts/up.py
git commit -m "feat(relay): update exports and wire relay token into up.py (M1.6)"
```

---

### Task 1.7: Add relay_token to Credentials

**Files:**
- Modify: `llmhosts/auth/device_flow.py`
- Modify: relevant tests

**Step 1: Add relay_token field**

In `Credentials` dataclass:

```python
@dataclass
class Credentials:
    api_key: str
    user_id: str
    email: str
    name: str | None
    plan: str
    server_url: str
    relay_token: str | None = None  # Added for relay tunnel auth

    def to_dict(self) -> dict[str, str | None]:
        return {
            "api_key": self.api_key,
            "user_id": self.user_id,
            "email": self.email,
            "name": self.name,
            "plan": self.plan,
            "server_url": self.server_url,
            "relay_token": self.relay_token,
        }

    @classmethod
    def from_dict(cls, data: dict[str, str | None]) -> Credentials:
        return cls(
            api_key=data.get("api_key", ""),
            user_id=data.get("user_id", ""),
            email=data.get("email", ""),
            name=data.get("name"),
            plan=data.get("plan", "free"),
            server_url=data.get("server_url", ""),
            relay_token=data.get("relay_token"),
        )
```

**Step 2: Run existing auth tests**

Run: `pytest tests/ -k "auth or device" -v`
Expected: All PASS (relay_token has a default, backward compatible)

**Step 3: Commit**

```bash
git add llmhosts/auth/device_flow.py
git commit -m "feat(relay): add relay_token field to Credentials (M1.7)"
```

---

### Task 1.8: CI integration — maturin develop in test pipeline

**Files:**
- Modify: `.github/workflows/ci.yml` (if needed)

**Step 1: Verify local tests pass without relay_core compiled**

Run: `pytest tests/test_tunnel_relay.py -v`
Expected: All PASS (tests mock relay_core, don't need compiled binary)

Run: `pytest tests/ -v --timeout=120`
Expected: Full suite passes

**Step 2: Create feature branch and push**

```bash
# Create feature branch from all M1 commits
git checkout -b feature/439-relay-core-m1
git push origin feature/439-relay-core-m1
gh pr create --title "feat(relay): M1 relay_core skeleton + pyo3 bridge + tunnel integration" \
  --body "$(cat <<'EOF'
## Summary
- Scaffold `relay_core/` Rust crate with pyo3+maturin (WebSocket client)
- Add `TunnelProvider.RELAY` enum and `RelayTunnel` Python wrapper
- Wire relay as priority 1 in TunnelDetector and TunnelManager cascade
- Add `relay_token` to Credentials for relay authentication
- Graceful fallback: relay_core not compiled → cascade to Tailscale/Cloudflare

## Test plan
- [ ] `pytest tests/test_tunnel_relay.py -v` — 7 relay-specific tests
- [ ] `pytest tests/test_tunnel.py -v` — all existing tunnel tests still pass
- [ ] `pytest tests/ -v` — full suite passes
- [ ] `cd relay_core && maturin develop` — Rust builds on dev machine
- [ ] `python3 -c "from _relay_core import RelayClient"` — pyo3 bridge works

Closes #439

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

**Step 3: Wait for CI, then merge**

---

## Milestone 2: Noise Protocol NK Encryption (Issue #440)

### Task 2.1: Add snow crate and Noise NK handshake

**Files:**
- Modify: `relay_core/Cargo.toml` — add `snow = "0.9"`
- Create: `relay_core/src/noise.rs`
- Modify: `relay_core/src/lib.rs`

**Implementation:**
- Add `snow` dependency to Cargo.toml
- Create `noise.rs` with NK handshake:
  - `NoiseTransport::new(server_pubkey: &[u8])` — initiator
  - `NoiseTransport::handshake(ws: &mut WebSocket)` — perform NK handshake over WebSocket
  - `NoiseTransport::encrypt(plaintext: &[u8]) -> Vec<u8>` — encrypt frame
  - `NoiseTransport::decrypt(ciphertext: &[u8]) -> Vec<u8>` — decrypt frame
- Modify `lib.rs`: add `server_pubkey: Option<Vec<u8>>` to RelayClient constructor
- After WebSocket connect, perform Noise NK handshake before reporting connected
- All subsequent WebSocket messages go through encrypt/decrypt

**Test:** Unit test in Rust (`cargo test`) — handshake between initiator and responder in-process.

**Commit:** `feat(relay): add Noise NK encryption for relay transport (M2)`

---

## Milestone 3: yamux Stream Multiplexing (Issue #441)

### Task 3.1: Add yamux and stream management

**Files:**
- Modify: `relay_core/Cargo.toml` — add `yamux = "0.13"`
- Create: `relay_core/src/mux.rs`
- Create: `relay_core/src/forward.rs`
- Modify: `relay_core/src/lib.rs`

**Implementation:**
- Add `yamux` dependency
- Create `mux.rs`:
  - Wrap Noise-encrypted WebSocket as yamux `Connection`
  - Stream 0: control channel (heartbeat, auth refresh)
  - Stream 1+: server-initiated, one per HTTP request
- Create `forward.rs`:
  - Accept incoming yamux stream (HTTP request from server)
  - Forward to `http://localhost:{local_port}`
  - Stream response back through yamux
- Add Python-visible metrics: `streams_active`, `bytes_relayed`, `latency_ms`, `uptime_seconds`

**Test:**
- Rust unit tests: yamux session with mock transport
- Python integration test: mock WebSocket server → RelayClient → verify metrics update

**Commit:** `feat(relay): add yamux multiplexing for concurrent streams (M3)`

---

## Milestone 4: relay_server Binary + VPS Deployment (Issue #442 + #447)

### Task 4.1: Scaffold relay_server crate

**Files:**
- Create: `relay_server/Cargo.toml`
- Create: `relay_server/src/main.rs`
- Create: `relay_server/src/acceptor.rs`
- Create: `relay_server/src/registry.rs`
- Create: `relay_server/src/proxy.rs`
- Create: `relay_server/src/health.rs`
- Create: `relay_server/src/config.rs`

**Implementation:**
- `main.rs`: tokio runtime, config loading, bind HTTPS server + WebSocket acceptor
- `acceptor.rs`: WebSocket upgrade → Noise NK responder → yamux session
- `registry.rs`: `HashMap<DeviceId, yamux::Control>` — device lookup
- `proxy.rs`: HTTPS → lookup device → open yamux stream → forward request → return response
- `health.rs`: `/health`, `/metrics` (Prometheus), `/ready` endpoints
- `config.rs`: TOML config (bind addr, TLS cert paths, max connections, timeouts)

### Task 4.2: Deploy to VPS

- Build release binary: `cargo build --release --target x86_64-unknown-linux-musl`
- Create systemd service file
- Configure Traefik route: `relay.llmhosts.com` → relay_server
- Configure wildcard DNS: `*.llmhosts.com` → VPS IP
- TLS via Let's Encrypt (Traefik auto)
- Health monitoring: Prometheus scrape + Grafana dashboard

### Task 4.3: Frontend relay-token endpoint

**Files:**
- Modify: `frontend/src/app/api/devices/route.ts` — add relay token endpoint

**Implementation:**
- `POST /api/devices/relay-token`:
  - Validate device_id + api_key
  - Generate relay token (JWT or opaque, 24h TTL)
  - Return `{ relay_token, server_pubkey, relay_url }`

**Commit:** `feat(relay): relay_server binary + VPS deployment + token endpoint (M4)`

---

## Milestone 5: Production Hardening (Issue #443)

### Task 5.1: Reconnection with exponential backoff

- In `relay_core/src/client.rs`: reconnect loop with 1s→2s→4s→...→60s cap + jitter
- Never stop retrying (device should always be reachable)
- During reconnection: local proxy continues, dashboard shows "Reconnecting..."

### Task 5.2: Token refresh via control stream

- Stream 0 sends `REFRESH_TOKEN` request every 23 hours
- Server responds with new token
- Client stores and uses new token for next reconnect

### Task 5.3: Connection draining for server restarts

- SIGTERM → stop accepting new connections
- Wait for in-flight requests to complete (30s timeout)
- Close all yamux sessions gracefully

### Task 5.4: Rate limiting and bandwidth monitoring

- Per-device rate limits (configurable in server config)
- Prometheus metrics: bytes_in/out, streams_opened, connections_active
- Alerting thresholds in Grafana

### Task 5.5: Legacy cleanup

- Archive `llmhosts/hive/headscale.py` → `_legacy/`
- Archive `tests/test_hive_headscale.py` → `_legacy/`
- Archive `infrastructure/headscale/` → `_legacy/`
- Update all docs referencing Headscale
- Remove `HEADSCALE_*` env vars from `.env.example`

### Task 5.6: E2E test on Alienware

- Run `llmhosts up` on Alienware laptop
- Verify relay connection to VPS
- `curl https://device.llmhosts.com/v1/models` returns models
- Test streaming response through relay
- Test reconnection (kill WiFi, reconnect)
- Test concurrent requests (10 parallel streams)

**Commit:** `feat(relay): production hardening, reconnection, rate limiting, legacy cleanup (M5)`

---

## Execution Order Summary

```
M1.1  Scaffold relay_core crate           → commit
M1.2  Add TunnelProvider.RELAY            → commit
M1.3  Create RelayTunnel wrapper          → commit
M1.4  Wire relay into detector            → commit
M1.5  Wire relay into manager cascade     → commit
M1.6  Update exports + up.py             → commit
M1.7  Add relay_token to Credentials     → commit
M1.8  CI integration + PR                → PR #(new), merge

M2.1  Noise NK encryption                → commit → PR, merge
M3.1  yamux multiplexing + forwarding    → commit → PR, merge
M4.1  relay_server scaffold              → commit
M4.2  VPS deployment                     → commit
M4.3  Frontend token endpoint            → commit → PR, merge
M5.1-5.6  Hardening + E2E               → commit → PR, merge
```

**Total: ~25 commits across 5 PRs, one per milestone.**
