diff --git a/Cargo.lock b/Cargo.lock index 174f23ca..4327d1f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.3.4", "bitflags 1.3.2", "bytes", "futures-util", @@ -101,6 +101,37 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "sync_wrapper 1.0.2", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.3.4" @@ -118,6 +149,26 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", +] + [[package]] name = "base58ck" version = "0.1.0" @@ -1001,7 +1052,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1317,6 +1368,30 @@ dependencies = [ "tonic", ] +[[package]] +name = "ldk-server-mcp" +version = "0.1.0" +dependencies = [ + "axum 0.7.9", + "base64 0.21.7", + "chrono", + "clap", + "getrandom 0.2.16", + "hex-conservative 0.2.1", + "hyper 1.7.0", + "hyper-util", + "ldk-server-client", + "ldk-server-grpc", + "log", + "ring", + "serde", + "serde_json", + "tokio", + "tokio-rustls 0.26.4", + "toml", + "tower 0.5.2", +] + [[package]] name = "libc" version = "0.2.177" @@ -1745,7 +1820,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.34", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror", "tokio", "tracing", @@ -1782,7 +1857,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] @@ -2182,6 +2257,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2517,7 +2603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" dependencies = [ "async-trait", - "axum", + "axum 0.6.20", "base64 0.21.7", "bytes", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 6b09eb62..f9ccb552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server"] +members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server", "ldk-server-mcp"] exclude = ["e2e-tests"] [profile.release] diff --git a/contrib/ldk-server-mcp-config.toml b/contrib/ldk-server-mcp-config.toml new file mode 100644 index 00000000..132a987b --- /dev/null +++ b/contrib/ldk-server-mcp-config.toml @@ -0,0 +1,37 @@ +# LDK Server MCP gateway settings +[gateway] +# Address the gateway listens on for HTTPS (UI + future MCP endpoint). +listen_addr = "127.0.0.1:3537" + +# Directory the gateway uses for persistent state. Holds tls.crt / tls.key +# (auto-generated if not provided), the future sqlite token store, and the +# bootstrap admin token hash. +storage_dir = "/var/lib/ldk-server-mcp" + +# Optional. Defaults to "info". Accepts: error, warn, info, debug, trace, off. +#log_level = "info" + +# Optional. Defaults to /ldk-server-mcp.log. +#log_file_path = "/var/log/ldk-server-mcp.log" + +# Optional TLS overrides. If both are omitted, a self-signed certificate is +# auto-generated under storage_dir on first start (mirrors the daemon). +#[gateway.tls] +#cert_path = "/etc/ldk-server-mcp/tls.crt" +#key_path = "/etc/ldk-server-mcp/tls.key" +# Extra hosts to include in the auto-generated certificate's SAN list. The +# names "localhost" and "127.0.0.1" are always included. +#hosts = ["mcp.example.com"] + +# Connection details for the upstream LDK Server daemon. +[daemon] +# Bare host:port; the scheme is stripped if present. +address = "127.0.0.1:3536" + +# Path to the api_key file that the daemon writes on first start. Typically +# under //api_key. Must be absolute. +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" + +# Path to the daemon's TLS certificate. Used to pin the daemon's self-signed +# cert when the gateway connects to it. Must be absolute. +tls_cert_path = "/var/lib/ldk-server/tls.crt" diff --git a/docs/brainstorms/2026-05-07-ldk-server-mcp.md b/docs/brainstorms/2026-05-07-ldk-server-mcp.md new file mode 100644 index 00000000..376f06de --- /dev/null +++ b/docs/brainstorms/2026-05-07-ldk-server-mcp.md @@ -0,0 +1,240 @@ +# Brainstorm: ldk-server-mcp — HTTPS MCP gateway for LDK Server + +## Clarified Problem Statement + +**Goal:** Add a new `ldk-server-mcp` crate that exposes the LDK Server gRPC API as an +HTTPS MCP server, with a bundled web UI for minting named, scoped auth tokens that +users hand to Claude (Desktop / Code) for chat-driven node management. + +**Constraints:** +- New crate only — `ldk-server` daemon untouched in v1 +- New deps allowed: `rmcp` (MCP Rust SDK) + `axum` (HTTP server) + `argon2` +- Reuse existing `ldk-server-client` to call the daemon's gRPC over its self-signed TLS +- Reuse the daemon's TLS bootstrap pattern (auto-generate self-signed cert in storage dir) +- Match existing code style: Apache-2.0/MIT dual license header, `cargo fmt`, no surprise abstractions + +**Non-goals:** +- Multi-tenant / multi-node management (one MCP per ldk-server) +- OAuth 2.1 / SSO (bearer tokens only for v1) +- Public-internet hosting story (assume same-host or trusted-network deploy) +- Modifying the daemon's existing single-`api_key` HMAC auth in v1 + +**Success criteria:** +- `cargo run -p ldk-server-mcp -- ` boots, serves UI on `https://localhost:/` +- First-run prints a one-shot bootstrap admin token; admin signs in and mints a named + token with scopes +- "Connect to Claude" UI flow produces a copy-paste MCP server config (Streamable HTTP + URL + bearer) +- All ~40 LDK Server RPCs are reachable as MCP tools, filtered by token scope +- Adding a tool or scope is a small, mechanical edit + +## Approaches Considered + +### Approach A — Gateway with its own auth, daemon untouched (Selected) +- **Sketch:** `ldk-server-mcp` holds the sqlite token store. Tokens have a scope set + (which MCP tools they can call). The gateway holds the daemon's single `api_key` + and is a fully-trusted client. UI lives at `axum` route `/`, MCP at `/mcp` + (Streamable HTTP). +- **Affected files/modules:** new `ldk-server-mcp/` crate; `Cargo.toml` workspace + `members`; `ldk-server-client` as path dep; nothing in `ldk-server/` or + `ldk-server-grpc/`. +- **Tradeoffs:** Fastest to ship. Scope enforcement is at the gateway layer only — + a compromised gateway has full daemon access via the embedded api_key. + Acceptable when gateway and daemon co-locate on a trusted host. +- **Effort:** M. + +### Approach B — Token store in the daemon, MCP is a thin proxy +- **Sketch:** Daemon gains a sqlite `auth_tokens` table + bearer-token auth path + alongside the existing HMAC. Tokens carry scopes; daemon checks scopes per RPC. + MCP gateway just translates MCP tool calls → gRPC and forwards the bearer. +- **Affected files/modules:** new `ldk-server-mcp/` crate **plus** changes in + `ldk-server/src/service.rs`, `ldk-server/src/io/persist/`, + `ldk-server-grpc/src/proto/api.proto`, `ldk-server/src/api/`. +- **Tradeoffs:** Real scope enforcement at the trust boundary; revoking a token + in the daemon stops it for any client immediately. Touches `api.proto`, which + means a wire-level review and an upstream PR is bigger. +- **Effort:** L. + +### Approach C — Phased: ship A first, migrate to B in v2 +- **Sketch:** v1 = Approach A. Token format on disk is designed forward-compatibly + so v2 can lift it into the daemon's sqlite without re-issuing tokens. v2 adds + the daemon-side `auth_tokens` table and the MCP gateway becomes a thin proxy. +- **Tradeoffs:** Get something testable in front of Claude in 1–2 PRs. Defers the + bigger auth refactor until UX is validated. +- **Effort:** M (v1) + L (v2 later). + +## Recommendation + +**Approach A** for v1 (chosen by user). The user's combination of "full API surface" ++ "named tokens with scopes" eventually wants real daemon-side enforcement (Approach +B), but doing B in one shot means a `.proto` change, new admin RPCs, and a new auth +path before anyone has even held the new feature in their hands. Ship A as v1 to +validate the UX, the rmcp integration, and the Claude config flow; promote token +storage into the daemon as v2 once the shape is settled. + +--- + +## v1 Spec + +### Shape +- New workspace crate. Single binary. Talks to the daemon via existing + `ldk-server-client` over the daemon's gRPC + TLS. Daemon untouched. +- Single `axum` listener with TLS, four surfaces: + - `/` and `/assets/*` — embedded UI (`include_str!` of the HTML) + - `/api/*` — UI REST endpoints, HttpOnly-cookie authed + - `/mcp` — MCP Streamable HTTP endpoint, `Authorization: Bearer ` authed + - `/healthz` — liveness probe (no auth) + +### Config (`ldk-server-mcp.toml`) + +```toml +[gateway] +listen_addr = "127.0.0.1:3537" +storage_dir = "/var/lib/ldk-server-mcp" +log_level = "info" + +[gateway.tls] +# Optional. Omit to auto-generate self-signed cert in storage_dir. +cert_path = "/etc/ldk-server-mcp/tls.crt" +key_path = "/etc/ldk-server-mcp/tls.key" + +[daemon] +address = "https://127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +``` + +### Auth model (two token types) +- **Bootstrap admin token** — generated on first run, printed once to stdout, hash + stored at `/bootstrap_token.hash` (argon2id). Used only to sign into + the UI. Rotatable via `ldk-server-mcp rotate-admin-token `. +- **Named tokens** — minted from the UI, written to `/mcp.sqlite`, + used by Claude. Bearer format: `lsmcp_`. Stored as argon2id hash + plus an 8-char prefix for UI display. + +### sqlite schema (`mcp.sqlite`) + +```sql +CREATE TABLE auth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_id TEXT NOT NULL UNIQUE, -- public id, hex(16B) + name TEXT NOT NULL UNIQUE, -- [a-z0-9-]+, 1..40 + secret_hash TEXT NOT NULL, -- argon2id + secret_prefix TEXT NOT NULL, -- first 8 chars of bearer + scopes TEXT NOT NULL, -- JSON array + created_at INTEGER NOT NULL, + revoked_at INTEGER, + last_used_at INTEGER +); +CREATE INDEX idx_auth_tokens_token_id ON auth_tokens(token_id); +``` + +`last_used_at` updates are debounced — one write per token per 60s, in a background task. + +### Scope catalog + +| Scope | RPCs | +|---|---| +| `read-only` | `get_node_info`, `get_balances`, `list_channels`, `list_payments`, `get_payment_details`, `list_forwarded_payments`, `list_peers`, `graph_list_channels`, `graph_get_channel`, `graph_list_nodes`, `graph_get_node`, `decode_invoice`, `decode_offer`, `export_pathfinding_scores` | +| `receive` | `bolt11_receive`, `bolt11_receive_for_hash`, `bolt11_claim_for_hash`, `bolt11_fail_for_hash`, `bolt11_receive_via_jit_channel`, `bolt11_receive_variable_amount_via_jit_channel`, `bolt12_receive`, `onchain_receive` | +| `payments` | `bolt11_send`, `bolt12_send`, `onchain_send`, `spontaneous_send`, `unified_send` | +| `channels` | `open_channel`, `close_channel`, `force_close_channel`, `splice_in`, `splice_out`, `update_channel_config` | +| `peers` | `connect_peer`, `disconnect_peer` | +| `signing` | `sign_message`, `verify_signature` | +| `events` | streaming notifications only | +| `admin` | superset of all of the above | + +### MCP tool layer +- 1:1 mapping: each daemon RPC → one MCP tool (snake_case names). +- JSON Schema for inputs hand-written for v1 (small per-tool modules). +- Each handler: validate scope → deserialize args → call `LdkServerClient` method + → serialize response. gRPC errors map to MCP errors via a small status-code table. +- The MCP catalog response (`tools/list`) is filtered per-bearer so Claude only + sees what it can call. + +### SubscribeEvents → MCP notifications +- One long-lived gRPC subscription from the gateway to the daemon, fanned out + internally with a `tokio::broadcast` channel. +- Each MCP session whose token has `events` (or `admin`) gets forwarded events as + MCP `notifications/message` with method `ldk-server/event` and the + `EventEnvelope` JSON as params. + +### Crate layout + +``` +ldk-server-mcp/ + Cargo.toml + src/ + main.rs # clap, config load, axum boot, signal handling + config.rs # TOML schema + validation + storage.rs # sqlite migrations + token DAO + session DAO + auth/ + session.rs # UI cookie sessions + bearer.rs # MCP bearer validation + scope check + bootstrap.rs # admin token gen/rotate + daemon/ + client.rs # wraps ldk-server-client::LdkServerClient + events.rs # SubscribeEvents fan-out + mcp/ + tools.rs # catalog, scope filtering, dispatch + handlers/ # node, balances, payments, channels, peers, signing, graph + notifications.rs # event -> notification + web/ + routes.rs # /api/* axum routes + static_assets.rs # include_str!("../ui/index.html") + util/ + tls.rs # copy of daemon's pattern + logger.rs # reuse ServerLogger style + ui/ + index.html # produced by the UI design prompt +``` + +### New deps (approved) +- `rmcp` (modelcontextprotocol/rust-sdk) — Streamable HTTP server transport +- `axum`, `tower`, `tower-http` — HTTP, middleware +- `argon2` — token hashing +- `tower-cookies` (or `cookie` directly) — UI session cookie + +Already in tree and reused: `tokio`, `tokio-rustls`, `ring`, `rusqlite`, `serde` / +`serde_json`, `prost`, `chrono`, `clap`, `log`, `hex-conservative`, `base64`, +`getrandom`, `ldk-server-client`, `ldk-server-grpc`. + +### First-run UX + +``` +$ ldk-server-mcp /etc/ldk-server-mcp/config.toml +[INFO] Generated bootstrap admin token (save it now): + lsmcp-admin_4n7q...G2k8 +[INFO] gateway listening on https://127.0.0.1:3537 +[INFO] daemon connection ok (alias='alice', network='signet') +``` + +### Suggested PR breakdown + +1. **PR1 — crate skeleton** — workspace member, config, TLS bootstrap, axum + listener, healthz, daemon connection check. No auth, no UI, no MCP yet. +2. **PR2 — sqlite + auth** — token DAO, argon2 hashing, bootstrap admin token, + UI session cookie, `/api/login`, `/api/tokens` (list/create/revoke). +3. **PR3 — UI** — drop in the HTML, wire up `/api/*` endpoints, basic dashboard. +4. **PR4 — MCP read-only tools** — `rmcp` integration, tool catalog filtered by + scope, handlers for the read RPCs. +5. **PR5 — MCP write scopes** — handlers for `payments`, `receive`, `channels`, + `peers`, `signing`. +6. **PR6 — Events** — daemon `SubscribeEvents` fan-out, MCP notifications. +7. **PR7 — Connect-to-Claude UX** — `/api/connect/snippet` + UI tab. +8. **PR8 — operational polish** — `rotate-admin-token` subcommand, structured + logs, systemd unit example, README and `docs/mcp-gateway.md`. + +### Deferred to v2 +- Daemon-side `auth_tokens` (Approach B) +- OAuth 2.1 dynamic client registration +- Prometheus metrics on the gateway +- Per-token rate limits +- Multi-node fan-out + +## Open Questions (resolved) +- MCP transport: **Streamable HTTP only** +- UI tech: **single self-contained HTML, no build step** +- Bootstrap admin token: **printed once on first run, hash stored on disk** +- Listening port: **127.0.0.1:3537** (default) +- Same binary for UI + MCP: **yes** diff --git a/ldk-server-mcp/Cargo.toml b/ldk-server-mcp/Cargo.toml new file mode 100644 index 00000000..56418c47 --- /dev/null +++ b/ldk-server-mcp/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "ldk-server-mcp" +version = "0.1.0" +edition = "2021" + +[dependencies] +ldk-server-client = { path = "../ldk-server-client" } +ldk-server-grpc = { path = "../ldk-server-grpc" } + +# HTTP server +axum = { version = "0.7", default-features = false, features = ["json", "tokio"] } +hyper = { version = "1", default-features = false, features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", default-features = false, features = ["tokio", "service", "server-auto", "http1", "http2"] } + +# TLS +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +ring = { version = "0.17", default-features = false } + +# Async runtime +tokio = { version = "1.38.0", default-features = false, features = ["time", "signal", "rt-multi-thread", "sync", "macros", "net"] } + +# Serialization +serde = { version = "1.0.203", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } +toml = { version = "0.8.9", default-features = false, features = ["parse"] } + +# CLI +clap = { version = "4.0.5", default-features = false, features = ["derive", "std", "error-context", "suggestions", "help", "env"] } + +# Utility +log = { version = "0.4.28", features = ["std"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +hex = { package = "hex-conservative", version = "0.2.1", default-features = false, features = ["std"] } +base64 = { version = "0.21", default-features = false, features = ["std"] } +getrandom = { version = "0.2", default-features = false } + +[dev-dependencies] +tower = { version = "0.5", default-features = false, features = ["util"] } diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md new file mode 100644 index 00000000..cf127720 --- /dev/null +++ b/ldk-server-mcp/README.md @@ -0,0 +1,40 @@ +# ldk-server-mcp + +`ldk-server-mcp` is an HTTPS gateway that sits in front of an LDK Server daemon and (eventually) +exposes its API as an MCP (Model Context Protocol) server, plus a small admin web UI for minting +named, scoped auth tokens to plug into Claude Desktop / Claude Code. + +## Status + +This crate is the **v1 scaffolding only**. It currently: + +- Loads a TOML config (see `contrib/ldk-server-mcp-config.toml`) +- Auto-generates a self-signed TLS certificate in the storage directory (or uses paths from + the config), mirroring the daemon's pattern +- Verifies it can reach the upstream daemon on boot by calling `GetNodeInfo` +- Serves a single `/healthz` endpoint over HTTPS via `axum` +- Handles `SIGTERM`, `SIGHUP` (log reopen), and `Ctrl-C` + +The token store, UI, MCP tool layer, and event-streaming notifications are landing in +follow-up PRs. See `docs/brainstorms/2026-05-07-ldk-server-mcp.md` for the full v1 spec and PR +breakdown. + +## Build & run + +```bash +cargo build --release -p ldk-server-mcp +cp contrib/ldk-server-mcp-config.toml my-mcp.toml # edit paths to match your daemon +./target/release/ldk-server-mcp my-mcp.toml +``` + +Probe the health endpoint (use `-k` because the cert is self-signed): + +```bash +curl -k https://127.0.0.1:3537/healthz +# -> ok +``` + +## Architecture + +`ldk-server-mcp` is a separate workspace crate from the `ldk-server` daemon. It calls the daemon +over its existing gRPC + TLS interface using `ldk-server-client`. The daemon is unmodified. diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs new file mode 100644 index 00000000..0c495eaa --- /dev/null +++ b/ldk-server-mcp/src/config.rs @@ -0,0 +1,354 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs; +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Parser; +use log::LevelFilter; +use serde::Deserialize; + +/// CLI args. The single positional is the path to a TOML config file. All other +/// behavior is configured via the file. +#[derive(Parser, Debug)] +#[command(name = "ldk-server-mcp", about = "MCP gateway for LDK Server", version)] +pub struct ArgsConfig { + /// Path to the TOML configuration file. + pub config_file: PathBuf, +} + +/// On-disk TOML schema. Mirrors the daemon's config style. +/// +/// `deny_unknown_fields` is set on every section so a typo in `cert_path` (etc.) +/// surfaces as a parse error instead of silently using the default. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigFile { + gateway: GatewaySection, + daemon: DaemonSection, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct GatewaySection { + listen_addr: String, + storage_dir: String, + #[serde(default)] + log_level: Option, + #[serde(default)] + log_file_path: Option, + #[serde(default)] + tls: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TlsSection { + #[serde(default)] + cert_path: Option, + #[serde(default)] + key_path: Option, + #[serde(default)] + hosts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DaemonSection { + address: String, + api_key_path: String, + tls_cert_path: String, +} + +/// Optional per-host TLS settings for the gateway. If both fields are `None`, +/// a self-signed certificate is auto-generated under `storage_dir`. +#[derive(Debug, Clone)] +pub struct TlsConfig { + pub cert_path: Option, + pub key_path: Option, + pub hosts: Vec, +} + +/// Connection details for the upstream LDK Server daemon. +#[derive(Debug, Clone)] +pub struct DaemonConfig { + /// Bare host:port (the scheme is stripped if the user includes one). + pub address: String, + pub api_key_path: PathBuf, + pub tls_cert_path: PathBuf, +} + +/// Fully-validated configuration ready to drive the runtime. +#[derive(Debug, Clone)] +pub struct Config { + pub listen_addr: SocketAddr, + pub storage_dir: PathBuf, + pub log_level: LevelFilter, + pub log_file_path: PathBuf, + pub tls_config: Option, + pub daemon: DaemonConfig, +} + +/// Loads and validates the configuration file referenced by `args`. +pub fn load_config(args: &ArgsConfig) -> Result { + let raw = fs::read_to_string(&args.config_file) + .map_err(|e| format!("Failed to read config file '{}': {e}", args.config_file.display()))?; + let parsed: ConfigFile = toml::from_str(&raw).map_err(|e| { + format!("Failed to parse config file '{}': {e}", args.config_file.display()) + })?; + + let listen_addr = parsed.gateway.listen_addr.parse::().map_err(|e| { + format!("Invalid gateway.listen_addr '{}': {e}", parsed.gateway.listen_addr) + })?; + + let storage_dir = PathBuf::from(&parsed.gateway.storage_dir); + if storage_dir.as_os_str().is_empty() { + return Err("gateway.storage_dir must be a non-empty path".to_string()); + } + + let log_level = parse_log_level(parsed.gateway.log_level.as_deref())?; + let log_file_path = match parsed.gateway.log_file_path { + Some(p) => PathBuf::from(p), + None => storage_dir.join("ldk-server-mcp.log"), + }; + + if log_file_path == storage_dir { + return Err("gateway.log_file_path cannot be the same as gateway.storage_dir".to_string()); + } + + let tls_config = parsed.gateway.tls.map(|t| TlsConfig { + cert_path: t.cert_path, + key_path: t.key_path, + hosts: t.hosts, + }); + + let address = strip_scheme(&parsed.daemon.address).to_string(); + if address.is_empty() { + return Err("daemon.address must include a host:port".to_string()); + } + + let api_key_path = PathBuf::from(&parsed.daemon.api_key_path); + if !api_key_path.is_absolute() { + // Daemons are typically managed via systemd units with absolute paths; + // relative paths are almost always a misconfiguration. + return Err(format!( + "daemon.api_key_path must be an absolute path, got '{}'", + api_key_path.display() + )); + } + + let tls_cert_path = PathBuf::from(&parsed.daemon.tls_cert_path); + if !tls_cert_path.is_absolute() { + return Err(format!( + "daemon.tls_cert_path must be an absolute path, got '{}'", + tls_cert_path.display() + )); + } + + Ok(Config { + listen_addr, + storage_dir, + log_level, + log_file_path, + tls_config, + daemon: DaemonConfig { address, api_key_path, tls_cert_path }, + }) +} + +fn parse_log_level(s: Option<&str>) -> Result { + match s.map(str::to_ascii_lowercase).as_deref() { + None | Some("info") => Ok(LevelFilter::Info), + Some("trace") => Ok(LevelFilter::Trace), + Some("debug") => Ok(LevelFilter::Debug), + Some("warn") => Ok(LevelFilter::Warn), + Some("error") => Ok(LevelFilter::Error), + Some("off") => Ok(LevelFilter::Off), + Some(other) => Err(format!("Invalid log_level '{other}'")), + } +} + +fn strip_scheme(s: &str) -> &str { + s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")).unwrap_or(s) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + fn write_temp_config(contents: &str) -> PathBuf { + let mut suffix_bytes = [0u8; 8]; + getrandom::getrandom(&mut suffix_bytes).unwrap(); + let suffix = u64::from_ne_bytes(suffix_bytes); + let path = std::env::temp_dir().join(format!("ldk-server-mcp-test-{suffix}.toml")); + let mut f = fs::File::create(&path).unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + path + } + + #[test] + fn parses_minimal_config() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "127.0.0.1:3537" +storage_dir = "/var/lib/ldk-server-mcp" + +[daemon] +address = "https://127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let cfg = load_config(&args).unwrap(); + fs::remove_file(&path).ok(); + + assert_eq!(cfg.listen_addr.to_string(), "127.0.0.1:3537"); + assert_eq!(cfg.log_level, LevelFilter::Info); + assert_eq!(cfg.daemon.address, "127.0.0.1:3536"); + assert!(cfg.tls_config.is_none()); + } + + #[test] + fn parses_full_config() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "0.0.0.0:8443" +storage_dir = "/srv/ldk-mcp" +log_level = "debug" +log_file_path = "/var/log/ldk-server-mcp.log" + +[gateway.tls] +cert_path = "/etc/ldk-server-mcp/tls.crt" +key_path = "/etc/ldk-server-mcp/tls.key" +hosts = ["mcp.example.com"] + +[daemon] +address = "127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let cfg = load_config(&args).unwrap(); + fs::remove_file(&path).ok(); + + assert_eq!(cfg.log_level, LevelFilter::Debug); + assert_eq!(cfg.log_file_path, PathBuf::from("/var/log/ldk-server-mcp.log")); + let tls = cfg.tls_config.unwrap(); + assert_eq!(tls.cert_path.unwrap(), "/etc/ldk-server-mcp/tls.crt"); + assert_eq!(tls.hosts, vec!["mcp.example.com".to_string()]); + } + + #[test] + fn rejects_invalid_listen_addr() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "not-a-socket" +storage_dir = "/tmp" + +[daemon] +address = "127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let err = load_config(&args).unwrap_err(); + fs::remove_file(&path).ok(); + assert!(err.contains("listen_addr"), "unexpected error: {err}"); + } + + #[test] + fn rejects_relative_api_key_path() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "127.0.0.1:3537" +storage_dir = "/tmp" + +[daemon] +address = "127.0.0.1:3536" +api_key_path = "relative/path" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let err = load_config(&args).unwrap_err(); + fs::remove_file(&path).ok(); + assert!(err.contains("absolute path"), "unexpected error: {err}"); + } + + #[test] + fn strips_scheme_from_daemon_address() { + assert_eq!(strip_scheme("https://localhost:3536"), "localhost:3536"); + assert_eq!(strip_scheme("http://localhost:3536"), "localhost:3536"); + assert_eq!(strip_scheme("localhost:3536"), "localhost:3536"); + } + + #[test] + fn rejects_invalid_log_level() { + assert!(parse_log_level(Some("verbose")).is_err()); + } + + #[test] + fn rejects_unknown_top_level_field() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "127.0.0.1:3537" +storage_dir = "/tmp" + +[daemon] +address = "127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" + +[surprise] +hello = "world" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let err = load_config(&args).unwrap_err(); + fs::remove_file(&path).ok(); + assert!(err.contains("unknown") || err.contains("surprise"), "unexpected error: {err}"); + } + + #[test] + fn rejects_unknown_gateway_field() { + let path = write_temp_config( + r#" +[gateway] +listen_addr = "127.0.0.1:3537" +storage_dir = "/tmp" +typo_field = "oops" + +[daemon] +address = "127.0.0.1:3536" +api_key_path = "/var/lib/ldk-server/bitcoin/api_key" +tls_cert_path = "/var/lib/ldk-server/tls.crt" +"#, + ); + + let args = ArgsConfig { config_file: path.clone() }; + let err = load_config(&args).unwrap_err(); + fs::remove_file(&path).ok(); + assert!(err.contains("unknown") || err.contains("typo_field"), "unexpected error: {err}"); + } +} diff --git a/ldk-server-mcp/src/daemon/client.rs b/ldk-server-mcp/src/daemon/client.rs new file mode 100644 index 00000000..86bfa909 --- /dev/null +++ b/ldk-server-mcp/src/daemon/client.rs @@ -0,0 +1,56 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs; + +use hex::DisplayHex; +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::error::LdkServerError; +use ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse}; + +use crate::config::DaemonConfig; + +/// Thin wrapper around `LdkServerClient` that loads its credentials from the +/// paths declared in the gateway config. +/// +/// Subsequent PRs (read-only tools, write tools, events) will hang per-RPC +/// methods off this type or call into `inner` directly. +pub struct DaemonClient { + inner: LdkServerClient, +} + +impl DaemonClient { + /// Loads the daemon's API key and TLS certificate from disk and constructs + /// an authenticated client. + pub fn new(config: &DaemonConfig) -> Result { + let api_key_bytes = fs::read(&config.api_key_path).map_err(|e| { + format!("Failed to read daemon api_key from '{}': {e}", config.api_key_path.display()) + })?; + if api_key_bytes.is_empty() { + return Err(format!( + "Daemon api_key file '{}' is empty", + config.api_key_path.display() + )); + } + let api_key = api_key_bytes.to_lower_hex_string(); + + let cert_pem = fs::read(&config.tls_cert_path).map_err(|e| { + format!("Failed to read daemon TLS cert from '{}': {e}", config.tls_cert_path.display()) + })?; + + let inner = LdkServerClient::new(config.address.clone(), api_key, &cert_pem)?; + Ok(Self { inner }) + } + + /// Calls `GetNodeInfo` against the daemon, used at boot to verify the + /// gateway can reach and authenticate to the upstream node. + pub async fn get_node_info(&self) -> Result { + self.inner.get_node_info(GetNodeInfoRequest {}).await + } +} diff --git a/ldk-server-mcp/src/daemon/mod.rs b/ldk-server-mcp/src/daemon/mod.rs new file mode 100644 index 00000000..f8e93bad --- /dev/null +++ b/ldk-server-mcp/src/daemon/mod.rs @@ -0,0 +1,10 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub mod client; diff --git a/ldk-server-mcp/src/main.rs b/ldk-server-mcp/src/main.rs new file mode 100644 index 00000000..9a297787 --- /dev/null +++ b/ldk-server-mcp/src/main.rs @@ -0,0 +1,154 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +mod config; +mod daemon; +mod util; +mod web; + +use std::fs; +use std::process::ExitCode; +use std::sync::Arc; + +use clap::Parser; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto; +use hyper_util::service::TowerToHyperService; +use log::{debug, error, info}; +use tokio::net::TcpListener; +use tokio::select; +use tokio::signal::unix::SignalKind; + +use crate::config::{load_config, ArgsConfig, Config}; +use crate::daemon::client::DaemonClient; +use crate::util::logger::GatewayLogger; +use crate::util::tls::get_or_generate_tls_config; +use crate::web::routes::build_router; + +fn main() -> ExitCode { + let args = ArgsConfig::parse(); + + let config = match load_config(&args) { + Ok(c) => c, + Err(e) => { + eprintln!("Invalid configuration: {e}"); + return ExitCode::from(1); + }, + }; + + if let Err(e) = fs::create_dir_all(&config.storage_dir) { + eprintln!("Failed to create storage_dir '{}': {e}", config.storage_dir.display()); + return ExitCode::from(1); + } + + let logger = match GatewayLogger::init(config.log_level, &config.log_file_path) { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to initialize logger: {e}"); + return ExitCode::from(1); + }, + }; + + let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(rt) => Arc::new(rt), + Err(e) => { + error!("Failed to set up tokio runtime: {e}"); + return ExitCode::from(1); + }, + }; + + runtime.block_on(async { + match run(config, logger).await { + Ok(()) => ExitCode::from(0), + Err(e) => { + error!("Fatal: {e}"); + ExitCode::from(1) + }, + } + }) +} + +async fn run(config: Config, logger: Arc) -> Result<(), String> { + let storage_dir_str = config + .storage_dir + .to_str() + .ok_or_else(|| format!("storage_dir contains non-UTF-8 bytes: {:?}", config.storage_dir))?; + + let server_config = get_or_generate_tls_config(config.tls_config.clone(), storage_dir_str)?; + let tls_acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + + let daemon_client = DaemonClient::new(&config.daemon)?; + match daemon_client.get_node_info().await { + Ok(info) => { + let alias = info.node_alias.as_deref().unwrap_or(""); + info!("daemon connection ok (node_id='{}', alias='{}')", info.node_id, alias); + }, + Err(e) => { + return Err(format!("Daemon health check failed: {e:?}")); + }, + } + + let router = build_router(); + + let listener = TcpListener::bind(config.listen_addr) + .await + .map_err(|e| format!("Failed to bind {}: {e}", config.listen_addr))?; + info!("gateway listening on https://{}", config.listen_addr); + + let mut sighup_stream = tokio::signal::unix::signal(SignalKind::hangup()) + .map_err(|e| format!("Failed to register SIGHUP handler: {e}"))?; + let mut sigterm_stream = tokio::signal::unix::signal(SignalKind::terminate()) + .map_err(|e| format!("Failed to register SIGTERM handler: {e}"))?; + + loop { + select! { + res = listener.accept() => { + match res { + Ok((tcp, peer)) => { + let acceptor = tls_acceptor.clone(); + let svc = TowerToHyperService::new(router.clone()); + tokio::spawn(async move { + let tls = match acceptor.accept(tcp).await { + Ok(s) => s, + Err(e) => { + debug!("TLS handshake failed for {peer}: {e}"); + return; + } + }; + let io = TokioIo::new(tls); + if let Err(e) = auto::Builder::new(TokioExecutor::new()) + .serve_connection(io, svc) + .await + { + debug!("Connection error from {peer}: {e}"); + } + }); + }, + Err(e) => error!("Failed to accept connection: {e}"), + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received CTRL-C, shutting down.."); + break; + } + _ = sigterm_stream.recv() => { + info!("Received SIGTERM, shutting down.."); + break; + } + _ = sighup_stream.recv() => { + if let Err(e) = logger.reopen() { + error!("Failed to reopen log file on SIGHUP: {e}"); + } + } + } + } + + info!("Shutdown complete.."); + Ok(()) +} diff --git a/ldk-server-mcp/src/util/logger.rs b/ldk-server-mcp/src/util/logger.rs new file mode 100644 index 00000000..1d19fa0e --- /dev/null +++ b/ldk-server-mcp/src/util/logger.rs @@ -0,0 +1,154 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// A logger implementation that writes logs to both stdout/stderr and a file. +/// +/// Mirrors the daemon's `ServerLogger` so operators see consistent output +/// across the two processes. +/// +/// All log messages follow the format: +/// `[TIMESTAMP LEVEL TARGET:LINE] MESSAGE` +pub struct GatewayLogger { + level: LevelFilter, + file: Mutex, + log_file_path: PathBuf, +} + +impl GatewayLogger { + /// Initializes the global logger with the specified level and file path. + /// + /// Should be called once at application startup. + pub fn init(level: LevelFilter, log_file_path: &Path) -> Result, io::Error> { + if let Some(parent) = log_file_path.parent() { + fs::create_dir_all(parent)?; + } + + let file = open_log_file(log_file_path)?; + + let logger = Arc::new(GatewayLogger { + level, + file: Mutex::new(file), + log_file_path: log_file_path.to_path_buf(), + }); + + log::set_boxed_logger(Box::new(LoggerWrapper(Arc::clone(&logger)))) + .map_err(io::Error::other)?; + log::set_max_level(level); + Ok(logger) + } + + /// Reopens the log file. Called on SIGHUP for log rotation. + pub fn reopen(&self) -> Result<(), io::Error> { + let new_file = open_log_file(&self.log_file_path)?; + match self.file.lock() { + Ok(mut file) => { + file.flush()?; + *file = new_file; + Ok(()) + }, + Err(e) => Err(io::Error::other(format!("Failed to acquire lock: {e}"))), + } + } +} + +impl Log for GatewayLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let level_str = format_level(record.level()); + let line = record.line().unwrap_or(0); + + let _ = match record.level() { + Level::Error => writeln!( + io::stderr(), + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ), + _ => writeln!( + io::stdout(), + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ), + }; + + if let Ok(mut file) = self.file.lock() { + let _ = writeln!( + file, + "[{} {} {}:{}] {}", + format_timestamp(), + level_str, + record.target(), + line, + record.args() + ); + } + } + } + + fn flush(&self) { + let _ = io::stdout().flush(); + let _ = io::stderr().flush(); + if let Ok(mut file) = self.file.lock() { + let _ = file.flush(); + } + } +} + +fn format_timestamp() -> String { + let now = chrono::Utc::now(); + now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn format_level(level: Level) -> &'static str { + match level { + Level::Error => "ERROR", + Level::Warn => "WARN ", + Level::Info => "INFO ", + Level::Debug => "DEBUG", + Level::Trace => "TRACE", + } +} + +fn open_log_file(log_file_path: &Path) -> Result { + OpenOptions::new().create(true).append(true).open(log_file_path) +} + +struct LoggerWrapper(Arc); + +impl Log for LoggerWrapper { + fn enabled(&self, metadata: &Metadata) -> bool { + self.0.enabled(metadata) + } + + fn log(&self, record: &Record) { + self.0.log(record) + } + + fn flush(&self) { + self.0.flush() + } +} diff --git a/ldk-server-mcp/src/util/mod.rs b/ldk-server-mcp/src/util/mod.rs new file mode 100644 index 00000000..fac861ad --- /dev/null +++ b/ldk-server-mcp/src/util/mod.rs @@ -0,0 +1,11 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub mod logger; +pub mod tls; diff --git a/ldk-server-mcp/src/util/tls.rs b/ldk-server-mcp/src/util/tls.rs new file mode 100644 index 00000000..0b8d2153 --- /dev/null +++ b/ldk-server-mcp/src/util/tls.rs @@ -0,0 +1,410 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +// Mirrors `ldk-server/src/util/tls.rs`. Copied (rather than extracted into a shared +// crate) to keep the v1 PR scoped to a single new crate. See the brainstorm doc +// at docs/brainstorms/2026-05-07-ldk-server-mcp.md for the deferred refactor. + +use std::fs; +use std::net::IpAddr; +use std::os::unix::fs::PermissionsExt; + +use base64::Engine; +use ring::rand::SystemRandom; +use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING}; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::ServerConfig; + +use crate::config::TlsConfig; + +const ISSUER_NAME: &str = "localhost"; + +const PEM_CERT_BEGIN: &str = "-----BEGIN CERTIFICATE-----"; +const PEM_CERT_END: &str = "-----END CERTIFICATE-----"; +const PEM_KEY_BEGIN: &str = "-----BEGIN PRIVATE KEY-----"; +const PEM_KEY_END: &str = "-----END PRIVATE KEY-----"; + +const OID_EC_PUBLIC_KEY: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01]; +const OID_PRIME256V1: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; +const OID_ECDSA_WITH_SHA256: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02]; +const OID_COMMON_NAME: &[u8] = &[0x55, 0x04, 0x03]; +const OID_SUBJECT_ALT_NAME: &[u8] = &[0x55, 0x1D, 0x11]; + +const TAG_INTEGER: u8 = 0x02; +const TAG_BIT_STRING: u8 = 0x03; +const TAG_OCTET_STRING: u8 = 0x04; +const TAG_OID: u8 = 0x06; +const TAG_UTF8_STRING: u8 = 0x0C; +const TAG_UTC_TIME: u8 = 0x17; +const TAG_SEQUENCE: u8 = 0x30; +const TAG_SET: u8 = 0x31; + +/// Gets or generates TLS configuration. If custom paths are provided, uses those. +/// Otherwise, generates a self-signed certificate in the storage directory. +pub fn get_or_generate_tls_config( + tls_config: Option, storage_dir: &str, +) -> Result { + if let Some(config) = tls_config { + let cert_path = config.cert_path.unwrap_or(format!("{storage_dir}/tls.crt")); + let key_path = config.key_path.unwrap_or(format!("{storage_dir}/tls.key")); + if !fs::exists(&cert_path).unwrap_or(false) || !fs::exists(&key_path).unwrap_or(false) { + generate_self_signed_cert(&cert_path, &key_path, &config.hosts)?; + } + load_tls_config(&cert_path, &key_path) + } else { + let cert_path = format!("{storage_dir}/tls.crt"); + let key_path = format!("{storage_dir}/tls.key"); + if !fs::exists(&cert_path).unwrap_or(false) || !fs::exists(&key_path).unwrap_or(false) { + generate_self_signed_cert(&cert_path, &key_path, &[])?; + } + load_tls_config(&cert_path, &key_path) + } +} + +fn parse_pem_certs(pem_data: &str) -> Result>, String> { + let mut certs = Vec::new(); + + for block in pem_data.split(PEM_CERT_END) { + if let Some(start) = block.find(PEM_CERT_BEGIN) { + let base64_content: String = block[start + PEM_CERT_BEGIN.len()..] + .lines() + .filter(|line| !line.starts_with("-----") && !line.is_empty()) + .collect(); + + let der = base64::engine::general_purpose::STANDARD + .decode(&base64_content) + .map_err(|e| format!("Failed to decode certificate base64: {e}"))?; + + certs.push(CertificateDer::from(der)); + } + } + + Ok(certs) +} + +fn parse_pem_private_key(pem_data: &str) -> Result, String> { + let start = pem_data.find(PEM_KEY_BEGIN).ok_or("Missing BEGIN PRIVATE KEY marker")?; + let end = pem_data.find(PEM_KEY_END).ok_or("Missing END PRIVATE KEY marker")?; + + let base64_content: String = pem_data[start + PEM_KEY_BEGIN.len()..end] + .lines() + .filter(|line| !line.starts_with("-----") && !line.is_empty()) + .collect(); + + let der = base64::engine::general_purpose::STANDARD + .decode(&base64_content) + .map_err(|e| format!("Failed to decode private key base64: {e}"))?; + + Ok(PrivateKeyDer::Pkcs8(der.into())) +} + +fn generate_self_signed_cert( + cert_path: &str, key_path: &str, configure_hosts: &[String], +) -> Result<(), String> { + let mut hosts = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + hosts.extend_from_slice(configure_hosts); + + let rng = SystemRandom::new(); + + let pkcs8_doc = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng) + .map_err(|e| format!("Failed to generate key pair: {e}"))?; + + let key_pair = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8_doc.as_ref(), &rng) + .map_err(|e| format!("Failed to parse generated key pair: {e}"))?; + + let cert_der = build_self_signed_cert(&key_pair, &hosts, &rng)?; + + let cert_pem = der_to_pem(&cert_der, PEM_CERT_BEGIN, PEM_CERT_END); + let key_pem = der_to_pem(pkcs8_doc.as_ref(), PEM_KEY_BEGIN, PEM_KEY_END); + + fs::write(key_path, &key_pem) + .map_err(|e| format!("Failed to write TLS key to '{key_path}': {e}"))?; + fs::set_permissions(key_path, fs::Permissions::from_mode(0o400)) + .map_err(|e| format!("Failed to set TLS key permissions for '{key_path}': {e}"))?; + fs::write(cert_path, &cert_pem) + .map_err(|e| format!("Failed to write TLS certificate to '{cert_path}': {e}"))?; + + Ok(()) +} + +fn der_to_pem(der: &[u8], begin: &str, end: &str) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let lines: Vec<&str> = + b64.as_bytes().chunks(64).map(|c| std::str::from_utf8(c).unwrap()).collect(); + format!("{begin}\n{}\n{end}\n", lines.join("\n")) +} + +fn build_self_signed_cert( + key_pair: &EcdsaKeyPair, hosts: &[String], rng: &SystemRandom, +) -> Result, String> { + let tbs_cert = build_tbs_certificate(key_pair, hosts)?; + let signature = key_pair.sign(rng, &tbs_cert).map_err(|_| "Failed to sign certificate")?; + + let sig_alg_oid = der_oid(OID_ECDSA_WITH_SHA256); + let sig_alg = der_sequence(&sig_alg_oid); + let sig_value = der_bit_string(signature.as_ref()); + + let cert_content = [tbs_cert, sig_alg, sig_value].concat(); + Ok(der_sequence(&cert_content)) +} + +fn build_tbs_certificate(key_pair: &EcdsaKeyPair, hosts: &[String]) -> Result, String> { + let version = der_context_explicit(0, &der_integer(&[2])); + let serial_number = der_integer(&[1]); + let sig_alg_oid = der_oid(OID_ECDSA_WITH_SHA256); + let signature_alg = der_sequence(&sig_alg_oid); + let issuer = build_name(ISSUER_NAME); + let validity = + der_sequence(&[der_utc_time("260101000000Z"), der_utc_time("491231235959Z")].concat()); + let subject = build_name(ISSUER_NAME); + let spki = build_subject_public_key_info(key_pair); + let extensions = build_extensions(hosts)?; + let extensions_explicit = der_context_explicit(3, &extensions); + + let tbs_content = [ + version, + serial_number, + signature_alg, + issuer, + validity, + subject, + spki, + extensions_explicit, + ] + .concat(); + + Ok(der_sequence(&tbs_content)) +} + +fn build_name(cn: &str) -> Vec { + let cn_attr = der_sequence(&[der_oid(OID_COMMON_NAME), der_utf8_string(cn)].concat()); + let rdn = der_set(&cn_attr); + der_sequence(&rdn) +} + +fn build_subject_public_key_info(key_pair: &EcdsaKeyPair) -> Vec { + let algorithm = der_sequence(&[der_oid(OID_EC_PUBLIC_KEY), der_oid(OID_PRIME256V1)].concat()); + let public_key = key_pair.public_key().as_ref(); + let public_key_bits = der_bit_string(public_key); + der_sequence(&[algorithm, public_key_bits].concat()) +} + +fn build_extensions(hosts: &[String]) -> Result, String> { + let san_ext = build_san_extension(hosts)?; + Ok(der_sequence(&san_ext)) +} + +fn build_san_extension(hosts: &[String]) -> Result, String> { + let mut general_names = Vec::new(); + + for host in hosts { + if let Ok(ip) = host.parse::() { + let ip_bytes = match ip { + IpAddr::V4(v4) => v4.octets().to_vec(), + IpAddr::V6(v6) => v6.octets().to_vec(), + }; + general_names.extend(der_context_implicit(7, &ip_bytes)); + } else { + general_names.extend(der_context_implicit(2, host.as_bytes())); + } + } + + let san_value = der_sequence(&general_names); + let san_octet = der_octet_string(&san_value); + + Ok(der_sequence(&[der_oid(OID_SUBJECT_ALT_NAME), san_octet].concat())) +} + +fn der_length_size(len: usize) -> usize { + if len < 128 { + 1 + } else if len < 256 { + 2 + } else if len < 65536 { + 3 + } else { + 4 + } +} + +fn der_tag_length_value(tag: u8, value: &[u8]) -> Vec { + let len = value.len(); + let len_size = der_length_size(len); + let mut result = Vec::with_capacity(1 + len_size + len); + + result.push(tag); + + if len < 128 { + result.push(len as u8); + } else if len < 256 { + result.push(0x81); + result.push(len as u8); + } else if len < 65536 { + result.push(0x82); + result.push((len >> 8) as u8); + result.push(len as u8); + } else { + result.push(0x83); + result.push((len >> 16) as u8); + result.push((len >> 8) as u8); + result.push(len as u8); + } + + result.extend_from_slice(value); + result +} + +fn der_sequence(content: &[u8]) -> Vec { + der_tag_length_value(TAG_SEQUENCE, content) +} + +fn der_set(content: &[u8]) -> Vec { + der_tag_length_value(TAG_SET, content) +} + +fn der_integer(value: &[u8]) -> Vec { + if !value.is_empty() && value[0] & 0x80 != 0 { + let mut padded = Vec::with_capacity(1 + value.len()); + padded.push(0x00); + padded.extend_from_slice(value); + der_tag_length_value(TAG_INTEGER, &padded) + } else { + der_tag_length_value(TAG_INTEGER, value) + } +} + +fn der_bit_string(value: &[u8]) -> Vec { + let mut content = Vec::with_capacity(1 + value.len()); + content.push(0x00); + content.extend_from_slice(value); + der_tag_length_value(TAG_BIT_STRING, &content) +} + +fn der_octet_string(value: &[u8]) -> Vec { + der_tag_length_value(TAG_OCTET_STRING, value) +} + +fn der_oid(oid: &[u8]) -> Vec { + der_tag_length_value(TAG_OID, oid) +} + +fn der_utf8_string(s: &str) -> Vec { + der_tag_length_value(TAG_UTF8_STRING, s.as_bytes()) +} + +fn der_utc_time(s: &str) -> Vec { + der_tag_length_value(TAG_UTC_TIME, s.as_bytes()) +} + +fn der_context_explicit(tag_num: u8, content: &[u8]) -> Vec { + der_tag_length_value(0xA0 | tag_num, content) +} + +fn der_context_implicit(tag_num: u8, content: &[u8]) -> Vec { + der_tag_length_value(0x80 | tag_num, content) +} + +fn load_tls_config(cert_path: &str, key_path: &str) -> Result { + let cert_pem = fs::read_to_string(cert_path) + .map_err(|e| format!("Failed to read TLS certificate file '{cert_path}': {e}"))?; + let key_pem = fs::read_to_string(key_path) + .map_err(|e| format!("Failed to read TLS key file '{key_path}': {e}"))?; + + let certs = parse_pem_certs(&cert_pem)?; + + if certs.is_empty() { + return Err("No certificates found in certificate file".to_string()); + } + + let key = parse_pem_private_key(&key_pem)?; + + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| format!("Failed to build TLS server config: {e}"))?; + // Advertise both HTTP/2 and HTTP/1.1 so browsers (UI) and gRPC-style HTTP/2 + // clients (the future MCP transport) both negotiate cleanly. + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pem_certs_empty() { + let certs = parse_pem_certs("").unwrap(); + assert!(certs.is_empty()); + + let certs = parse_pem_certs("not a cert").unwrap(); + assert!(certs.is_empty()); + } + + #[test] + fn test_parse_pem_private_key_invalid() { + let result = parse_pem_private_key(""); + assert!(result.is_err()); + + let result = parse_pem_private_key("not a key"); + assert!(result.is_err()); + } + + #[test] + fn test_generate_and_load_roundtrip() { + let temp_dir = std::env::temp_dir(); + let mut suffix_bytes = [0u8; 8]; + getrandom::getrandom(&mut suffix_bytes).unwrap(); + let suffix = u64::from_ne_bytes(suffix_bytes); + let cert_path = temp_dir.join(format!("test_mcp_tls_cert_{suffix}.pem")); + let key_path = temp_dir.join(format!("test_mcp_tls_key_{suffix}.pem")); + + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + + generate_self_signed_cert(cert_path.to_str().unwrap(), key_path.to_str().unwrap(), &[]) + .unwrap(); + + assert!(cert_path.exists()); + assert!(key_path.exists()); + + let res = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap()); + assert!(res.is_ok(), "load_tls_config failed: {:?}", res.err()); + + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + } + + #[test] + fn test_generate_self_signed_cert_with_extra_hosts() { + let temp_dir = std::env::temp_dir(); + let mut suffix_bytes = [0u8; 8]; + getrandom::getrandom(&mut suffix_bytes).unwrap(); + let suffix = u64::from_ne_bytes(suffix_bytes); + let cert_path = temp_dir.join(format!("test_mcp_tls_san_cert_{suffix}.pem")); + let key_path = temp_dir.join(format!("test_mcp_tls_san_key_{suffix}.pem")); + + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + + generate_self_signed_cert( + cert_path.to_str().unwrap(), + key_path.to_str().unwrap(), + &["mcp.example.com".to_string(), "10.0.0.1".to_string()], + ) + .unwrap(); + + assert!(cert_path.exists()); + assert!(key_path.exists()); + + let _ = fs::remove_file(&cert_path); + let _ = fs::remove_file(&key_path); + } +} diff --git a/ldk-server-mcp/src/web/mod.rs b/ldk-server-mcp/src/web/mod.rs new file mode 100644 index 00000000..85e75e14 --- /dev/null +++ b/ldk-server-mcp/src/web/mod.rs @@ -0,0 +1,10 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +pub mod routes; diff --git a/ldk-server-mcp/src/web/routes.rs b/ldk-server-mcp/src/web/routes.rs new file mode 100644 index 00000000..1aca0722 --- /dev/null +++ b/ldk-server-mcp/src/web/routes.rs @@ -0,0 +1,56 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; + +/// Builds the gateway's HTTP router. v1 only exposes `/healthz`; subsequent +/// PRs add `/api/*` (UI), `/mcp` (MCP Streamable HTTP), and the static UI. +pub fn build_router() -> Router { + Router::new().route("/healthz", get(healthz)) +} + +async fn healthz() -> impl IntoResponse { + (StatusCode::OK, "ok") +} + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + use super::*; + + #[tokio::test] + async fn healthz_returns_ok() { + let app = build_router(); + let response = app + .oneshot(Request::builder().uri("/healthz").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024).await.unwrap(); + assert_eq!(&body[..], b"ok"); + } + + #[tokio::test] + async fn unknown_path_returns_404() { + let app = build_router(); + let response = app + .oneshot(Request::builder().uri("/nope").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +}