systemprompt_cli/commands/cloud/doctor/
checks.rs1use std::collections::HashMap;
13use std::hash::BuildHasher;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use systemprompt_models::Profile;
18use systemprompt_models::services::ProviderRegistry;
19
20use super::{CheckResult, CheckStatus};
21
22pub fn check_profile_valid(profile: &Profile) -> CheckResult {
23 match profile.validate() {
24 Ok(()) => CheckResult::pass("profile", "schema and required fields valid"),
25 Err(err) => CheckResult::fail("profile", err.to_string()),
26 }
27}
28
29pub fn check_extension_configs(profile: &Profile) -> CheckResult {
30 let services_path = Path::new(&profile.paths.services);
31 match systemprompt_runtime::validate_extension_configs(services_path) {
32 Err(err) => CheckResult::fail(
33 "extension-config",
34 format!("could not discover extensions: {err}"),
35 ),
36 Ok(outcomes) => {
37 let failures: Vec<String> = outcomes
38 .iter()
39 .filter_map(|o| {
40 o.error
41 .as_ref()
42 .map(|msg| format!("[ext:{}] {msg}", o.extension_id))
43 })
44 .collect();
45 if failures.is_empty() {
46 CheckResult::pass("extension-config", "all extension configs valid")
47 } else {
48 CheckResult::fail("extension-config", failures.join("\n"))
49 }
50 },
51 }
52}
53
54pub(in crate::commands::cloud) fn resolve_signing_key_path(
55 profile: &Profile,
56 profile_dir: &Path,
57) -> PathBuf {
58 let configured = &profile.security.signing_key_path;
59 if configured.is_absolute() {
60 configured.clone()
61 } else {
62 profile_dir.join(configured)
63 }
64}
65
66pub fn check_signing_key<S: BuildHasher>(
67 profile: &Profile,
68 profile_dir: &Path,
69 secrets: &HashMap<String, String, S>,
70) -> CheckResult {
71 if secrets.contains_key("signing_key_pem") {
72 return CheckResult::pass("signing-key", "provided via secrets.json (signing_key_pem)");
73 }
74
75 let path = resolve_signing_key_path(profile, profile_dir);
76 if path.exists() {
77 CheckResult::pass("signing-key", path.display().to_string())
78 } else {
79 CheckResult::fail(
80 "signing-key",
81 format!(
82 "no signing key at {} and no signing_key_pem in secrets.json — the deploy cannot \
83 provision a JWT signing key, so every request would 500. Generate one with \
84 `systemprompt admin keys generate --output {}`.",
85 path.display(),
86 path.display()
87 ),
88 )
89 }
90}
91
92pub fn check_required_secrets<S: BuildHasher>(secrets: &HashMap<String, String, S>) -> CheckResult {
93 let mut missing: Vec<&str> = Vec::new();
94
95 if !secrets.contains_key("oauth_at_rest_pepper") {
96 missing.push("oauth_at_rest_pepper");
97 }
98 let has_db =
99 secrets.contains_key("database_url") || secrets.contains_key("internal_database_url");
100 if !has_db {
101 missing.push("database_url (or internal_database_url)");
102 }
103
104 if missing.is_empty() {
105 CheckResult::pass("secrets", "required keys present")
106 } else {
107 CheckResult::fail(
108 "secrets",
109 format!(
110 "secrets.json is missing required keys: {}",
111 missing.join(", ")
112 ),
113 )
114 }
115}
116
117pub fn check_provider_secrets<S: BuildHasher>(
118 registry: &ProviderRegistry,
119 secrets: &HashMap<String, String, S>,
120) -> CheckResult {
121 let missing: Vec<String> = registry
122 .providers
123 .iter()
124 .filter(|provider| !secret_present(secrets, provider.api_key_secret.as_str()))
125 .map(|provider| {
126 format!(
127 "{} (needs `{}`)",
128 provider.name.as_str(),
129 provider.api_key_secret.as_str()
130 )
131 })
132 .collect();
133
134 if missing.is_empty() {
135 CheckResult::pass("providers", "all provider credentials present")
136 } else {
137 CheckResult::fail(
138 "providers",
139 format!(
140 "secrets.json is missing credentials for: {}",
141 missing.join(", ")
142 ),
143 )
144 }
145}
146
147fn secret_present<S: BuildHasher>(secrets: &HashMap<String, String, S>, name: &str) -> bool {
148 secrets.contains_key(name)
149 || secrets.contains_key(&name.to_uppercase())
150 || secrets.contains_key(&name.to_lowercase())
151}
152
153pub(super) async fn check_database_reachable(secrets: &HashMap<String, String>) -> CheckResult {
154 let Some(url) = secrets
155 .get("external_database_url")
156 .or_else(|| secrets.get("database_url"))
157 .or_else(|| secrets.get("internal_database_url"))
158 else {
159 return CheckResult::warn("database", "no database URL to probe");
160 };
161
162 let Some((host, port)) = host_port(url) else {
163 return CheckResult::warn("database", "could not parse host:port from database URL");
164 };
165
166 match tokio::time::timeout(
167 Duration::from_secs(5),
168 tokio::net::TcpStream::connect((host.as_str(), port)),
169 )
170 .await
171 {
172 Ok(Ok(_)) => CheckResult::pass("database", format!("reachable at {host}:{port}")),
173 Ok(Err(err)) => CheckResult::warn(
174 "database",
175 format!("{host}:{port} unreachable from here ({err}) — fine if DB is Fly-internal"),
176 ),
177 Err(_) => CheckResult::warn(
178 "database",
179 format!("{host}:{port} did not answer within 5s — fine if DB is Fly-internal"),
180 ),
181 }
182}
183
184pub fn check_proxy_topology(profile: &Profile) -> CheckResult {
185 if !profile.target.is_cloud() {
186 return CheckResult::pass("proxy-topology", "not a cloud profile");
187 }
188 if systemprompt_cloud::trusted_proxies::covers_fly_peer(&profile.server.trusted_proxies) {
189 if !systemprompt_cloud::trusted_proxies::covers_fly_public_edge(
190 &profile.server.trusted_proxies,
191 ) {
192 return CheckResult::warn(
193 "proxy-topology",
194 "server.trusted_proxies covers the Fly peer range (fc00::/7) but not Fly's \
195 public edge range 66.241.64.0/18 — requests routed through the public edge \
196 would be attributed to the Fly proxy instead of the client. Add to the \
197 profile:\n server:\n trusted_proxies:\n - \"66.241.64.0/18\"",
198 );
199 }
200 return CheckResult::pass(
201 "proxy-topology",
202 "server.trusted_proxies covers the Fly peer (fc00::/7) and public edge \
203 (66.241.64.0/18) ranges",
204 );
205 }
206 CheckResult::fail(
207 "proxy-topology",
208 "server.trusted_proxies does not cover Fly's internal peer range fc00::/7 — every \
209 request would resolve to the Fly proxy's private address and forwarded client-IP \
210 headers (X-Forwarded-For, CF-Connecting-IP) would be ignored, breaking geo \
211 attribution, rate-limit keys, and IP bans. Add to the profile:\n server:\n \
212 trusted_proxies:\n - \"fc00::/7\"\nplus your edge proxy ranges (e.g. Cloudflare).",
213 )
214}
215
216pub(super) fn check_governance_hook_url(profile: &Profile) -> CheckResult {
217 let Some(authz) = profile.governance.as_ref().and_then(|g| g.authz.as_ref()) else {
218 return CheckResult::warn("hook-url", "no governance.authz block");
219 };
220 let Some(url) = authz.hook.url.as_deref().filter(|u| !u.is_empty()) else {
221 return CheckResult::pass("hook-url", "no webhook URL to check for this mode");
222 };
223
224 let hook_host = host_port(url).map(|(h, _)| h);
225 let external_host = host_port(&profile.server.api_external_url).map(|(h, _)| h);
226
227 match (hook_host, external_host) {
228 (Some(hook), Some(external)) if hook == external => {
229 CheckResult::pass("hook-url", format!("targets {external}"))
230 },
231 (Some(hook), Some(external)) if is_loopback(&hook) => CheckResult::warn(
232 "hook-url",
233 format!(
234 "points at {hook} but api_external_url is {external} — a loopback hook only works \
235 if the gateway and webhook share the machine"
236 ),
237 ),
238 (Some(hook), Some(external)) => CheckResult::warn(
239 "hook-url",
240 format!("targets {hook}, but api_external_url is {external} — verify this is intended"),
241 ),
242 _ => CheckResult::warn("hook-url", "could not parse hook or api_external_url host"),
243 }
244}
245
246fn host_port(raw: &str) -> Option<(String, u16)> {
247 let parsed = url::Url::parse(raw).ok()?;
248 let host = parsed.host_str()?.to_owned();
249 let port = parsed.port_or_known_default()?;
250 Some((host, port))
251}
252
253fn is_loopback(host: &str) -> bool {
254 host == "localhost" || host == "127.0.0.1" || host == "::1"
255}
256
257impl CheckResult {
258 pub(super) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
259 Self {
260 name,
261 status: CheckStatus::Pass,
262 detail: detail.into(),
263 }
264 }
265
266 pub(super) fn warn(name: &'static str, detail: impl Into<String>) -> Self {
267 Self {
268 name,
269 status: CheckStatus::Warn,
270 detail: detail.into(),
271 }
272 }
273
274 pub(super) fn fail(name: &'static str, detail: impl Into<String>) -> Self {
275 Self {
276 name,
277 status: CheckStatus::Fail,
278 detail: detail.into(),
279 }
280 }
281}