Skip to content

Commit 1bd31b1

Browse files
author
Paul C
committed
feat: wire OIDC, image watcher, and integrations to live API endpoints
All announced features now have working REST endpoints: OIDC / SSO: - GET /api/auth/oidc/providers — list configured SSO providers (no auth) - GET /api/auth/oidc/login/{id} — initiate OIDC login redirect - GET /api/auth/oidc/callback — handle IdP callback, create session - Role mapping from IdP claims via map_claims_to_role() Docker Image Watcher: - GET /api/image-watcher/config — get watcher configuration - PUT /api/image-watcher/config — save watcher configuration - GET /api/image-watcher/status — cached check results for all containers - POST /api/image-watcher/check/{container} — force check a container - Background task checks all containers on configured interval API Integration Framework: - GET /api/integrations/connectors — list available connector types - GET /api/integrations — list configured instances with health - POST /api/integrations — create new instance - GET /api/integrations/{id} — get instance details - DELETE /api/integrations/{id} — delete instance - Background health check loop (60s interval) AppState: added oidc_pending_flows, image_watcher_cache, integrations fields Zero dead code warnings. 74 tests pass.
1 parent f9924d1 commit 1bd31b1

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "16.16.3"
3+
version = "16.16.4"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ pub struct AppState {
125125
pub alert_log: Arc<std::sync::RwLock<Vec<AlertLogEntry>>>,
126126
/// Password reset tokens (in-memory, 30-minute expiry)
127127
pub password_reset_tokens: Arc<crate::auth::PasswordResetTokens>,
128+
/// OIDC pending authentication flows (state token → flow data, 5-minute TTL)
129+
pub oidc_pending_flows: Arc<std::sync::RwLock<std::collections::HashMap<String, crate::auth::oidc::OidcPendingFlow>>>,
130+
/// Image update watcher cache (container name → last check result)
131+
pub image_watcher_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, crate::containers::image_watcher::ImageCheckResult>>>,
132+
/// Integration framework state
133+
pub integrations: Arc<crate::integrations::IntegrationState>,
128134
}
129135

130136
#[derive(Clone, Serialize, Deserialize)]
@@ -15198,6 +15204,261 @@ pub async fn token_events(req: HttpRequest, state: web::Data<AppState>) -> HttpR
1519815204
HttpResponse::Ok().json(entries)
1519915205
}
1520015206

