@@ -44768,6 +44768,67 @@ fn serde_qs_from_map(
4476844768 })
4476944769}
4477044770
44771+ /// Strip a hostname down to what is safe inside a `Content-Disposition`
44772+ /// filename. A hostname is operator-controlled, and CR/LF or a quote in a
44773+ /// response header is header injection — so this allows only characters
44774+ /// that cannot terminate or escape the header value.
44775+ pub(crate) fn sanitise_filename_part(raw: &str) -> String {
44776+ let cleaned: String = raw
44777+ .chars()
44778+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
44779+ .collect();
44780+ let trimmed = cleaned.trim_matches('-').to_string();
44781+ if trimmed.is_empty() {
44782+ "wolfstack".to_string()
44783+ } else {
44784+ trimmed.chars().take(64).collect()
44785+ }
44786+ }
44787+
44788+ /// GET /api/diagnostics — collect a full self-diagnostic.
44789+ ///
44790+ /// Returns the structured report for on-screen display AND the rendered
44791+ /// text bundle in the same response, so what the operator reads and what
44792+ /// they send us are the same snapshot. Rendering server-side also keeps
44793+ /// the one formatter in one place rather than duplicating it in JS.
44794+ ///
44795+ /// Admin-only: the report exposes peer addresses, thread layout and
44796+ /// journal lines. It deliberately contains nothing from /etc/wolfstack,
44797+ /// so no cluster secret or credential can ride along.
44798+ pub async fn diagnostics_collect(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
44799+ let caller = match require_auth(&req, &state) { Ok(u) => u, Err(resp) => return resp };
44800+ if !crate::auth::session_user_is_admin(&caller) {
44801+ return HttpResponse::Forbidden().json(serde_json::json!({
44802+ "error": "Only admin users can run diagnostics"
44803+ }));
44804+ }
44805+
44806+ // Blocking: samples CPU for ~1.5s and shells out to journalctl once.
44807+ // Never run that on a runtime worker.
44808+ let report = match web::block(crate::diagnostics::collect).await {
44809+ Ok(r) => r,
44810+ Err(e) => {
44811+ error!("diagnostics collection failed: {e}");
44812+ return HttpResponse::InternalServerError().json(serde_json::json!({
44813+ "error": format!("Diagnostic collection failed: {e}")
44814+ }));
44815+ }
44816+ };
44817+
44818+ let text = crate::diagnostics::render_text(&report);
44819+ let filename = format!(
44820+ "wolfstack-diagnostic-{}-{}.txt",
44821+ sanitise_filename_part(&report.context.hostname),
44822+ chrono::Utc::now().format("%Y%m%d-%H%M%S"),
44823+ );
44824+
44825+ HttpResponse::Ok().json(serde_json::json!({
44826+ "report": report,
44827+ "text": text,
44828+ "filename": filename,
44829+ }))
44830+ }
44831+
4477144832/// Configure all API routes
4477244833pub fn configure(cfg: &mut web::ServiceConfig) {
4477344834 cfg
@@ -44845,6 +44906,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
4484544906 .route("/api/auth/smtp-configured", web::get().to(smtp_configured))
4484644907 .route("/api/auth/forgot-password", web::post().to(forgot_password))
4484744908 .route("/api/auth/reset-password", web::post().to(reset_password))
44909+ // Self-diagnostics (Settings > Diagnostics). Admin-only.
44910+ .route("/api/diagnostics", web::get().to(diagnostics_collect))
4484844911 // Support tickets — proxied to wolfstack.org, auth = dashboard session
4484944912 .route("/api/support/entitlement", web::get().to(support_entitlement))
4485044913 .route("/api/support/tickets", web::get().to(support_tickets_list))
0 commit comments