Skip to main content

vane_mgmt/
verb.rs

1//! Per-verb argument and result schemas. Wire-side `Request.args` is a
2//! `serde_json::Value` (untyped on the wire); each verb's handler
3//! deserialises it into its own typed struct, surfacing
4//! [`crate::protocol::WireErrorKind::BadArgs`] on shape mismatch.
5//!
6//! Verbs: `compile_dry_run`, `reload`, `get_config`, `stats`,
7//! `shutdown`, `get_connections`, plus `ping` for cheap liveness checks.
8//!
9//! See [`spec/crates/mgmt.md` § _Verbs_](../../../spec/crates/mgmt.md#verbs).
10
11use serde::{Deserialize, Serialize};
12
13pub const VERB_PING: &str = "ping";
14pub const VERB_STATS: &str = "stats";
15pub const VERB_SHUTDOWN: &str = "shutdown";
16pub const VERB_GET_CONFIG: &str = "get_config";
17pub const VERB_RELOAD: &str = "reload";
18pub const VERB_COMPILE_DRY_RUN: &str = "compile_dry_run";
19pub const VERB_GET_CONNECTIONS: &str = "get_connections";
20pub const VERB_TAIL_FLOW: &str = "tail_flow";
21pub const VERB_TAIL_LOG: &str = "tail_log";
22pub const VERB_GET_METRICS: &str = "get_metrics";
23pub const VERB_GET_POOLS: &str = "get_pools";
24pub const VERB_GET_UPSTREAMS: &str = "get_upstreams";
25pub const VERB_RELOAD_NATIVE_ROOTS: &str = "reload_native_roots";
26
27/// Placeholder for verbs that accept no arguments. Round-trips as `{}`.
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct NoArgs {}
30
31/// `ping` is the cheapest liveness verb. The 6 spec verbs all touch
32/// daemon state; `ping` only confirms the dispatcher is alive and
33/// reports the daemon's build version. Probes / health checks should
34/// prefer `ping` over `stats`.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct PingResult {
37	pub pong: bool,
38	/// `vaned` `CARGO_PKG_VERSION` at compile time.
39	pub version: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct StatsResult {
44	pub uptime_ms: u64,
45	/// Lower-case hex of the active flow-graph's SHA-256 version hash.
46	pub graph_version_hash: String,
47	pub listeners: Vec<ListenerStatus>,
48	/// Live `tail_flow` subscribers — `BroadcastSink::subscriber_count`.
49	/// Tests use this to wait for streaming readiness rather than
50	/// sleeping a fixed interval.
51	#[serde(default)]
52	pub flow_log_subscribers: usize,
53	/// Live `tail_log` subscribers — `BroadcastTracingLayer::subscriber_count`.
54	#[serde(default)]
55	pub tracing_log_subscribers: usize,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct ListenerStatus {
60	pub addr: String,
61	pub bound: bool,
62	pub in_flight_count: usize,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct ShutdownResult {
67	/// Always `true` on a successful shutdown verb — the daemon has
68	/// observed the trigger and is in the soft-drain phase. Operators
69	/// should follow up by waiting on the `vaned` process exit.
70	pub draining: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GetConfigResult {
75	/// Serialized `vane_core::SymbolicFlowGraph`. Kept as
76	/// `serde_json::Value` so consumers (CLI / TUI / external tools)
77	/// don't need to depend on `vane-core` to decode the wire payload.
78	pub graph: serde_json::Value,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(tag = "kind", rename_all = "snake_case")]
83pub enum ReloadResult {
84	/// Recompile produced a new graph and the runtime swap took effect.
85	Swapped { hash: String },
86	/// Recompile reproduced the active graph's hash; swap was skipped.
87	Unchanged { hash: String },
88}
89
90/// Result of the `reload_native_roots` verb. `anchors` is the count
91/// of trust anchors successfully loaded; operators use it as a quick
92/// sanity check that the refresh produced a non-empty store.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct ReloadNativeRootsResult {
95	pub anchors: usize,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CompileDryRunArgs {
100	/// Filesystem path to the candidate config tree.
101	pub config_dir: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CompileDryRunResult {
106	/// The compiled (but not linked, not swapped) `SymbolicFlowGraph`.
107	pub graph: serde_json::Value,
108}
109
110/// One in-flight connection on the wire. `conn_id` is hex (16 chars,
111/// matches `ConnId`'s `Display`); addresses use the standard
112/// `SocketAddr` Display form.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct ConnectionInfo {
115	pub conn_id: String,
116	pub listener_addr: String,
117	pub remote: String,
118	pub age_ms: u64,
119}
120
121/// Args for `get_metrics`. `format` selects the output shape.
122///
123/// - `"prometheus"` (default, or `null` / missing / `""`) — Prometheus
124///   text exposition format.
125/// - `"json"` — structured JSON parsed from the text exposition.
126/// - Any other value → `WireErrorKind::BadArgs`.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128pub struct GetMetricsArgs {
129	/// Output format: `"prometheus"` or `"json"`. Missing / null treated
130	/// as `"prometheus"`.
131	#[serde(default)]
132	pub format: Option<String>,
133}
134
135/// Result of `get_metrics`. Tagged by `format` so consumers can branch
136/// without an extra discriminant field.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(tag = "format", rename_all = "snake_case")]
139pub enum GetMetricsResult {
140	Prometheus { body: String },
141	Json { metrics: serde_json::Value },
142}
143
144/// Per-listener summary plus the live in-flight connection list.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct GetConnectionsResult {
147	pub listeners: Vec<ListenerStatus>,
148	#[serde(default)]
149	pub connections: Vec<ConnectionInfo>,
150}
151
152/// Snapshot of every daemon-bounded execution pool: WASM stateful /
153/// stateless instance pools and the CGI concurrency-cap semaphore.
154///
155/// `spec/crates/mgmt.md` § _State_ lists the per-pool fields as
156/// "pool size, in-use count, total allocations, failures". The first
157/// two map directly onto `capacity` / `in_use`; the latter two are
158/// reserved on the wire (always `0`) until the daemon plumbs the
159/// metrics counters required to populate them.
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
161pub struct GetPoolsResult {
162	#[serde(default)]
163	pub wasm: Vec<WasmPoolEntry>,
164	/// `None` when the `cgi` feature is disabled, or when no CGI rule
165	/// has fired in this daemon's lifetime (the semaphore is lazily
166	/// initialised on the first request).
167	#[serde(default)]
168	pub cgi: Option<CgiPoolEntry>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172pub struct WasmPoolEntry {
173	/// `"stateful"` or `"stateless"`.
174	pub kind: String,
175	/// Module identity (canonical absolute path of the `.wasm` file).
176	pub key: String,
177	/// Plugin export name within the component.
178	pub export: String,
179	pub capacity: usize,
180	pub available: usize,
181	pub in_use: usize,
182	/// Cumulative successful checkouts (stateful) or rentals
183	/// (stateless).
184	#[serde(default)]
185	pub total_allocations: u64,
186	/// Cumulative checkout / instantiation failures.
187	#[serde(default)]
188	pub failures: u64,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct CgiPoolEntry {
193	pub cap: usize,
194	pub available: usize,
195	pub in_use: usize,
196	/// Cumulative successful permit acquisitions (CGI fetches that
197	/// proceeded to fork/exec).
198	#[serde(default)]
199	pub total_allocations: u64,
200	/// Cumulative cap-rejected fetches (503 fast-rejects, spec
201	/// `spec/crates/engine.md` § _Concurrency cap_).
202	#[serde(default)]
203	pub failures: u64,
204}
205
206/// Snapshot of cached upstream connection objects: the TCP / TLS
207/// `hyper-util` client cache and (when `h3` is built) the QUIC pool.
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
209pub struct GetUpstreamsResult {
210	#[serde(default)]
211	pub tcp: Vec<TcpUpstreamEntry>,
212	/// Empty when the `h3` feature is disabled.
213	#[serde(default)]
214	pub quic: Vec<QuicUpstreamEntry>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct TcpUpstreamEntry {
219	/// Negotiated upstream version: `"auto"`, `"h1"`, `"h2"`, `"h3"`.
220	pub version: String,
221	/// `"http"` (cleartext) or `"https"` (TLS).
222	pub scheme: String,
223	/// Trust-root posture: `"system"`, `"bundle"`, `"insecure-skip"`,
224	/// or `"none"` (cleartext).
225	pub root_ca: String,
226	/// Verify mode: `"full"`, `"skip"`, or `"none"` (cleartext).
227	pub verify_mode: String,
228	pub alpn: Vec<String>,
229	/// `"system"` (read `/etc/resolv.conf`) or `"custom"` (operator-
230	/// pinned nameservers).
231	pub dns: String,
232	/// 16-char hex identifier for `pool.drain`. Stable for the
233	/// process lifetime as long as the underlying fingerprint contents
234	/// are unchanged.
235	#[serde(default)]
236	pub fingerprint_id: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct QuicUpstreamEntry {
241	pub remote_addr: String,
242	pub sni: String,
243	pub alpn: Vec<String>,
244	/// 16-char hex identifier for `pool.drain`.
245	#[serde(default)]
246	pub fingerprint_id: String,
247}
248
249/// Verb name for the manual pool eviction RPC. Operators look up a
250/// `fingerprint_id` from `get_upstreams` and pass it back here to
251/// remove the matching cache entry. Live `Arc<Client>` /
252/// `Arc<QuicPoolEntry>` references survive — only future cache
253/// lookups are affected.
254pub const VERB_POOL_DRAIN: &str = "pool_drain";
255
256#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
257pub struct PoolDrainArgs {
258	pub fingerprint_id: String,
259}
260
261#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
262pub struct PoolDrainResult {
263	/// Number of TCP / TLS `client_cache` entries removed (almost
264	/// always 0 or 1).
265	pub tcp_drained: usize,
266	/// Number of QUIC pool entries removed. `0` when the `h3` feature
267	/// is disabled or no QUIC pool entry matches the id.
268	pub quic_drained: usize,
269}
270
271/// Verb name for the operator-driven "renew this cert NOW" RPC per
272/// `spec/crates/engine-acme.md` § _mgmt verbs_. Bypasses the
273/// `renew_before` timer and any active backoff; useful for
274/// key-compromise rotation. The actual issuance runs asynchronously
275/// — `queued: true` means the registry accepted the request, not
276/// that the cert is in hand.
277pub const VERB_FORCE_RENEW: &str = "force_renew";
278
279#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
280pub struct ForceRenewArgs {
281	pub sni: String,
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
285pub struct ForceRenewResult {
286	/// `true` when the registry accepted the request and spawned a
287	/// renewal task; `false` when the SNI is not declared managed
288	/// (no `tls.managed` rule references it) or no renewal job has
289	/// been registered for it.
290	pub queued: bool,
291	/// Cert lifecycle status at the moment the request was received:
292	/// `"valid"`, `"renewing"`, `"failed"`, or `"limited"` per spec
293	/// § _Rate limits and failure handling_. `"unknown"` for SNIs
294	/// that have never been declared.
295	pub current_status: String,
296}
297
298/// Verb name for the cert inventory RPC per `spec/crates/engine-acme.md`
299/// § _mgmt verbs_. Lists every cert the daemon tracks —
300/// managed (full lifecycle detail) and static (SNI + source label).
301pub const VERB_GET_CERTS: &str = "get_certs";
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
304pub struct GetCertsResult {
305	pub certs: Vec<CertSummary>,
306}
307
308/// One cert's wire-shape summary. Field set matches
309/// `spec/crates/engine-acme.md` § _mgmt verbs_; static-source
310/// entries leave the lifecycle fields (`status`, `last_*`,
311/// `next_*`, `ari_window`) at their defaults — they're meaningful
312/// only for managed certs.
313///
314/// Named `CertSummary` to disambiguate from
315/// `vane_engine::tls::CertEntry` (the rustls-side handshake bundle);
316/// the wire shape is operator-facing, the engine type is
317/// resolver-internal.
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319pub struct CertSummary {
320	pub sni: String,
321	/// `"managed"` for ACME-issued certs, `"static"` for operator-
322	/// supplied PEMs.
323	pub source: String,
324	#[serde(default)]
325	pub san: Vec<String>,
326	/// ISO 8601 / RFC 3339 timestamp. `None` when no cert is
327	/// currently issued (managed SNI before first issuance).
328	#[serde(default, skip_serializing_if = "Option::is_none")]
329	pub not_after: Option<String>,
330	#[serde(default, skip_serializing_if = "Option::is_none")]
331	pub issued_at: Option<String>,
332	/// `"valid"` | `"renewing"` | `"failed"` | `"limited"`. Empty
333	/// for static certs.
334	#[serde(default)]
335	pub status: String,
336	#[serde(default, skip_serializing_if = "Option::is_none")]
337	pub last_attempt_at: Option<String>,
338	#[serde(default, skip_serializing_if = "Option::is_none")]
339	pub last_error: Option<String>,
340	#[serde(default, skip_serializing_if = "Option::is_none")]
341	pub next_attempt_at: Option<String>,
342	#[serde(default, skip_serializing_if = "Option::is_none")]
343	pub ari_window: Option<AriWindowWire>,
344	/// OCSP staple status from the cert's perspective:
345	///
346	/// - `"stapled"`: a fresh OCSP response is cached and rustls
347	///   ships it on every handshake.
348	/// - `"no_staple"`: the cert has no AIA OCSP URL (or none was
349	///   discovered yet) — OCSP isn't applicable here.
350	/// - `"fetch_failed"`: the AIA URL is known but the most
351	///   recent fetch failed; the scheduler will retry.
352	///
353	/// Empty for static certs that don't opt into OCSP.
354	#[serde(default)]
355	pub ocsp_status: String,
356	/// RFC 3339 of the cached OCSP staple's `nextUpdate`. `None`
357	/// when no staple is cached.
358	#[serde(default, skip_serializing_if = "Option::is_none")]
359	pub ocsp_next_update: Option<String>,
360	/// AIA-extracted OCSP responder URL. `None` when the cert
361	/// doesn't carry one (vane fetches OCSP only when the cert
362	/// advertises a responder).
363	#[serde(default, skip_serializing_if = "Option::is_none")]
364	pub ocsp_aia_url: Option<String>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368pub struct AriWindowWire {
369	/// RFC 3339 timestamp of the suggested renewal window's start.
370	pub start: String,
371	/// RFC 3339 timestamp of the suggested renewal window's end.
372	pub end: String,
373}
374
375#[cfg(test)]
376mod tests {
377	use super::*;
378
379	fn round_trip<T>(value: &T) -> T
380	where
381		T: serde::Serialize + for<'de> serde::Deserialize<'de>,
382	{
383		let s = serde_json::to_string(value).expect("serialize");
384		serde_json::from_str(&s).expect("deserialize")
385	}
386
387	#[test]
388	fn no_args_round_trips() {
389		let s = serde_json::to_string(&NoArgs {}).expect("serialize");
390		assert_eq!(s, "{}");
391		let _back: NoArgs = serde_json::from_str(&s).expect("deserialize");
392	}
393
394	#[test]
395	fn ping_result_round_trips() {
396		let r = PingResult { pong: true, version: env!("CARGO_PKG_VERSION").to_string() };
397		assert_eq!(round_trip(&r), r);
398	}
399
400	#[test]
401	fn stats_result_round_trips() {
402		let r = StatsResult {
403			uptime_ms: 12_345,
404			graph_version_hash: "abcd".to_string(),
405			listeners: vec![ListenerStatus {
406				addr: "127.0.0.1:8080".to_string(),
407				bound: true,
408				in_flight_count: 3,
409			}],
410			flow_log_subscribers: 2,
411			tracing_log_subscribers: 1,
412		};
413		assert_eq!(round_trip(&r), r);
414	}
415
416	#[test]
417	fn stats_result_decodes_payload_without_subscriber_counts() {
418		// Older daemons emit StatsResult without the subscriber counts.
419		// `#[serde(default)]` lets the client decode that shape with
420		// the counts implicitly zero.
421		let raw = r#"{"uptime_ms":1,"graph_version_hash":"00","listeners":[]}"#;
422		let r: StatsResult = serde_json::from_str(raw).expect("decode");
423		assert_eq!(r.flow_log_subscribers, 0);
424		assert_eq!(r.tracing_log_subscribers, 0);
425	}
426
427	#[test]
428	fn reload_result_swapped_round_trips() {
429		let r = ReloadResult::Swapped { hash: "ff".to_string() };
430		assert_eq!(round_trip(&r), r);
431		// Tagged shape: kind/hash should be flat.
432		let value = serde_json::to_value(&r).expect("to_value");
433		assert_eq!(value["kind"], "swapped");
434		assert_eq!(value["hash"], "ff");
435	}
436
437	#[test]
438	fn reload_result_unchanged_round_trips() {
439		let r = ReloadResult::Unchanged { hash: "00".to_string() };
440		assert_eq!(round_trip(&r), r);
441		let value = serde_json::to_value(&r).expect("to_value");
442		assert_eq!(value["kind"], "unchanged");
443	}
444
445	#[test]
446	fn shutdown_result_round_trips() {
447		let r = ShutdownResult { draining: true };
448		assert_eq!(round_trip(&r), r);
449	}
450
451	#[test]
452	fn get_connections_result_round_trips() {
453		let r = GetConnectionsResult {
454			listeners: vec![
455				ListenerStatus { addr: "127.0.0.1:1".to_string(), bound: true, in_flight_count: 0 },
456				ListenerStatus { addr: "127.0.0.1:2".to_string(), bound: false, in_flight_count: 9 },
457			],
458			connections: vec![ConnectionInfo {
459				conn_id: "00000000deadbeef".to_string(),
460				listener_addr: "127.0.0.1:1".to_string(),
461				remote: "203.0.113.7:54321".to_string(),
462				age_ms: 1234,
463			}],
464		};
465		assert_eq!(round_trip(&r), r);
466	}
467
468	#[test]
469	fn get_connections_result_deserialises_payload_without_connections() {
470		// Daemons may emit `{"listeners": [...]}` with no `connections`
471		// key. The client must still decode them — `#[serde(default)]`
472		// on the field provides that.
473		let raw = r#"{"listeners":[{"addr":"127.0.0.1:1","bound":true,"in_flight_count":0}]}"#;
474		let r: GetConnectionsResult = serde_json::from_str(raw).expect("decode");
475		assert_eq!(r.listeners.len(), 1);
476		assert!(r.connections.is_empty());
477	}
478
479	#[test]
480	fn compile_dry_run_args_round_trips() {
481		let a = CompileDryRunArgs { config_dir: "/etc/vaned-b".to_string() };
482		let s = serde_json::to_string(&a).expect("serialize");
483		let back: CompileDryRunArgs = serde_json::from_str(&s).expect("deserialize");
484		assert_eq!(back.config_dir, "/etc/vaned-b");
485	}
486
487	#[test]
488	fn get_pools_result_round_trips() {
489		let r = GetPoolsResult {
490			wasm: vec![WasmPoolEntry {
491				kind: "stateful".to_string(),
492				key: "/etc/vaned/plugins/edge.wasm".to_string(),
493				export: "l4-peek".to_string(),
494				capacity: 8,
495				available: 5,
496				in_use: 3,
497				total_allocations: 0,
498				failures: 0,
499			}],
500			cgi: Some(CgiPoolEntry {
501				cap: 100,
502				available: 99,
503				in_use: 1,
504				total_allocations: 0,
505				failures: 0,
506			}),
507		};
508		assert_eq!(round_trip(&r), r);
509	}
510
511	#[test]
512	fn get_pools_result_decodes_minimal_payload() {
513		// Daemons whose `wasm` feature is off should be able to emit
514		// `{"cgi": null}` and have clients still decode the result.
515		let raw = r#"{"cgi": null}"#;
516		let r: GetPoolsResult = serde_json::from_str(raw).expect("decode");
517		assert!(r.wasm.is_empty());
518		assert!(r.cgi.is_none());
519	}
520
521	#[test]
522	fn get_upstreams_result_round_trips() {
523		let r = GetUpstreamsResult {
524			tcp: vec![TcpUpstreamEntry {
525				version: "auto".to_string(),
526				scheme: "https".to_string(),
527				root_ca: "system".to_string(),
528				verify_mode: "full".to_string(),
529				alpn: vec!["h2".to_string(), "http/1.1".to_string()],
530				dns: "system".to_string(),
531				fingerprint_id: "abcdef0123456789".to_string(),
532			}],
533			quic: vec![QuicUpstreamEntry {
534				remote_addr: "127.0.0.1:443".to_string(),
535				sni: "example.com".to_string(),
536				alpn: vec!["h3".to_string()],
537				fingerprint_id: "fedcba9876543210".to_string(),
538			}],
539		};
540		assert_eq!(round_trip(&r), r);
541	}
542
543	#[test]
544	fn get_upstreams_result_decodes_payload_without_quic() {
545		// Daemons built without `h3` may emit `{"tcp": [...]}` with no
546		// `quic` key. The client must still decode them.
547		let raw = r#"{"tcp": []}"#;
548		let r: GetUpstreamsResult = serde_json::from_str(raw).expect("decode");
549		assert!(r.tcp.is_empty());
550		assert!(r.quic.is_empty());
551	}
552}