Skip to main content

systemprompt_models/profile/
server.rs

1//! Server configuration.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::net::IpAddr;
7
8use ipnet::IpNet;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11pub use systemprompt_extension::FrameOptions;
12
13#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct ServerConfig {
16    pub host: String,
17
18    pub port: u16,
19
20    pub api_server_url: String,
21
22    pub api_internal_url: String,
23
24    pub api_external_url: String,
25
26    #[serde(default)]
27    pub use_https: bool,
28
29    #[serde(default)]
30    pub cors_allowed_origins: Vec<String>,
31
32    #[serde(default)]
33    pub content_negotiation: ContentNegotiationConfig,
34
35    #[serde(default)]
36    pub security_headers: SecurityHeadersConfig,
37
38    /// Stable identifier for this replica. Empty/unset resolves to the
39    /// OS hostname (or a generated short id) at config build time.
40    #[serde(default)]
41    pub instance_id: Option<String>,
42
43    /// Global cap on concurrent A2A SSE streams for this replica.
44    #[serde(default = "default_max_concurrent_streams")]
45    pub max_concurrent_streams: usize,
46
47    /// CIDR ranges whose immediate-peer requests are allowed to set
48    /// `X-Forwarded-For`, `X-Real-IP`, and `CF-Connecting-IP`. Empty
49    /// means the platform treats every connection as direct and ignores
50    /// those headers — the only safe default behind no proxy. Each entry
51    /// is a CIDR string (e.g. `10.0.0.0/8`, `192.168.1.0/24`,
52    /// `2001:db8::/32`). Single addresses without a `/` are accepted as
53    /// `/32` (IPv4) or `/128` (IPv6). Invalid entries fail profile load —
54    /// a silently-dropped proxy would demote a trusted hop to hostile and
55    /// break client-IP resolution with no boot-time signal.
56    #[serde(
57        default,
58        deserialize_with = "deserialize_trusted_proxies",
59        serialize_with = "serialize_trusted_proxies"
60    )]
61    #[schemars(with = "Vec<String>")]
62    pub trusted_proxies: Vec<IpNet>,
63}
64
65fn parse_trusted_proxy(entry: &str) -> Result<IpNet, String> {
66    let trimmed = entry.trim();
67    if let Ok(net) = trimmed.parse::<IpNet>() {
68        return Ok(net);
69    }
70    match trimmed.parse::<IpAddr>() {
71        Ok(IpAddr::V4(v4)) => Ok(IpNet::from(ipnet::Ipv4Net::from(v4))),
72        Ok(IpAddr::V6(v6)) => Ok(IpNet::from(ipnet::Ipv6Net::from(v6))),
73        Err(_) => Err(format!(
74            "'{trimmed}' is not a valid CIDR range or IP address"
75        )),
76    }
77}
78
79fn deserialize_trusted_proxies<'de, D>(deserializer: D) -> Result<Vec<IpNet>, D::Error>
80where
81    D: Deserializer<'de>,
82{
83    let raw = Vec::<String>::deserialize(deserializer)?;
84    raw.iter()
85        .map(|s| s.trim())
86        .filter(|s| !s.is_empty())
87        .map(|s| parse_trusted_proxy(s).map_err(serde::de::Error::custom))
88        .collect()
89}
90
91fn serialize_trusted_proxies<S>(nets: &[IpNet], serializer: S) -> Result<S::Ok, S::Error>
92where
93    S: Serializer,
94{
95    serializer.collect_seq(nets.iter().map(ToString::to_string))
96}
97
98const fn default_max_concurrent_streams() -> usize {
99    crate::config::DEFAULT_MAX_CONCURRENT_STREAMS
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub struct ContentNegotiationConfig {
105    #[serde(default)]
106    pub enabled: bool,
107
108    #[serde(default = "default_markdown_suffix")]
109    pub markdown_suffix: String,
110}
111
112impl Default for ContentNegotiationConfig {
113    fn default() -> Self {
114        Self {
115            enabled: false,
116            markdown_suffix: default_markdown_suffix(),
117        }
118    }
119}
120
121fn default_markdown_suffix() -> String {
122    ".md".to_owned()
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct SecurityHeadersConfig {
128    #[serde(default = "default_enabled")]
129    pub enabled: bool,
130
131    #[serde(default = "default_hsts")]
132    pub hsts: String,
133
134    #[serde(default = "default_frame_options")]
135    #[schemars(with = "String")]
136    pub frame_options: FrameOptions,
137
138    #[serde(default = "default_content_type_options")]
139    pub content_type_options: String,
140
141    #[serde(default)]
142    pub referrer_policy: ReferrerPolicy,
143
144    #[serde(default = "default_permissions_policy")]
145    pub permissions_policy: String,
146
147    #[serde(default)]
148    pub content_security_policy: Option<String>,
149}
150
151impl Default for SecurityHeadersConfig {
152    fn default() -> Self {
153        Self {
154            enabled: true,
155            hsts: default_hsts(),
156            frame_options: default_frame_options(),
157            content_type_options: default_content_type_options(),
158            referrer_policy: ReferrerPolicy::default(),
159            permissions_policy: default_permissions_policy(),
160            content_security_policy: None,
161        }
162    }
163}
164
165const fn default_enabled() -> bool {
166    true
167}
168
169fn default_hsts() -> String {
170    "max-age=63072000; includeSubDomains; preload".to_owned()
171}
172
173const fn default_frame_options() -> FrameOptions {
174    FrameOptions::Deny
175}
176
177fn default_content_type_options() -> String {
178    "nosniff".to_owned()
179}
180
181fn default_permissions_policy() -> String {
182    "camera=(), microphone=(), geolocation=()".to_owned()
183}
184
185/// `Referrer-Policy` directive. A closed set — an unknown value in the
186/// profile is a load error rather than a header the browser silently ignores.
187#[derive(
188    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
189)]
190pub enum ReferrerPolicy {
191    #[serde(rename = "no-referrer")]
192    NoReferrer,
193    #[serde(rename = "no-referrer-when-downgrade")]
194    NoReferrerWhenDowngrade,
195    #[serde(rename = "origin")]
196    Origin,
197    #[serde(rename = "origin-when-cross-origin")]
198    OriginWhenCrossOrigin,
199    #[serde(rename = "same-origin")]
200    SameOrigin,
201    #[serde(rename = "strict-origin")]
202    StrictOrigin,
203    #[default]
204    #[serde(rename = "strict-origin-when-cross-origin")]
205    StrictOriginWhenCrossOrigin,
206    #[serde(rename = "unsafe-url")]
207    UnsafeUrl,
208}
209
210impl ReferrerPolicy {
211    #[must_use]
212    pub const fn header_value(self) -> &'static str {
213        match self {
214            Self::NoReferrer => "no-referrer",
215            Self::NoReferrerWhenDowngrade => "no-referrer-when-downgrade",
216            Self::Origin => "origin",
217            Self::OriginWhenCrossOrigin => "origin-when-cross-origin",
218            Self::SameOrigin => "same-origin",
219            Self::StrictOrigin => "strict-origin",
220            Self::StrictOriginWhenCrossOrigin => "strict-origin-when-cross-origin",
221            Self::UnsafeUrl => "unsafe-url",
222        }
223    }
224}