vtc-service 0.12.0

Service for Verifiable Trust Communities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Admin UX route surface (§12.2, Phase 5 M5.7).
//!
//! Two handlers:
//!
//! - **Catch-all** (`GET /admin/*`) → serves the baked SPA from
//!   [`crate::admin_ui`], or the owner's console directory under
//!   `admin_ui.mode = "directory"`. SPA history-mode fallback: paths that
//!   don't match a baked file fall back to `index.html` so
//!   client-side routing works.
//! - **Build-info** (`GET /admin/build-info.json`) → returns the
//!   served console's SHA-256 + file count + mode. Unauth —
//!   the daemon's release metadata is public.

#![cfg(feature = "admin-ui")]

use std::path::{Path as StdPath, PathBuf};
use std::sync::LazyLock;
use std::time::{Duration, Instant};

use axum::Json;
use axum::body::Body;
use axum::extract::{Path, Request, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::warn;

use crate::admin_ui::AdminUiInfo;
use crate::server::AppState;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
    pub version: String,
    pub index_sha256: String,
    pub file_count: u32,
    pub mode: String,
}

/// `GET /admin/build-info.json` — unauth, surfaces the console being
/// served: the baked one, or with `admin_ui.mode = "directory"` the one on
/// disk (cached for [`PLUGIN_SCAN_TTL`], since hashing it walks the
/// directory and this route is unauthenticated).
pub async fn build_info(State(state): State<AppState>) -> Json<BuildInfo> {
    let config = state.config.read().await.admin_ui.clone();
    let info = match config.serve_dir() {
        Some(dir) => directory_info_cached(dir, &config.mode).await,
        None => AdminUiInfo::from_embedded(&config.mode),
    };
    Json(BuildInfo {
        // The admin SPA carries its own internal version, but
        // the embedded build's SHA-256 is what an operator
        // actually pins against.
        version: env!("CARGO_PKG_VERSION").to_string(),
        index_sha256: (*info.index_sha256).clone(),
        file_count: info.file_count,
        mode: (*info.mode).clone(),
    })
}

/// Short-TTL cache of [`AdminUiInfo::from_directory`], keyed by directory.
static DIRECTORY_INFO_CACHE: LazyLock<RwLock<Option<(PathBuf, Instant, AdminUiInfo)>>> =
    LazyLock::new(|| RwLock::new(None));

async fn directory_info_cached(dir: &StdPath, mode: &str) -> AdminUiInfo {
    if let Some((cached_dir, at, info)) = DIRECTORY_INFO_CACHE.read().await.as_ref()
        && cached_dir == dir
        && at.elapsed() < PLUGIN_SCAN_TTL
    {
        return info.clone();
    }
    let info = AdminUiInfo::from_directory(dir, mode);
    *DIRECTORY_INFO_CACHE.write().await = Some((dir.to_path_buf(), Instant::now(), info.clone()));
    info
}

/// `GET /admin/*` — serve the console: from `admin_ui.dir` when
/// `admin_ui.mode = "directory"`, the baked SPA otherwise.
pub async fn serve_spa(State(state): State<AppState>, req: Request<Body>) -> Response {
    let dir = state
        .config
        .read()
        .await
        .admin_ui
        .serve_dir()
        .map(StdPath::to_path_buf);
    let mut response = match dir {
        Some(dir) => crate::admin_ui::serve_dir(&dir, req).await,
        None => crate::admin_ui::serve(req).await,
    };
    // The console's login page shows a sign-in trigger link too.
    harden_sign_in_page(&mut response);
    response
}

/// `GET /members/*` — serve the member portal: from `admin_ui.members_dir`
/// when set, the baked one otherwise. A separate application from the
/// console (`crate::member_portal`), mounted at a fixed path.
pub async fn serve_members_spa(State(state): State<AppState>, req: Request<Body>) -> Response {
    let dir = state.config.read().await.admin_ui.members_dir.clone();
    let mut response = match dir {
        Some(dir) => crate::admin_ui::serve_members_dir(&dir, req).await,
        None => crate::admin_ui::serve_members(req).await,
    };
    harden_sign_in_page(&mut response);
    response
}

