Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b3c9527
docs(mdns): add discovery protocol spec + zeroconf dependency
W-Mai Jun 5, 2026
705c26a
refactor(tests): extract MockHTTPHandler into shared fixture
W-Mai Jun 5, 2026
3ac0b58
feat(server): add MdnsAdvertiser for _fpbinject._tcp.local.
W-Mai Jun 5, 2026
408332d
feat(cli): add cli/discover.py for client-side mDNS browsing
W-Mai Jun 5, 2026
b6bc6a1
feat(server): wire MdnsAdvertiser into WebServer main with --no-mdns
W-Mai Jun 5, 2026
b433711
feat(cli): auto-discover server via mDNS by default
W-Mai Jun 5, 2026
e5356e0
feat(mdns): bump default discovery timeout from 1s to 3s
W-Mai Jun 5, 2026
9f0fe60
fix(cli): print FPBCLIError without traceback during init
W-Mai Jun 5, 2026
d13176a
fix(mdns): include port in service instance name
W-Mai Jun 5, 2026
025d69b
refactor(cli): introduce ConnectionPlan / CommandPolicy / ConnectionMode
W-Mai Jun 5, 2026
1f69509
refactor(cli): replace requires_server with command_policy on every s…
W-Mai Jun 5, 2026
35d8c46
feat(mdns): prefer loopback over LAN-IP and normalize same-host services
W-Mai Jun 5, 2026
cc4de59
refactor(cli): single resolve_connection_plan + plan-driven connector
W-Mai Jun 5, 2026
52eda38
docs: rewrite CLI operating-modes + Discovery client precedence ladder
W-Mai Jun 5, 2026
7c1510b
feat(mdns): publish stable per-installation id in TXT
W-Mai Jun 5, 2026
75d3506
feat(cli): expose handle and id on FPBServer; add classifier helpers
W-Mai Jun 5, 2026
7c82270
feat(cli): one -s flag for URL / host:port / hostname
W-Mai Jun 5, 2026
c59e7a3
docs: rewrite CLI server selection around -s / FPB_SERVER
W-Mai Jun 5, 2026
de25312
perf(cli): -s host:port short-circuits mDNS browse
W-Mai Jun 8, 2026
a293a89
perf(cli): cache -s host:port lookups; refresh asynchronously
W-Mai Jun 8, 2026
fef317f
style(cli): apply project black + flake8
W-Mai Jun 8, 2026
a18e0f8
fix(cli,mdns): address Copilot review
W-Mai Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 104 additions & 18 deletions Docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,20 @@ Options:
--tx-chunk-delay <secs> Delay between TX fragments (default: 0.005)
--max-retries <num> Max retry attempts for file transfer (default: 10)
--direct Force direct serial (skip proxy detection)
--server-url <url> WebServer URL (default: http://127.0.0.1:5500)
--token <token> Auth token for remote servers (or set FPB_TOKEN env)
-s, --server <handle> Pick a server by discovery handle, hostname,
or full URL. Examples:
-s bench:5501
-s bench (when unique on LAN)
-s http://1.2.3.4:5500
If omitted: FPB_SERVER env, then mDNS
auto-discovery, then http://127.0.0.1:5500.
--no-discovery Disable mDNS auto-discovery
--token <token> Auth token (or set FPB_TOKEN env). Required
when the server returns 401/403.
```

`--server-url` and `FPB_SERVER_URL` still work for backwards compatibility but are deprecated; use `-s` / `FPB_SERVER` instead.

### About `--port`

The serial port belongs to the **WebServer**, not the CLI. When a server is already running and has a device connected, `--port` is not needed — the CLI attaches to the server's existing connection. This applies to **both local and remote** servers: as long as `/api/status` reports `connected=true`, you can run device commands without `--port`.
Expand All @@ -57,39 +67,115 @@ Without `--port` the CLI never opens a serial port directly — it either attach

## Operating Modes

| Mode | Trigger | Behavior |
|------|---------|----------|
| Offline (local) | No `--port`, no local server (or server has no device) | ELF analysis / compile only |
| Local proxy (port-less) | No `--port`, local server already has a device | Attach to server's existing connection |
| Local proxy | `--port` + local server running | Attach to server, forward device ops |
| Local auto-launch | `--port` + no local server | Auto-launch server, then proxy |
| Local direct | `--direct --port` | Open serial directly (bypass server) |
| Remote proxy | `--server-url http://remote:port` | Pure proxy to remote server, no auto-launch (attaches even if no device yet) |
The CLI runs in exactly one of four mutually-exclusive modes. The mode is decided once, before any command is dispatched, by the connection resolver. You don't pick a mode by name — you pick it by the inputs the resolver reads.

## Remote Control
| Mode | When | Auth |
|------|------|------|
| **Offline** | The subcommand is ELF-only (`analyze`, `disasm`, `decompile`, `signature`, `search`, `get-symbols`, `compile`) or admin-only (`discover`, `server-stop`, `disconnect`) | Never |
| **Local Proxy** | Server is on this host (loopback or a local interface IP) | None for localhost; LAN-bound server admins should still set a token |
| **Remote Proxy** | Server is on another host | `--token` / `FPB_TOKEN` required when the server returns 401/403 |
| **Direct Serial** | `--direct` is set explicitly | None; bypasses the WebServer entirely |

### How the mode is chosen

The resolver runs through this list and stops at the first match:

1. **Offline / admin subcommand** → Offline.
2. **`--direct`** → Direct Serial. Requires `--port`. Rejected with `-s` / `--server-url`.
3. **`-s / --server <handle>`** → resolve the handle, then Local or Remote Proxy.
4. **`FPB_SERVER`** env var → same handle resolution as `-s`.
5. *(deprecated)* `--server-url <URL>` → URL only.
6. *(deprecated)* `FPB_SERVER_URL` env → URL only.
7. **A single CLI-launched server** found via PID file → Local Proxy on `127.0.0.1:<port>`.
8. **`http://127.0.0.1:5500/api/status` reachable** → Local Proxy on the default port.
9. **`--no-discovery`** → Local Proxy on `http://127.0.0.1:5500` (no probe of LAN).
10. **mDNS browse for ~3 s** on `_fpbinject._tcp.local.`:
- 0 results → Local Proxy on `http://127.0.0.1:5500` (fallback).
- 1 result → Local or Remote Proxy (already loopback-normalized when same-host).
- 2+ results → list candidates on stderr, exit `2`. Re-run with `-s host:port`.

`--port` only ever names the **device serial port**, never the server port. To talk to a server on a non-default TCP port, use `-s 127.0.0.1:5501`.

### Handle forms accepted by `-s` / `FPB_SERVER`

| Form | Example | Behaviour |
|------|---------|-----------|
| URL | `http://1.2.3.4:5500` | Used verbatim. |
| `host:port` | `bench:5501` | Looked up via mDNS; must match exactly one server. |
| `host` | `bench` | Looked up via mDNS; must match exactly one server (else exit `2` with hints). |

To operate a device attached to another machine:
### Exit codes

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Runtime failure (connect / auth / IO / invalid flag combination / unresolvable handle) |
| `2` | Multiple servers matched a handle or were discovered with no `-s` — disambiguate |

### Invalid flag combinations

| Combo | Reason | Behaviour |
|-------|--------|-----------|
| `--direct -s …` (or `--direct --server-url …`) | Direct mode bypasses the WebServer | Rejected with one-line error, exit `1` |
| `--direct` without `--port` for a device command | Direct mode opens a serial port — there is nothing to do without one | Rejected with one-line error, exit `1` |

## Remote Control

```bash
# On the machine with the device (B): start WebServer
./main.py --host 0.0.0.0 --port 5500
# 🔑 Token: dd88d5df

# On the controlling machine (A):
# On the controlling machine (A) — pick the server by hostname or handle:
export FPB_TOKEN=dd88d5df
fpb_cli.py --server-url http://192.168.1.20:5500 info
fpb_cli.py --server-url http://192.168.1.20:5500 mem-read 0x20000000 64
fpb_cli.py --server-url http://192.168.1.20:5500 serial-send "ps"
export FPB_SERVER=B-host:5500 # use this server for the whole shell
fpb_cli.py info
fpb_cli.py mem-read 0x20000000 64
fpb_cli.py serial-send "ps"

# Or per-command:
fpb_cli.py -s B-host:5500 info

# If the remote server has no device connected yet:
fpb_cli.py --server-url http://192.168.1.20:5500 --port /dev/ttyACM0 connect
fpb_cli.py -s B-host:5500 --port /dev/ttyACM0 connect

# URL still works when DNS-style names aren't available:
fpb_cli.py -s http://192.168.1.20:5500 info
```

Notes:
- `--token` is required for non-localhost servers. Use `FPB_TOKEN` env to avoid shell history exposure.

- `--token` is required when the remote server returns 401/403. Use `FPB_TOKEN` env to keep it out of shell history.
- `--elf` / `--compile-commands` paths in inject commands refer to **server-side** paths.
- ELF analysis commands (analyze/disasm/search) always operate on the **local** ELF file.

## Auto-Discovery (mDNS)

The discovery step browses `_fpbinject._tcp.local.`. Three behaviours worth knowing:

- **Same-host normalization.** A server advertising both `127.0.0.1` and a LAN IP (e.g. on a multi-homed machine) is always classified as Local Proxy and the URL is rewritten to `127.0.0.1:<port>`. You will never be prompted for a token to talk to a server you started yourself.
- **Handle resolution.** `-s bench:5501` and `-s bench` both browse mDNS to find the matching server, so you don't have to copy URLs by hand. `-s` of a URL skips discovery.
- **Token never travels over mDNS.** TXT records carry `txtvers`, `version`, `auth` (advertised intent), `device`, `path`, `id` (stable per-installation UUID). Tokens come from `--token`, `FPB_TOKEN`, or the server's startup banner.

### `discover` — list visible servers

```bash
fpb_cli.py discover [--timeout 3.0] [--json]
```

Default output is a human-friendly table:

```text
HANDLE URL AUTH DEVICE VERSION
bench:5500 http://127.0.0.1:5500 token none 1.6.6
bench:5501 http://127.0.0.1:5501 none none 1.6.6
bench:5500 http://192.168.1.20:5500 token sensor 1.6.6
```

`--json` returns a machine-readable list (used to be the default).

For the full protocol contract see [Tools/WebServer/Docs/Discovery.md](../Tools/WebServer/Docs/Discovery.md).

## Commands

### Offline Commands (No Device Required)
Expand Down
4 changes: 4 additions & 0 deletions Tools/WebServer/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ package-lock.json
coverage/
.nyc_output/
tests/coverage/


# Per-machine stable server id minted by the mDNS advertiser.
.fpbinject_server_id
142 changes: 142 additions & 0 deletions Tools/WebServer/Docs/Discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# FPBInject mDNS Discovery Protocol

This document specifies the mDNS / DNS-SD service that FPBInject WebServer instances publish on the local network and that `fpb_cli.py` consumes for auto-discovery.

## Goals

- Let CLI clients find a running WebServer on the LAN without prior knowledge of host or port.
- Advertise enough metadata for the client to pick the right server when more than one is reachable.
- Stay out of the authentication path: discovery is **endpoint discovery, not authentication**. Tokens are exchanged out-of-band.

## Service registration

| Field | Value |
|---|---|
| Service type | `_fpbinject._tcp.local.` |
| Instance name | `FPBInject on <hostname>:<port>` (RFC 6763 §4.1.1 user-visible name; the port suffix lets multiple servers on one host coexist and is the source of the client-side ``handle`` value). |
| Port | The TCP port the WebServer is listening on (default `5500`, follows `--port`). |
| Address | All interfaces zeroconf binds to (IPv4 only by default). |
| Server hostname | `<hostname>.local.` |

`_fpbinject` is 9 characters, fits the RFC 6335 §5.1 ≤15-char limit, and is not registered on IANA — no collision with shipped service types.

## TXT records (v1)

Keys are case-insensitive. Values are printable UTF-8.

| Key | Value | Meaning |
|---|---|---|
| `txtvers` | `1` | Schema version of this TXT record (RFC 6763 §6.7). Bump only on incompatible changes. |
| `version` | e.g. `1.6.6` | WebServer application version. Clients MAY warn on major mismatch. |
| `auth` | `token` \| `none` | Advertised auth intent. `token` means a token is required for non-localhost access; `none` means the server was started with `--no-auth`. |
| `device` | `none` (v1) | Whether the server has a serial device attached. **v1 limitation: this value is set once at startup as `none` and is not updated at runtime.** Real-time updates may be added in a later schema version (will bump `txtvers`). |
| `path` | `/api` | API base path. Reserved for future protocol revs. |
| `id` | `fpb:<uuid>` | Stable per-installation UUID; persisted on the server in `Tools/WebServer/.fpbinject_server_id`. Survives port and hostname changes; deleting the file mints a new identity. Reserved for future client-side identity matching. |

### Security: token must not appear in TXT

The auth token MUST NOT be published in TXT records. mDNS announcements are broadcast in cleartext on UDP/5353 and cached for tens of minutes by other hosts on the network. Publishing the token (or a recoverable hash of it) defeats its purpose.

The CLI obtains the token from the server's startup banner (`🔑 Token: …`), the `FPB_TOKEN` env var, or the `--token` flag — never from mDNS.

## Lifecycle

| Event | Server behavior |
|---|---|
| Server start (no `--no-mdns`) | Construct `Zeroconf()`, register `ServiceInfo` for `_fpbinject._tcp.local.` with the TXT keys above. |
| Server graceful exit (atexit, SIGINT, SIGTERM) | `unregister_service()` sends a "goodbye" packet, then `close()`. |
| Server `kill -9` or hard crash | No goodbye packet. The stale entry is evicted by the mDNS TTL (default ~75 minutes per RFC 6762 §10). |
| `--no-mdns` flag | Server skips registration entirely. |

## Client discovery

`Tools/WebServer/cli/discover.py::discover_sync(timeout: float = 3.0)` returns `list[FPBServer]`.

Each `FPBServer` is a dataclass:

```
FPBServer(
name: str, # full mDNS instance name
host: str, # IPv4 or hostname (loopback when same-host)
port: int,
version: str, # from TXT
auth: str, # "token" | "none"
device: str, # "none" (v1)
path: str, # "/api"
url: str, # convenience: f"http://{host}:{port}"
id: str, # from TXT (empty for legacy servers)
handle: str, # "<host>:<port>" — the human-friendly id `-s` accepts
)
```

### CLI precedence ladder

`fpb_cli.py::resolve_connection_plan(args)` runs through this list and stops at the first match. Each step produces a final `ConnectionPlan` (mode + URL + token + serial port + flags); the connector consumes the plan once.

1. **Subcommand is offline or admin-only** (`analyze`, `disasm`, `decompile`, `signature`, `search`, `get-symbols`, `compile`, `discover`, `server-stop`, `disconnect`) — return Offline plan, skip everything below. Zero discovery delay.
2. **`--direct`** — return Direct plan. Requires `--port`. Rejected with `-s` / `--server-url`.
3. **`-s / --server <handle>`** — handle resolution:
- URL (contains `://`) → used verbatim.
- `host:port` → mDNS browse, exact `handle` match.
- `host` → mDNS browse, must match exactly one server (else exit `2` with hints).
4. **`FPB_SERVER`** env var — same handle resolution as `-s`.
5. *(deprecated)* **`--server-url <URL>`** — URL only. Warns under `-v` and is removed in a future release.
6. *(deprecated)* **`FPB_SERVER_URL`** env — URL only.
7. **Single CLI-launched server** (PID file in `Tools/WebServer/.cli_server_*.pid`) — Local Proxy on `127.0.0.1:<pid_port>`. No mDNS.
8. **`http://127.0.0.1:5500/api/status` reachable** — Local Proxy on the default port. No mDNS.
9. **`--no-discovery`** — Local Proxy on `http://127.0.0.1:5500` (fallback only, no LAN browse).
10. **mDNS browse** for 3.0 s on `_fpbinject._tcp.local.`:
- 0 results → Local Proxy on `http://127.0.0.1:5500` (fallback).
- 1 result → classify the address. Same-host hits (loopback or local interface IP) are normalized to `127.0.0.1:<port>` so the user is never asked for a token to talk to a server they themselves started.
- ≥ 2 results → list candidates on stderr, `sys.exit(2)`.

### Localhost preference

Within step 10, when a single mDNS service announces multiple addresses (very common on multi-homed hosts), the resolver sorts them with this key:

| Class | Key |
|-------|-----|
| Loopback (`127.0.0.0/8`) | `(0, addr)` |
| Local interface IP (matches `socket.getaddrinfo(gethostname())`) | `(1, addr)` |
| Anything else | `(2, addr)` |

Lowest tuple wins. If the winner is loopback or a local-interface IP, the host field of the resulting `FPBServer` is rewritten to `127.0.0.1` and the URL becomes `http://127.0.0.1:<port>`. This eliminates the LAN-IP-from-localhost trap that previously caused spurious 403s.

### Handle cache (stale-while-revalidate)

`-s host:port` and `FPB_SERVER=host:port` consult a per-user cache before doing any mDNS work. The cache is a JSON file at `${XDG_CACHE_HOME:-$HOME/.cache}/fpbinject/handles.json` mapping each handle to its last-known URL plus the server's TXT `id`.

Behaviour:

| Outcome | What happens | Time |
|---------|--------------|------|
| Hit, fresh (≤ 24 h) | Return the cached URL **immediately**. A daemon thread re-runs the mDNS lookup and updates the entry for next time. The user does not block on the refresh. | ~100 ms |
| Hit, but the cached URL refuses connection | The connector raises `FPBCLIError` and invalidates the cache entry; the next call falls back to a synchronous mDNS lookup. | (this call fails fast; next call ~1.3 s) |
| Miss / expired / `FPB_NO_CACHE=1` | Synchronous mDNS browse, then write the cache. | ~1.3 s |

The `host` (no port) form is **never** cached because it is allowed to match multiple servers and would race with the LAN topology.

`FPB_NO_CACHE=1` disables both reads and writes. `rm ~/.cache/fpbinject/handles.json` wipes the cache. Both are safe at any time.

Atomic writes use `tempfile + os.replace`, so concurrent CLI invocations cannot produce a half-written file. Last writer wins.

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Success. |
| `1` | Runtime failure (connect/auth/IO/invalid flag combination). |
| `2` | Multiple servers discovered without `--server-url`; user must disambiguate. |

## v1 limitations

- `device` TXT is published once at startup as `none`. Real-time `connected`/`none` transitions are deferred to a later TXT schema version.
- `auth` TXT carries advertised intent (server's `--no-auth` flag), not effective state. A misconfigured server with `auth=token` but no token configured is the server operator's problem, not the client's.
- Ungraceful exits (`kill -9`, crash) leave a stale advertisement until mDNS TTL eviction. SIGINT and SIGTERM are handled and trigger an immediate goodbye packet.

## References

- RFC 6762 — Multicast DNS
- RFC 6763 — DNS-Based Service Discovery (§4.1.1 instance names, §6.7 `txtvers`)
- RFC 6335 §5.1 — service-name format
- python-zeroconf (https://github.com/python-zeroconf/python-zeroconf)
67 changes: 67 additions & 0 deletions Tools/WebServer/cli/connection_plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Connection-plan data model for the CLI.

Single source of truth for what mode the CLI is operating in and what server
URL / token / serial port it should use. The resolver builds a
``ConnectionPlan`` once; the connector consumes it once. There is no other
decision-making code path between the two.

See ``Tools/WebServer/Docs/Discovery.md`` for the precedence rules and
``Docs/CLI.md`` for the user-facing description.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Optional


class CommandPolicy(Enum):
"""How a subcommand interacts with the server / device.

Subparsers attach exactly one of these via ``set_defaults`` so the
resolver and the dispatcher don't keep parallel hard-coded sets.
"""

OFFLINE = "offline" # ELF analysis only; never connects
DEVICE = "device" # needs a connected device to succeed
SERVER_ADMIN = "server_admin" # talks to a specific server only (server-stop)


class ConnectionMode(Enum):
"""Runtime mode after resolution."""

OFFLINE = "offline"
LOCAL_PROXY = "local_proxy"
REMOTE_PROXY = "remote_proxy"
DIRECT = "direct"


@dataclass(frozen=True)
class ConnectionPlan:
"""Output of ``resolve_connection_plan(args)``.

Every field is final. The connector reads but never mutates it.

``allow_launch``: only set for LOCAL_PROXY when the server URL is the
default localhost endpoint. Auto-launch never crosses hosts.

``allow_direct_fallback``: only set for LOCAL_PROXY plans that already
carry a ``serial_port`` -- preserves the legacy "auto-launch fails ->
direct serial" behavior, scoped to that one path.

``source``: short string describing which resolver branch produced the
plan, surfaced when ``--verbose``. Examples: ``"flag"``, ``"env"``,
``"localhost-default"``, ``"pid"``, ``"mdns"``, ``"direct"``,
``"offline-command"``.
"""

mode: ConnectionMode
server_url: Optional[str] = None
token: Optional[str] = None
serial_port: Optional[str] = None
baudrate: int = 115200
allow_launch: bool = False
allow_direct_fallback: bool = False
source: str = ""
cache_handle: Optional[str] = None
Loading
Loading