Skip to main content

netget/server/vault/
api.rs

1//! Vault HTTP API routing and response rendering, KV version 2 only.
2//!
3//! Pure functions: a method and path in, a [`Route`] out; a model's structured answer in, the
4//! JSON envelope the `vault` CLI's Go client decodes out. What the CLI needs that is not a
5//! secret — the mount preflight that tells it the engine is KV v2, seal status, health, the
6//! envelope's `request_id` / `lease_*` / `wrap_info` fields — is NetGet's. The secrets, key
7//! listings and versions are the model's.
8
9use serde_json::{json, Map, Value};
10
11/// Largest number of keys one `send_vault_list` may carry.
12pub const MAX_LIST_KEYS: usize = 10_000;
13
14/// What a request resolves to.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum Route {
17    /// `GET /v1/sys/seal-status`
18    SealStatus,
19    /// `GET /v1/sys/health`
20    Health,
21    /// `GET /v1/sys/leader`
22    Leader,
23    /// `GET /v1/sys/internal/ui/mounts/<path>` — the CLI's KV version preflight.
24    MountPreflight(String),
25    /// `GET /v1/<mount>/data/<path>`
26    ReadData { mount: String, path: String },
27    /// `PUT`/`POST /v1/<mount>/data/<path>`
28    WriteData { mount: String, path: String },
29    /// `LIST /v1/<mount>/metadata/<path>` or `GET …?list=true`
30    List { mount: String, path: String },
31    /// `GET /v1/<mount>/metadata/<path>`
32    ReadMetadata { mount: String, path: String },
33    /// A path under a KV mount with a method this server does not implement (delete, patch,
34    /// undelete, destroy, config).
35    Unsupported,
36    /// Anything else.
37    NotFound,
38}
39
40/// Split `path` (without `/v1/`) into the longest configured mount and the rest.
41fn split_mount<'a>(path: &'a str, mounts: &[String]) -> Option<(String, &'a str)> {
42    let path = path.trim_start_matches('/');
43    mounts
44        .iter()
45        .filter(|m| {
46            let m = m.trim_matches('/');
47            path == m || path.starts_with(&format!("{m}/"))
48        })
49        .max_by_key(|m| m.len())
50        .map(|m| {
51            let m = m.trim_matches('/');
52            (m.to_string(), path[m.len()..].trim_start_matches('/'))
53        })
54}
55
56/// Resolve a request. `path` is the URL path; `list_query` is whether `list=true` was given.
57pub fn resolve(method: &str, path: &str, list_query: bool, mounts: &[String]) -> Route {
58    let Some(rest) = path.strip_prefix("/v1/") else {
59        return Route::NotFound;
60    };
61    let is_get = method == "GET" || method == "HEAD";
62    match rest.trim_end_matches('/') {
63        "sys/seal-status" if is_get => return Route::SealStatus,
64        "sys/health" if is_get => return Route::Health,
65        "sys/leader" if is_get => return Route::Leader,
66        _ => {}
67    }
68    if let Some(p) = rest.strip_prefix("sys/internal/ui/mounts/") {
69        if is_get {
70            return Route::MountPreflight(p.to_string());
71        }
72        return Route::NotFound;
73    }
74    let Some((mount, after)) = split_mount(rest, mounts) else {
75        return Route::NotFound;
76    };
77    let (kind, secret_path) = match after.split_once('/') {
78        Some((k, p)) => (k, p.trim_end_matches('/').to_string()),
79        None => (after, String::new()),
80    };
81    match (kind, method) {
82        ("data", "GET") if !secret_path.is_empty() => Route::ReadData {
83            mount,
84            path: secret_path,
85        },
86        ("data", "PUT" | "POST") if !secret_path.is_empty() => Route::WriteData {
87            mount,
88            path: secret_path,
89        },
90        ("metadata", "LIST") => Route::List {
91            mount,
92            path: secret_path,
93        },
94        ("metadata", "GET") if list_query => Route::List {
95            mount,
96            path: secret_path,
97        },
98        ("metadata", "GET") if !secret_path.is_empty() => Route::ReadMetadata {
99            mount,
100            path: secret_path,
101        },
102        _ => Route::Unsupported,
103    }
104}
105
106/// 64 hex characters derived from `seed`, for identifiers (accessor, cluster id) that must be
107/// stable for one server and mean nothing.
108fn hex_id(seed: &str) -> String {
109    use std::hash::{Hash, Hasher};
110    let mut out = String::with_capacity(64);
111    for round in 0u8..4 {
112        let mut h = std::collections::hash_map::DefaultHasher::new();
113        (round, seed).hash(&mut h);
114        out.push_str(&format!("{:016x}", h.finish()));
115    }
116    out
117}
118
119/// Vault's error body: `{"errors": [...]}`.
120pub fn errors_body(errors: &[String]) -> Value {
121    json!({ "errors": errors })
122}
123
124/// A request id in UUID form. Unique per response; it identifies nothing.
125pub fn request_id() -> String {
126    uuid::Uuid::new_v4().to_string()
127}
128
129/// The response envelope every secret-engine answer is wrapped in.
130pub fn envelope(data: Value, mount_type: &str) -> Value {
131    json!({
132        "request_id": request_id(),
133        "lease_id": "",
134        "renewable": false,
135        "lease_duration": 0,
136        "data": data,
137        "wrap_info": null,
138        "warnings": null,
139        "auth": null,
140        "mount_type": mount_type,
141    })
142}
143
144/// `/v1/sys/internal/ui/mounts/<path>` for a path under a configured mount: what makes the CLI
145/// switch to KV v2 paths (`<mount>/data/…`, `<mount>/metadata/…`).
146pub fn mount_preflight(mount: &str) -> Value {
147    envelope(
148        json!({
149            "accessor": format!("kv_{}", &hex_id(mount)[..8]),
150            "config": {"default_lease_ttl": 0, "force_no_cache": false, "max_lease_ttl": 0},
151            "description": "key/value secret storage",
152            "external_entropy_access": false,
153            "local": false,
154            "options": {"version": "2"},
155            "path": format!("{mount}/"),
156            "plugin_version": "",
157            "running_plugin_version": "v0.20.0+builtin",
158            "running_sha256": "",
159            "seal_wrap": false,
160            "type": "kv",
161            "uuid": request_id(),
162        }),
163        "",
164    )
165}
166
167/// Which mount (if any) a preflight path falls under.
168pub fn preflight_mount(path: &str, mounts: &[String]) -> Option<String> {
169    split_mount(path, mounts).map(|(m, _)| m)
170}
171
172/// Identity for the static `sys/` endpoints.
173#[derive(Clone, Debug)]
174pub struct VaultIdentity {
175    pub version: String,
176    pub cluster_name: String,
177}
178
179pub fn seal_status(id: &VaultIdentity) -> Value {
180    json!({
181        "type": "shamir",
182        "initialized": true,
183        "sealed": false,
184        "t": 1,
185        "n": 1,
186        "progress": 0,
187        "nonce": "",
188        "version": id.version,
189        "build_date": "2025-01-01T00:00:00Z",
190        "migration": false,
191        "cluster_name": id.cluster_name,
192        "cluster_id": hex_id(&id.cluster_name)[..36].to_string(),
193        "recovery_seal": false,
194        "storage_type": "inmem",
195    })
196}
197
198pub fn health(id: &VaultIdentity) -> Value {
199    json!({
200        "initialized": true,
201        "sealed": false,
202        "standby": false,
203        "performance_standby": false,
204        "replication_performance_mode": "disabled",
205        "replication_dr_mode": "disabled",
206        "server_time_utc": now_unix(),
207        "version": id.version,
208        "enterprise": false,
209        "cluster_name": id.cluster_name,
210        "cluster_id": hex_id(&id.cluster_name)[..36].to_string(),
211        "echo_duration_ms": 0,
212        "clock_skew_ms": 0,
213    })
214}
215
216pub fn leader() -> Value {
217    json!({
218        "ha_enabled": false,
219        "is_self": false,
220        "active_time": "0001-01-01T00:00:00Z",
221        "leader_address": "",
222        "leader_cluster_address": "",
223        "performance_standby": false,
224        "performance_standby_last_remote_wal": 0,
225    })
226}
227
228fn now_unix() -> i64 {
229    crate::utils::clock::SystemTime::now()
230        .duration_since(crate::utils::clock::UNIX_EPOCH)
231        .map(|d| d.as_secs() as i64)
232        .unwrap_or(0)
233}
234
235fn now_rfc3339() -> String {
236    chrono::DateTime::from_timestamp(now_unix(), 0)
237        .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Micros, true))
238        .unwrap_or_default()
239}
240
241/// A timestamp the model gave, or now. Refuses one that is not RFC 3339.
242fn created_time(v: Option<&Value>) -> Result<String, String> {
243    match v {
244        None | Some(Value::Null) => Ok(now_rfc3339()),
245        Some(Value::String(s)) => chrono::DateTime::parse_from_rfc3339(s.trim())
246            .map(|t| {
247                t.with_timezone(&chrono::Utc)
248                    .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
249            })
250            .map_err(|_| format!("created_time {s:?} is not RFC 3339")),
251        Some(_) => Err("created_time must be an RFC 3339 string".to_string()),
252    }
253}
254
255fn version_of(v: Option<&Value>) -> Result<u64, String> {
256    match v {
257        None | Some(Value::Null) => Ok(1),
258        Some(Value::Number(n)) => match n.as_u64() {
259            Some(0) | None => Err("version must be a positive integer".to_string()),
260            Some(n) => Ok(n),
261        },
262        Some(Value::String(s)) => s
263            .trim()
264            .parse::<u64>()
265            .ok()
266            .filter(|n| *n > 0)
267            .ok_or_else(|| format!("version {s:?} must be a positive integer")),
268        Some(_) => Err("version must be a positive integer".to_string()),
269    }
270}
271
272fn custom_metadata(v: Option<&Value>) -> Result<Value, String> {
273    match v {
274        None | Some(Value::Null) => Ok(Value::Null),
275        Some(Value::Object(m)) => {
276            let mut out = Map::new();
277            for (k, v) in m {
278                let text = match v {
279                    Value::String(s) => s.clone(),
280                    other => other.to_string(),
281                };
282                out.insert(k.clone(), Value::String(text));
283            }
284            Ok(Value::Object(out))
285        }
286        Some(_) => Err("custom_metadata must be an object of strings".to_string()),
287    }
288}
289
290/// The version metadata block shared by reads and writes.
291fn version_metadata(answer: &Value) -> Result<Value, String> {
292    Ok(json!({
293        "created_time": created_time(answer.get("created_time"))?,
294        "custom_metadata": custom_metadata(answer.get("custom_metadata"))?,
295        "deletion_time": "",
296        "destroyed": false,
297        "version": version_of(answer.get("version"))?,
298    }))
299}
300
301/// The secret's key/value map. Any JSON object is a valid secret — KV stores arbitrary JSON —
302/// but it must be an object.
303fn secret_data(answer: &Value) -> Result<Value, String> {
304    match answer.get("data") {
305        None | Some(Value::Null) => Ok(json!({})),
306        Some(Value::Object(m)) => Ok(Value::Object(m.clone())),
307        Some(_) => Err("data must be an object of key/value pairs".to_string()),
308    }
309}
310
311/// `GET <mount>/data/<path>` body.
312pub fn render_secret(answer: &Value) -> Result<Value, String> {
313    Ok(envelope(
314        json!({"data": secret_data(answer)?, "metadata": version_metadata(answer)?}),
315        "kv",
316    ))
317}
318
319/// `PUT <mount>/data/<path>` body.
320pub fn render_write_ok(answer: &Value) -> Result<Value, String> {
321    Ok(envelope(version_metadata(answer)?, "kv"))
322}
323
324/// `GET <mount>/metadata/<path>` body — built from the same answer as a read.
325pub fn render_metadata(answer: &Value) -> Result<Value, String> {
326    let meta = version_metadata(answer)?;
327    let current = meta["version"].as_u64().unwrap_or(1);
328    let created = meta["created_time"].clone();
329    let mut versions = Map::new();
330    versions.insert(
331        current.to_string(),
332        json!({"created_time": created, "deletion_time": "", "destroyed": false}),
333    );
334    Ok(envelope(
335        json!({
336            "cas_required": false,
337            "created_time": created,
338            "current_version": current,
339            "custom_metadata": meta["custom_metadata"],
340            "delete_version_after": "0s",
341            "max_versions": 0,
342            "oldest_version": 0,
343            "updated_time": created,
344            "versions": versions,
345        }),
346        "kv",
347    ))
348}
349
350/// `LIST <mount>/metadata/<path>` body. Keys are names, not paths: a folder ends in `/`, and a
351/// key containing a `/` anywhere else would claim a nesting the listing does not have.
352pub fn render_list(answer: &Value) -> Result<Value, String> {
353    let keys = answer
354        .get("keys")
355        .and_then(Value::as_array)
356        .ok_or("keys must be an array of strings")?;
357    if keys.len() > MAX_LIST_KEYS {
358        return Err(format!("more than {MAX_LIST_KEYS} keys in one listing"));
359    }
360    let mut out = Vec::with_capacity(keys.len());
361    for k in keys {
362        let k = k.as_str().ok_or("keys must be strings")?;
363        let name = k.strip_suffix('/').unwrap_or(k);
364        if name.is_empty() || name.contains('/') || name.len() > 512 {
365            return Err(format!(
366                "key {k:?} must be one path segment, with a trailing '/' for a folder"
367            ));
368        }
369        out.push(Value::String(k.to_string()));
370    }
371    Ok(envelope(json!({"keys": out}), "kv"))
372}
373
374/// The status and errors of a `send_vault_error`.
375pub fn render_error(answer: &Value) -> Result<(u16, Vec<String>), String> {
376    let status = answer.get("status").and_then(Value::as_u64).unwrap_or(400);
377    if !(400..600).contains(&status) {
378        return Err(format!(
379            "status must be a 4xx or 5xx HTTP status, got {status}"
380        ));
381    }
382    let errors = match answer.get("errors") {
383        None | Some(Value::Null) => Vec::new(),
384        Some(Value::Array(a)) => a
385            .iter()
386            .map(|e| match e {
387                Value::String(s) => crate::utils::sanitize::line_field(s),
388                other => other.to_string(),
389            })
390            .collect(),
391        Some(Value::String(s)) => vec![crate::utils::sanitize::line_field(s)],
392        Some(_) => return Err("errors must be an array of strings".to_string()),
393    };
394    Ok((status as u16, errors))
395}