/// The portal's page and the console's login page show a sign-in trigger
/// link, so each is served with
/// `Referrer-Policy: no-referrer` and `Cache-Control: no-store` (VTI-LNK-082),
/// and a CSP with `frame-ancestors 'none'` and no third-party script source
/// (base design §13.6). Set here rather than left to the browser-surface
/// middleware so the page carries them however it is mounted. Hashed assets
/// keep their cache lifetime; only the HTML shell is `no-store`.
fn harden_sign_in_page(response: &mut Response) {
    use axum::http::HeaderValue;
    let is_html = response
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|ct| ct.starts_with("text/html"));
    let headers = response.headers_mut();
    headers.insert(
        axum::http::header::REFERRER_POLICY,
        HeaderValue::from_static("no-referrer"),
    );
    if is_html {
        headers.insert(
            axum::http::header::CACHE_CONTROL,
            HeaderValue::from_static("no-store"),
        );
        headers.insert(
            axum::http::header::CONTENT_SECURITY_POLICY,
            HeaderValue::from_static(crate::routing::security_headers::DEFAULT_CSP),
        );
    }
}

/// Manifest entry the admin SPA's plugin loader iterates over to
/// dynamically `import()` each third-party plugin's entry module.
///
/// Mirrors the shape of `PluginManifest` in the admin SPA's
/// `plugin-api.ts` for the fields a third-party plugin needs to
/// register itself: `id`, `label`, `path`, `entry`, plus optional
/// `icon` + `scopes`. The plugin's entry JS calls
/// `window.VtcPluginApi.registerPlugin({...})` to wire its UI into
/// the shell's router and nav.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifestEntry {
    pub id: String,
    pub label: String,
    pub path: String,
    /// Absolute URL the shell `import()`s. Daemon-served plugins
    /// resolve to `/admin/plugins/<id>/<file>`.
    pub entry: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub icon: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub scopes: Vec<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginsManifestResponse {
    pub plugins: Vec<PluginManifestEntry>,
}

/// Short-TTL cache of plugin-directory scans, keyed by the configured
/// `plugin_dir` so a runtime config change rescans rather than serving
/// a stale set. Keeps the **unauth** `/admin/plugins.json` route from
/// doing a `readdir` + read-every-manifest on every request (a disk-
/// amplification lever). The map holds one entry per distinct
/// `plugin_dir` ever configured (≈1 for a running daemon).
struct PluginScanEntry {
    scanned_at: Instant,
    plugins: Vec<PluginManifestEntry>,
}

static PLUGIN_SCAN_CACHE: LazyLock<RwLock<std::collections::HashMap<PathBuf, PluginScanEntry>>> =
    LazyLock::new(|| RwLock::new(std::collections::HashMap::new()));

const PLUGIN_SCAN_TTL: Duration = Duration::from_secs(30);

/// `GET /admin/plugins.json` — third-party plugin manifest.
///
/// Scans `admin_ui.plugin_dir` (if configured) for subdirectories;
/// each subdirectory whose ID matches `^[a-z][a-z0-9-]*$` and which
/// contains a readable `manifest.json` becomes a plugin in the
/// response. IDs that fail the regex, manifests that fail to parse,
/// or paths that escape the plugin root are dropped silently (with
/// a `warn!`) so one malformed plugin can't take down the manifest
/// surface. The scan result is cached for [`PLUGIN_SCAN_TTL`].
///
/// Unauth on purpose: knowing which plugins are installed is not
/// sensitive, and the shell fetches the manifest before login.
pub async fn plugins_manifest(State(state): State<AppState>) -> Json<PluginsManifestResponse> {
    let plugin_dir = state.config.read().await.admin_ui.plugin_dir.clone();
    let Some(plugin_dir) = plugin_dir else {
        return Json(PluginsManifestResponse { plugins: vec![] });
    };

    Json(PluginsManifestResponse {
        plugins: scan_plugin_dir_cached(&plugin_dir).await,
    })
}