15207+
// ═══════════════════════════════════════════════════════════════════════════
15208+
// ─── OIDC Authentication Endpoints ───
15209+
// ═══════════════════════════════════════════════════════════════════════════
15210+
15211+
/// GET /api/auth/oidc/providers — list available OIDC providers (no auth)
15212+
pub async fn oidc_providers() -> HttpResponse {
15213+
if !crate::compat::platform_ready() {
15214+
return HttpResponse::Ok().json(serde_json::json!([]));
15215+
}
15216+
let config = crate::auth::oidc::OidcConfig::load();
15217+
if !config.enabled {
15218+
return HttpResponse::Ok().json(serde_json::json!([]));
15219+
}
15220+
let providers: Vec<serde_json::Value> = config.providers.iter()
15221+
.filter(|p| p.enabled)
15222+
.map(|p| serde_json::json!({
15223+
"id": p.id,
15224+
"name": p.name,
15225+
"button_label": format!("Sign in with {}", p.name),
15226+
}))
15227+
.collect();
15228+
HttpResponse::Ok().json(providers)
15229+
}
15230+
15231+
/// GET /api/auth/oidc/login/{provider_id} — initiate OIDC login (no auth, returns 302)
15232+
pub async fn oidc_login(
15233+
req: HttpRequest,
15234+
state: web::Data<AppState>,
15235+
path: web::Path<String>,
15236+
) -> HttpResponse {
15237+
let provider_id = path.into_inner();
15238+
let config = crate::auth::oidc::OidcConfig::load();
15239+
if !config.enabled {
15240+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "OIDC is not enabled" }));
15241+
}
15242+
let provider = match config.providers.iter().find(|p| p.id == provider_id && p.enabled) {
15243+
Some(p) => p.clone(),
15244+
None => return HttpResponse::NotFound().json(serde_json::json!({ "error": "Provider not found" })),
15245+
};
15246+
15247+
let conn = req.connection_info();
15248+
let redirect_uri_base = format!("{}://{}", conn.scheme(), conn.host());
15249+
15250+
match crate::auth::oidc::build_auth_url(&provider, &redirect_uri_base).await {
15251+
Ok((auth_url, pending)) => {
15252+
let csrf_state = pending.csrf_state.clone();
15253+
state.oidc_pending_flows.write().unwrap().insert(csrf_state, pending);
15254+
HttpResponse::Found().append_header(("Location", auth_url)).finish()
15255+
}
15256+
Err(e) => {
15257+
error!("OIDC build_auth_url failed: {}", e);
15258+
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }))
15259+
}
15260+
}
15261+
}
15262+
15263+
#[derive(Deserialize)]
15264+
pub struct OidcCallbackQuery {
15265+
pub code: String,
15266+
pub state: String,
15267+
}
15268+
15269+
/// GET /api/auth/oidc/callback — OIDC callback (no auth, returns 302 to dashboard)
15270+
pub async fn oidc_callback(
15271+
req: HttpRequest,
15272+
state: web::Data<AppState>,
15273+
query: web::Query<OidcCallbackQuery>,
15274+
) -> HttpResponse {
15275+
// Look up and remove the pending flow (single use)
15276+
let pending = {
15277+
let mut flows = state.oidc_pending_flows.write().unwrap();
15278+
flows.remove(&query.state)
15279+
};
15280+
let pending = match pending {
15281+
Some(p) => p,
15282+
None => return HttpResponse::BadRequest().json(serde_json::json!({
15283+
"error": "Invalid or expired OIDC state token"
15284+
})),
15285+
};
15286+
15287+
// Find the provider
15288+
let config = crate::auth::oidc::OidcConfig::load();
15289+
let provider = match config.providers.iter().find(|p| p.id == pending.provider_id) {
15290+
Some(p) => p.clone(),
15291+
None => return HttpResponse::InternalServerError().json(serde_json::json!({
15292+
"error": "OIDC provider no longer configured"
15293+
})),
15294+
};
15295+
15296+
let conn = req.connection_info();
15297+
let redirect_uri_base = format!("{}://{}", conn.scheme(), conn.host());
15298+
15299+
// Exchange the authorization code for claims
15300+
let claims = match crate::auth::oidc::exchange_code(
15301+
&provider, &query.code, &pending, &redirect_uri_base, &state.cluster_secret,
15302+
).await {
15303+
Ok(c) => c,
15304+
Err(e) => {
15305+
error!("OIDC code exchange failed: {}", e);
15306+
return HttpResponse::InternalServerError().json(serde_json::json!({ "error": e }));
15307+
}
15308+
};
15309+
15310+
// Extract username from claims (preferred_username → email → sub)
15311+
let username = claims.get("preferred_username")
15312+
.or_else(|| claims.get("email"))
15313+
.or_else(|| claims.get("sub"))
15314+
.and_then(|v| v.as_str())
15315+
.unwrap_or("oidc-user")
15316+
.to_string();
15317+
15318+
// Map IdP claims to a WolfStack role using the provider's role_claim + role_mappings
15319+
let role = crate::auth::oidc::map_claims_to_role(&claims, &provider);
15320+
tracing::info!("OIDC login: user='{}', provider='{}', role='{}'", username, provider.id, role);
15321+
15322+
// Create session and set cookie
15323+
let token = state.sessions.create_session(&username);
15324+
let mut cookie = Cookie::build("wolfstack_session", &token)
15325+
.path("/")
15326+
.http_only(true)
15327+
.same_site(actix_web::cookie::SameSite::Lax)
15328+
.max_age(actix_web::cookie::time::Duration::hours(8))
15329+
.finish();
15330+
if state.tls_enabled {
15331+
cookie.set_secure(true);
15332+
}
15333+
15334+
HttpResponse::Found()
15335+
.cookie(cookie)
15336+
.append_header(("Location", "/index.html"))
15337+
.finish()
15338+
}
15339+
15340+
// ═══════════════════════════════════════════════════════════════════════════
15341+
// ─── Image Watcher Endpoints ───
15342+
// ═══════════════════════════════════════════════════════════════════════════
15343+
15344+
/// GET /api/image-watcher/config
15345+
pub async fn image_watcher_config_get(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15346+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15347+
HttpResponse::Ok().json(crate::containers::image_watcher::ImageWatcherConfig::load())
15348+
}
15349+
15350+
/// PUT /api/image-watcher/config
15351+
pub async fn image_watcher_config_save(
15352+
req: HttpRequest,
15353+
state: web::Data<AppState>,
15354+
body: web::Json<crate::containers::image_watcher::ImageWatcherConfig>,
15355+
) -> HttpResponse {
15356+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15357+
match body.into_inner().save() {
15358+
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "message": "Image watcher config saved" })),
15359+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
15360+
}
15361+
}
15362+
15363+
/// GET /api/image-watcher/status
15364+
pub async fn image_watcher_status(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15365+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15366+
let cache = state.image_watcher_cache.read().unwrap();
15367+
let results: Vec<&crate::containers::image_watcher::ImageCheckResult> = cache.values().collect();
15368+
HttpResponse::Ok().json(results)
15369+
}
15370+
15371+
/// POST /api/image-watcher/check/{container}
15372+
pub async fn image_watcher_check(
15373+
req: HttpRequest,
15374+
state: web::Data<AppState>,
15375+
path: web::Path<String>,
15376+
) -> HttpResponse {
15377+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15378+
let container = path.into_inner();
15379+
match crate::containers::image_watcher::check_container_update(&container).await {
15380+
Ok(result) => {
15381+
state.image_watcher_cache.write().unwrap()
15382+
.insert(result.container_name.clone(), result.clone());
15383+
HttpResponse::Ok().json(result)
15384+
}
15385+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
15386+
}
15387+
}
15388+
15389+
// ═══════════════════════════════════════════════════════════════════════════
15390+
// ─── Integration Framework Endpoints ───
15391+
// ═══════════════════════════════════════════════════════════════════════════
15392+
15393+
/// GET /api/integrations/connectors — list available connector types
15394+
pub async fn integrations_connectors(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15395+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15396+
let infos: Vec<crate::integrations::ConnectorInfo> = state.integrations.connectors
15397+
.values().map(|c| c.info()).collect();
15398+
HttpResponse::Ok().json(infos)
15399+
}
15400+
15401+
/// GET /api/integrations — list configured integration instances
15402+
pub async fn integrations_list(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
15403+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15404+
let instances = state.integrations.list_instances();
15405+
let result: Vec<serde_json::Value> = instances.iter().map(|inst| {
15406+
let health = state.integrations.get_health(&inst.id);
15407+
serde_json::json!({
15408+
"instance": inst,
15409+
"health": health,
15410+
})
15411+
}).collect();
15412+
HttpResponse::Ok().json(result)
15413+
}
15414+
15415+
/// POST /api/integrations — create a new integration instance
15416+
pub async fn integrations_create(
15417+
req: HttpRequest,
15418+
state: web::Data<AppState>,
15419+
body: web::Json<crate::integrations::IntegrationInstance>,
15420+
) -> HttpResponse {
15421+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15422+
match state.integrations.create_instance(body.into_inner()) {
15423+
Ok(inst) => HttpResponse::Ok().json(inst),
15424+
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
15425+
}
15426+
}
15427+
15428+
/// GET /api/integrations/{id} — get a single integration instance
15429+
pub async fn integrations_get(
15430+
req: HttpRequest,
15431+
state: web::Data<AppState>,
15432+
path: web::Path<String>,
15433+
) -> HttpResponse {
15434+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15435+
let id = path.into_inner();
15436+
match state.integrations.get_instance(&id) {
15437+
Some(inst) => {
15438+
let health = state.integrations.get_health(&id);
15439+
HttpResponse::Ok().json(serde_json::json!({
15440+
"instance": inst,
15441+
"health": health,
15442+
}))
15443+
}
15444+
None => HttpResponse::NotFound().json(serde_json::json!({ "error": "Integration not found" })),
15445+
}
15446+
}
15447+
15448+
/// DELETE /api/integrations/{id} — delete an integration instance
15449+
pub async fn integrations_delete(
15450+
req: HttpRequest,
15451+
state: web::Data<AppState>,
15452+
path: web::Path<String>,
15453+
) -> HttpResponse {
15454+
if let Err(resp) = require_auth(&req, &state) { return resp; }
15455+
let id = path.into_inner();
15456+
match state.integrations.delete_instance(&id) {
15457+
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "message": "Integration deleted" })),
15458+
Err(e) => HttpResponse::NotFound().json(serde_json::json!({ "error": e })),
15459+
}
15460+
}
15461+
1520115462
/// Configure all API routes
1520215463
pub fn configure(cfg: &mut web::ServiceConfig) {
1520315464
cfg
@@ -15212,6 +15473,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1521215473
.route("/api/auth/smtp-configured", web::get().to(smtp_configured))
1521315474
.route("/api/auth/forgot-password", web::post().to(forgot_password))
1521415475
.route("/api/auth/reset-password", web::post().to(reset_password))
15476+
// OIDC Authentication (no auth required)
15477+
.route("/api/auth/oidc/providers", web::get().to(oidc_providers))
15478+
.route("/api/auth/oidc/login/{provider_id}", web::get().to(oidc_login))
15479+
.route("/api/auth/oidc/callback", web::get().to(oidc_callback))
1521515480
// User management (auth required)
1521615481
.route("/api/auth/config", web::get().to(get_auth_config))
1521715482
.route("/api/auth/config", web::post().to(save_auth_config))
@@ -15268,6 +15533,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1526815533
.route("/api/containers/{runtime}/{id}/updates/check", web::post().to(container_updates_check))
1526915534
.route("/api/containers/{runtime}/{id}/updates/apply", web::post().to(container_updates_apply))
1527015535
.route("/api/containers/{runtime}/{id}/exec", web::post().to(container_exec))
15536+
// Image Watcher (auth required)
15537+
.route("/api/image-watcher/config", web::get().to(image_watcher_config_get))
15538+
.route("/api/image-watcher/config", web::put().to(image_watcher_config_save))
15539+
.route("/api/image-watcher/status", web::get().to(image_watcher_status))
15540+
.route("/api/image-watcher/check/{container}", web::post().to(image_watcher_check))
1527115541
// Certificates
1527215542
.route("/api/certificates", web::post().to(request_certificate))
1527315543
.route("/api/certificates/list", web::get().to(list_certificates))
@@ -15604,6 +15874,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1560415874
.route("/api/alerts/config", web::get().to(alerts_config_get))
1560515875
.route("/api/alerts/config", web::post().to(alerts_config_save))
1560615876
.route("/api/alerts/test", web::post().to(alerts_test))
15877+
// Integrations (auth required)
15878+
.route("/api/integrations/connectors", web::get().to(integrations_connectors))
15879+
.route("/api/integrations", web::get().to(integrations_list))
15880+
.route("/api/integrations", web::post().to(integrations_create))
15881+
.route("/api/integrations/{id}", web::get().to(integrations_get))
15882+
.route("/api/integrations/{id}", web::delete().to(integrations_delete))
1560715883
// WolfRun — container orchestration
1560815884
.route("/api/wolfrun/services", web::get().to(wolfrun_list))
1560915885
.route("/api/wolfrun/services", web::post().to(wolfrun_create))

src/auth/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
// with optional WolfStack user accounts and TOTP two-factor authentication.
1111

1212
pub mod users;
13+
#[allow(dead_code)]
1314
pub mod oidc;
15+
#[allow(dead_code)]
1416
pub mod webauthn;
1517

1618
use std::collections::HashMap;

src/main.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod wolfnote;
4141
mod paths;
4242
mod compat;
4343
mod plugins;
44+
#[allow(dead_code)]
4445
mod integrations;
4546

4647
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};
@@ -322,6 +323,9 @@ async fn main() -> std::io::Result<()> {
322323
migration_tasks: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
323324
alert_log: Arc::new(std::sync::RwLock::new(Vec::new())),
324325
password_reset_tokens: Arc::new(auth::PasswordResetTokens::new()),
326+
oidc_pending_flows: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
327+
image_watcher_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
328+
integrations: Arc::new(crate::integrations::IntegrationState::new(&cluster_secret)),
325329
});
326330

