Skip to main content

vane_core/preset/
mod.rs

1//! Preset expansion: `{"preset": ..., ...}` โ†’ `Vec<RawRule>`.
2//!
3//! Presets are opinionated compile-stage expansions that turn high-level
4//! intent into raw-rule bundles. The four built-in presets are
5//! `reverse_proxy`, `port_forward`, `static_site`, and `redirect_https`.
6//!
7//! See [`spec/crates/core.md` ยง _Compile pipeline_](../../../../spec/crates/core.md#compile-pipeline).
8
9mod port_forward;
10mod redirect_https;
11mod reverse_proxy;
12mod static_site;
13
14use serde_json::Value;
15
16use crate::error::Error;
17use crate::rule::{ListenSpec, RawRule, SourceInfo, TlsConfig};
18
19/// User-authored preset invocation. The `preset` field discriminates
20/// which expander runs; `args` is opaque at parse time and validated
21/// inside the expander.
22#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
23pub struct PresetInvocation {
24	/// Base name; the expander prefixes synth rules (`<name>.main`,
25	/// `<name>.ws`, `<name>.ws-allow`, `<name>.ws-deny`).
26	pub name: String,
27	/// Discriminator. One of `reverse_proxy` / `port_forward` /
28	/// `static_site` / `redirect_https`.
29	pub preset: String,
30	#[serde(deserialize_with = "crate::rule::de_listen_non_empty")]
31	pub listen: Vec<ListenSpec>,
32	#[serde(default)]
33	pub args: Value,
34	/// Optional TLS termination config โ€” same shape as `RawRule.tls`.
35	/// Each preset's `expand()` propagates this to every emitted
36	/// rule on the listener, so a `reverse_proxy` preset that emits
37	/// `<name>.ws` + `<name>.main` carries the same TLS config on
38	/// both rules and `lower_port`'s consistency check passes.
39	#[serde(default)]
40	pub tls: Option<TlsConfig>,
41	#[serde(default)]
42	pub source: SourceInfo,
43}
44
45/// File-level entry: either a hand-written raw rule or a preset
46/// invocation that expands to one or more raw rules. Discrimination is
47/// by presence of the top-level `preset` key โ€” the custom `Deserialize`
48/// peeks at the JSON before routing to the right variant so a malformed
49/// preset payload produces a pointed error instead of falling through to
50/// `RawRule` parsing and surfacing a confusing "missing terminate" error.
51#[derive(Debug, Clone, serde::Serialize)]
52#[serde(untagged)]
53pub enum RuleEntry {
54	Preset(PresetInvocation),
55	Raw(RawRule),
56}
57
58impl<'de> serde::Deserialize<'de> for RuleEntry {
59	fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
60		let v = Value::deserialize(d)?;
61		if v.get("preset").is_some() {
62			let inv: PresetInvocation = serde_json::from_value(v).map_err(serde::de::Error::custom)?;
63			Ok(Self::Preset(inv))
64		} else {
65			let r: RawRule = serde_json::from_value(v).map_err(serde::de::Error::custom)?;
66			Ok(Self::Raw(r))
67		}
68	}
69}
70
71/// Dispatch on `inv.preset` to the appropriate expander.
72///
73/// # Errors
74/// Returns [`Error::compile`] when `inv.preset` names an unknown preset,
75/// or when the dispatched expander rejects `inv.args`.
76pub fn expand_invocation(inv: PresetInvocation) -> Result<Vec<RawRule>, Error> {
77	match inv.preset.as_str() {
78		"reverse_proxy" => reverse_proxy::expand(inv),
79		"port_forward" => port_forward::expand(inv),
80		"static_site" => static_site::expand(inv),
81		"redirect_https" => redirect_https::expand(inv),
82		other => Err(Error::compile(format!(
83			"unknown preset {other:?}; supported: reverse_proxy / port_forward / static_site / redirect_https"
84		))),
85	}
86}
87
88#[cfg(test)]
89mod tests {
90	use super::*;
91
92	#[test]
93	fn unknown_preset_name_yields_compile_error() {
94		let inv = PresetInvocation {
95			name: "x".into(),
96			preset: "no_such_preset".into(),
97			listen: vec![":443".into()],
98			args: Value::Null,
99			tls: None,
100			source: SourceInfo::default(),
101		};
102		let err = expand_invocation(inv).expect_err("unknown preset must fail");
103		let msg = err.to_string();
104		assert!(msg.contains("no_such_preset"), "error names the offending preset: {msg}");
105		assert!(msg.contains("reverse_proxy"), "error lists supported presets: {msg}");
106	}
107
108	#[test]
109	fn rule_entry_deserializes_preset_when_preset_key_present() {
110		let raw = serde_json::json!({
111			"preset": "port_forward",
112			"name": "ssh",
113			"listen": [":2222"],
114			"args": { "upstream": "10.0.0.5:22" }
115		});
116		let entry: RuleEntry = serde_json::from_value(raw).expect("parse preset entry");
117		match entry {
118			RuleEntry::Preset(inv) => {
119				assert_eq!(inv.preset, "port_forward");
120				assert_eq!(inv.name, "ssh");
121				assert_eq!(inv.listen, vec![":2222".to_string()]);
122			}
123			RuleEntry::Raw(_) => panic!("preset key must route to Preset variant"),
124		}
125	}
126
127	#[test]
128	fn rule_entry_deserializes_raw_when_no_preset_key() {
129		let raw = serde_json::json!({
130			"name": "r",
131			"listen": [":443"],
132			"terminate": { "type": "http_proxy", "upstream": "127.0.0.1:8080" }
133		});
134		let entry: RuleEntry = serde_json::from_value(raw).expect("parse raw entry");
135		match entry {
136			RuleEntry::Raw(r) => assert_eq!(r.name, "r"),
137			RuleEntry::Preset(_) => panic!("no preset key must route to Raw variant"),
138		}
139	}
140}