/// [`scan_plugin_dir`] behind the [`PLUGIN_SCAN_CACHE`] TTL.
async fn scan_plugin_dir_cached(plugin_dir: &StdPath) -> Vec<PluginManifestEntry> {
    if let Some(entry) = PLUGIN_SCAN_CACHE.read().await.get(plugin_dir)
        && entry.scanned_at.elapsed() < PLUGIN_SCAN_TTL
    {
        return entry.plugins.clone();
    }
    let plugins = scan_plugin_dir(plugin_dir);
    PLUGIN_SCAN_CACHE.write().await.insert(
        plugin_dir.to_path_buf(),
        PluginScanEntry {
            scanned_at: Instant::now(),
            plugins: plugins.clone(),
        },
    );
    plugins
}

fn scan_plugin_dir(plugin_dir: &StdPath) -> Vec<PluginManifestEntry> {
    let entries = match std::fs::read_dir(plugin_dir) {
        Ok(e) => e,
        Err(e) => {
            warn!(
                path = %plugin_dir.display(),
                error = %e,
                "admin_ui.plugin_dir is set but unreadable — no third-party plugins served"
            );
            return Vec::new();
        }
    };

    let mut plugins = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let Some(id) = path.file_name().and_then(|f| f.to_str()) else {
            continue;
        };
        if !is_valid_plugin_id(id) {
            warn!(id, "skipping plugin: id does not match ^[a-z][a-z0-9-]*$");
            continue;
        }
        let manifest_path = path.join("manifest.json");
        let raw = match std::fs::read_to_string(&manifest_path) {
            Ok(s) => s,
            Err(e) => {
                warn!(
                    id,
                    path = %manifest_path.display(),
                    error = %e,
                    "skipping plugin: manifest.json unreadable"
                );
                continue;
            }
        };
        let mut manifest: DiskManifest = match serde_json::from_str(&raw) {
            Ok(m) => m,
            Err(e) => {
                warn!(id, error = %e, "skipping plugin: manifest.json malformed");
                continue;
            }
        };
        // Operators don't have to repeat the id in the manifest;
        // we infer it from the directory name. If they do provide
        // one and it mismatches, take the directory as authoritative.
        if manifest.id.as_deref() != Some(id) {
            manifest.id = Some(id.to_string());
        }

        // Compose the entry URL from the plugin id + the entry
        // file the manifest declared. Refuse anything that tries
        // to escape the plugin's own directory.
        let entry_file = manifest.entry.trim_start_matches('/');
        if entry_file.contains("..") || entry_file.is_empty() {
            warn!(
                id,
                entry = entry_file,
                "skipping plugin: entry path traversal"
            );
            continue;
        }

        plugins.push(PluginManifestEntry {
            id: id.to_string(),
            label: manifest.label,
            path: manifest.path,
            entry: format!("/admin/plugins/{id}/{entry_file}"),
            icon: manifest.icon,
            scopes: manifest.scopes.unwrap_or_default(),
        });
    }
    plugins
}

fn is_valid_plugin_id(id: &str) -> bool {
    let mut chars = id.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_lowercase() {
        return false;
    }
    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}

/// Shape stored on disk under `<plugin_dir>/<id>/manifest.json`.
/// The `id` field is optional in the file — the directory name is
/// authoritative.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DiskManifest {
    #[serde(default)]
    id: Option<String>,
    label: String,
    path: String,
    /// Filename inside the plugin directory the shell `import()`s.
    /// Typically `index.js`. Absolute paths / `..` segments are
    /// rejected at scan time.
    entry: String,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    scopes: Option<Vec<String>>,
}