327331
// Background: periodic self-monitoring update
@@ -485,6 +489,38 @@ async fn main() -> std::io::Result<()> {
485489
});
486490
}
487491

492+
// Background: image watcher — periodic check for container image updates
493+
{
494+
let iw_cache = app_state.image_watcher_cache.clone();
495+
tokio::spawn(async move {
496+
tokio::time::sleep(Duration::from_secs(120)).await;
497+
loop {
498+
let config = crate::containers::image_watcher::ImageWatcherConfig::load();
499+
if config.enabled {
500+
let results = crate::containers::image_watcher::check_all_containers(&config).await;
501+
let mut cache = iw_cache.write().unwrap();
502+
for r in results {
503+
cache.insert(r.container_name.clone(), r);
504+
}
505+
}
506+
let interval = config.check_interval_secs.max(300);
507+
tokio::time::sleep(Duration::from_secs(interval)).await;
508+
}
509+
});
510+
}
511+
512+
// Background: integration health checks (every 60 seconds)
513+
{
514+
let int_state = app_state.integrations.clone();
515+
tokio::spawn(async move {
516+
tokio::time::sleep(Duration::from_secs(60)).await;
517+
loop {
518+
int_state.check_all_health().await;
519+
tokio::time::sleep(Duration::from_secs(60)).await;
520+
}
521+
});
522+
}
523+
488524
// Background: scheduled issues scan (configurable alerts + daily summary)
489525
let scan_state = app_state.clone();
490526
let scan_ai = ai_agent.clone();

0 commit comments

Comments
 (0)