/// `GET /admin/plugins/{id}/*path` — serves files from
/// `<plugin_dir>/<id>/<path>`. ID + path both validated against
/// directory-traversal before any IO. Falls through to 404 when
/// `plugin_dir` isn't configured.
pub async fn plugin_asset(
    State(state): State<AppState>,
    Path((id, rel_path)): Path<(String, String)>,
) -> Response {
    if !is_valid_plugin_id(&id) {
        return (StatusCode::NOT_FOUND, "plugin id invalid").into_response();
    }
    // Reject any path component that's exactly `..` — `Path` doesn't
    // resolve traversal by itself.
    if rel_path.contains("..") {
        return (StatusCode::NOT_FOUND, "plugin path traversal").into_response();
    }
    let Some(plugin_dir) = state.config.read().await.admin_ui.plugin_dir.clone() else {
        return (StatusCode::NOT_FOUND, "no plugin_dir configured").into_response();
    };

    let absolute: PathBuf = plugin_dir.join(&id).join(&rel_path);
    // Defence in depth: even after the per-component checks above,
    // make sure the canonical path is still under the plugin root.
    let canonical_root = match plugin_dir.canonicalize() {
        Ok(p) => p,
        Err(_) => return (StatusCode::NOT_FOUND, "plugin_dir not resolvable").into_response(),
    };
    let canonical_abs = match absolute.canonicalize() {
        Ok(p) => p,
        Err(_) => return (StatusCode::NOT_FOUND, "plugin asset not found").into_response(),
    };
    if !canonical_abs.starts_with(&canonical_root) {
        return (StatusCode::NOT_FOUND, "plugin path escapes root").into_response();
    }

    let bytes = match std::fs::read(&canonical_abs) {
        Ok(b) => b,
        Err(_) => return (StatusCode::NOT_FOUND, "plugin asset not found").into_response(),
    };
    let mime = mime_guess::from_path(&canonical_abs)
        .first_or_octet_stream()
        .to_string();
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, mime)
        .header(header::CACHE_CONTROL, "public, max-age=300")
        .body(Body::from(bytes))
        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build").into_response())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_plugin(dir: &StdPath, id: &str, entry: &str) {
        let pdir = dir.join(id);
        std::fs::create_dir_all(&pdir).unwrap();
        std::fs::write(
            pdir.join("manifest.json"),
            format!(r#"{{"label":"{id}","path":"/{id}","entry":"{entry}"}}"#),
        )
        .unwrap();
    }

    #[test]
    fn scan_skips_invalid_ids_and_traversal_entries() {
        let dir = tempfile::tempdir().unwrap();
        write_plugin(dir.path(), "good", "index.js");
        write_plugin(dir.path(), "Bad_Id", "index.js"); // invalid id
        write_plugin(dir.path(), "escape", "../../etc/passwd"); // traversal

        let plugins = scan_plugin_dir(dir.path());
        assert_eq!(plugins.len(), 1, "only the valid plugin survives");
        assert_eq!(plugins[0].id, "good");
        assert_eq!(plugins[0].entry, "/admin/plugins/good/index.js");
    }

    #[tokio::test]
    async fn cached_scan_is_not_reread_within_ttl() {
        let dir = tempfile::tempdir().unwrap();
        write_plugin(dir.path(), "alpha", "index.js");

        // First call populates the cache.
        let first = scan_plugin_dir_cached(dir.path()).await;
        assert_eq!(first.len(), 1);

        // Add a second plugin; within the TTL the cached (single)
        // result still stands, proving we don't readdir every request.
        write_plugin(dir.path(), "beta", "index.js");
        let second = scan_plugin_dir_cached(dir.path()).await;
        assert_eq!(second.len(), 1, "scan must be cached within the TTL");

        // A direct (uncached) scan sees both — confirms the cache, not
        // the scanner, is what hid the new plugin.
        assert_eq!(scan_plugin_dir(dir.path()).len(), 2);
    }
}