diff --git a/control-plane/api-gateway/src/routes/registry.rs b/control-plane/api-gateway/src/routes/registry.rs index 4cb0573..4199626 100644 --- a/control-plane/api-gateway/src/routes/registry.rs +++ b/control-plane/api-gateway/src/routes/registry.rs @@ -778,6 +778,45 @@ async fn proxy_to_registry( } } +pub async fn create_bootstrap_token( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + headers: HeaderMap, + body: String, +) -> Result)> { + let body_json: Option = serde_json::from_str(&body).ok(); + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::POST, "/admin/bootstrap-tokens", body_json, Some(header_map)).await +} + +pub async fn list_bootstrap_tokens( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + headers: HeaderMap, +) -> Result)> { + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::GET, "/admin/bootstrap-tokens", None, Some(header_map)).await +} + +pub async fn revoke_bootstrap_token( + AuthenticatedUser(_claims): AuthenticatedUser, + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result)> { + let header_map: Vec<(String, String)> = headers + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|val| (k.to_string(), val.to_string()))) + .collect(); + proxy_to_registry(&state, reqwest::Method::POST, &format!("/admin/bootstrap-tokens/{}/revoke", id), None, Some(header_map)).await +} + /// Registry health check #[utoipa::path( get, @@ -811,6 +850,10 @@ pub fn registry_routes() -> Router { "/registry/admin/agents/pending/:id", post(delete_pending_agent), ) + // Admin routes - Bootstrap Token Management + .route("/registry/admin/bootstrap-tokens", post(create_bootstrap_token)) + .route("/registry/admin/bootstrap-tokens", get(list_bootstrap_tokens)) + .route("/registry/admin/bootstrap-tokens/:id/revoke", post(revoke_bootstrap_token)) // Admin routes - Token Management (DEPRECATED) .route("/registry/admin/tokens", post(create_token)) .route("/registry/admin/tokens", get(list_tokens)) diff --git a/control-plane/registry/src/db/bootstrap_tokens.rs b/control-plane/registry/src/db/bootstrap_tokens.rs new file mode 100644 index 0000000..ec7dbbf --- /dev/null +++ b/control-plane/registry/src/db/bootstrap_tokens.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use entity::bootstrap_tokens; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, +}; +use uuid::Uuid; + +pub async fn create( + db: &DatabaseConnection, + token: String, + description: Option, + created_by: String, + expires_at: chrono::DateTime, + max_uses: i32, +) -> Result { + let model = bootstrap_tokens::ActiveModel { + id: Set(Uuid::new_v4()), + token: Set(token), + description: Set(description), + created_by: Set(created_by), + created_at: Set(chrono::Utc::now().naive_utc()), + expires_at: Set(expires_at.naive_utc()), + max_uses: Set(max_uses), + use_count: Set(0), + revoked: Set(false), + revoked_at: Set(None), + }; + Ok(model.insert(db).await?) +} + +pub async fn get_by_token( + db: &DatabaseConnection, + token: &str, +) -> Result> { + Ok(bootstrap_tokens::Entity::find() + .filter(bootstrap_tokens::Column::Token.eq(token)) + .one(db) + .await?) +} + +pub async fn increment_use_count(db: &DatabaseConnection, id: Uuid) -> Result<()> { + let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))? + .into(); + + let current = match &model.use_count { + sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v, + _ => 0, + }; + model.use_count = Set(current + 1); + model.update(db).await?; + Ok(()) +} + +pub async fn revoke(db: &DatabaseConnection, id: Uuid) -> Result<()> { + let mut model: bootstrap_tokens::ActiveModel = bootstrap_tokens::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| anyhow::anyhow!("Bootstrap token not found"))? + .into(); + + model.revoked = Set(true); + model.revoked_at = Set(Some(chrono::Utc::now().naive_utc())); + model.update(db).await?; + Ok(()) +} + +pub async fn get_all_active( + db: &DatabaseConnection, +) -> Result> { + Ok(bootstrap_tokens::Entity::find() + .filter(bootstrap_tokens::Column::Revoked.eq(false)) + .all(db) + .await?) +} + +pub async fn delete_expired(db: &DatabaseConnection) -> Result { + let now = chrono::Utc::now().naive_utc(); + let result = bootstrap_tokens::Entity::delete_many() + .filter(bootstrap_tokens::Column::ExpiresAt.lt(now)) + .exec(db) + .await?; + Ok(result.rows_affected) +} diff --git a/control-plane/registry/src/db/mod.rs b/control-plane/registry/src/db/mod.rs index 75c731d..d0675a8 100644 --- a/control-plane/registry/src/db/mod.rs +++ b/control-plane/registry/src/db/mod.rs @@ -1,4 +1,5 @@ pub mod agents; pub mod api_keys; +pub mod bootstrap_tokens; pub mod certificates; pub mod tokens; diff --git a/control-plane/registry/src/handlers/admin.rs b/control-plane/registry/src/handlers/admin.rs index 360722a..25f5f05 100644 --- a/control-plane/registry/src/handlers/admin.rs +++ b/control-plane/registry/src/handlers/admin.rs @@ -3,6 +3,7 @@ use axum::{ http::StatusCode, response::Json, }; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ @@ -11,6 +12,18 @@ use crate::{ services::registry::PreRegisterParams, }; +#[derive(Debug, Deserialize)] +pub struct CreateBootstrapTokenRequest { + pub description: Option, + pub ttl_hours: Option, + pub max_uses: Option, +} + +#[derive(Debug, Serialize)] +pub struct RevokeBootstrapTokenResponse { + pub message: String, +} + pub async fn pre_register_agent( State(state): State, Json(request): Json, @@ -127,3 +140,36 @@ pub async fn get_statistics( ) -> Json { Json(state.agent_registry.get_statistics().await) } + +pub async fn create_bootstrap_token( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let ttl_hours = request.ttl_hours.unwrap_or(24 * 30); + let max_uses = request.max_uses.unwrap_or(100); + + state + .bootstrap_token_manager + .create(request.description, "admin".to_string(), ttl_hours, max_uses) + .await + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }))) +} + +pub async fn list_bootstrap_tokens( + State(state): State, +) -> Json> { + Json(state.bootstrap_token_manager.list().await) +} + +pub async fn revoke_bootstrap_token( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + state + .bootstrap_token_manager + .revoke(id) + .await + .map(|_| Json(RevokeBootstrapTokenResponse { message: format!("Bootstrap token {} revoked", id) })) + .map_err(|e| (StatusCode::NOT_FOUND, Json(ErrorResponse { error: e }))) +} diff --git a/control-plane/registry/src/handlers/agent.rs b/control-plane/registry/src/handlers/agent.rs index d04e6fd..7727626 100644 --- a/control-plane/registry/src/handlers/agent.rs +++ b/control-plane/registry/src/handlers/agent.rs @@ -15,52 +15,72 @@ pub async fn register_agent( State(state): State, Json(request): Json, ) -> Result, (StatusCode, Json)> { - let token_data = match state - .token_manager - .validate_and_consume_token(&request.registration_token) - .await - { - Ok(token) => token, - Err(e) => { + let agent_id = if crate::services::bootstrap_tokens::BootstrapTokenManager::is_bootstrap_token( + &request.registration_token, + ) { + if let Err(e) = state + .bootstrap_token_manager + .validate_and_use(&request.registration_token) + .await + { return Err(( StatusCode::UNAUTHORIZED, Json(ErrorResponse { - error: format!("Invalid registration token: {}", e), + error: format!("Invalid bootstrap token: {}", e), }), - )) + )); } - }; + uuid::Uuid::new_v4() + } else { + let token_data = match state + .token_manager + .validate_and_consume_token(&request.registration_token) + .await + { + Ok(token) => token, + Err(e) => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: format!("Invalid registration token: {}", e), + }), + )) + } + }; - if token_data.expected_name != request.name { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: format!( - "Agent name mismatch. Expected '{}', got '{}'", - token_data.expected_name, request.name - ), - }), - )); - } + if token_data.expected_name != request.name { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: format!( + "Agent name mismatch. Expected '{}', got '{}'", + token_data.expected_name, request.name + ), + }), + )); + } - if token_data.expected_hostname != request.hostname { - return Err(( - StatusCode::FORBIDDEN, - Json(ErrorResponse { - error: format!( - "Agent hostname mismatch. Expected '{}', got '{}'", - token_data.expected_hostname, request.hostname - ), - }), - )); - } + if token_data.expected_hostname != request.hostname { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: format!( + "Agent hostname mismatch. Expected '{}', got '{}'", + token_data.expected_hostname, request.hostname + ), + }), + )); + } + + token_data.agent_id + }; let csr_pem = request.csr_pem.clone(); let agent = match state .agent_registry .register_agent(RegisterAgentParams { - agent_id: token_data.agent_id, + agent_id, name: request.name, hostname: request.hostname, os_type: request.os_type, diff --git a/control-plane/registry/src/main.rs b/control-plane/registry/src/main.rs index 9859715..79d0fc4 100644 --- a/control-plane/registry/src/main.rs +++ b/control-plane/registry/src/main.rs @@ -34,6 +34,7 @@ async fn main() -> anyhow::Result<()> { .expect("Failed to initialize PKI service"); let token_manager = Arc::new(services::tokens::TokenManager::new(db_conn.clone())); + let bootstrap_token_manager = Arc::new(services::bootstrap_tokens::BootstrapTokenManager::new(db_conn.clone())); let api_key_manager = Arc::new(services::api_keys::ApiKeyManager::new(db_conn.clone())); let agent_registry = Arc::new(services::registry::AgentRegistry::new(db_conn.clone())); @@ -52,6 +53,7 @@ async fn main() -> anyhow::Result<()> { let state = server::AppState { token_manager: token_manager.clone(), + bootstrap_token_manager: bootstrap_token_manager.clone(), api_key_manager: api_key_manager.clone(), agent_registry: agent_registry.clone(), pki_service: Arc::new(pki_service), @@ -63,11 +65,13 @@ async fn main() -> anyhow::Result<()> { let token_cleanup_handle = { let token_mgr = token_manager.clone(); + let bootstrap_mgr = bootstrap_token_manager.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600)); loop { interval.tick().await; token_mgr.cleanup_expired().await; + bootstrap_mgr.cleanup_expired().await; } }) }; diff --git a/control-plane/registry/src/server.rs b/control-plane/registry/src/server.rs index 1aa0239..fbdccc6 100644 --- a/control-plane/registry/src/server.rs +++ b/control-plane/registry/src/server.rs @@ -11,12 +11,19 @@ use std::sync::Arc; use crate::{ handlers::{admin, agent, pki}, metrics, - services::{api_keys::ApiKeyManager, pki::PkiService, registry::AgentRegistry, tokens::TokenManager}, + services::{ + api_keys::ApiKeyManager, + bootstrap_tokens::BootstrapTokenManager, + pki::PkiService, + registry::AgentRegistry, + tokens::TokenManager, + }, }; #[derive(Clone)] pub struct AppState { pub token_manager: Arc, + pub bootstrap_token_manager: Arc, pub api_key_manager: Arc, pub agent_registry: Arc, pub pki_service: Arc, @@ -42,6 +49,9 @@ pub fn create_router(state: AppState) -> Router { delete(admin::delete_pending_agent), ) .route("/admin/tokens", get(admin::list_tokens)) + .route("/admin/bootstrap-tokens", post(admin::create_bootstrap_token)) + .route("/admin/bootstrap-tokens", get(admin::list_bootstrap_tokens)) + .route("/admin/bootstrap-tokens/:id/revoke", post(admin::revoke_bootstrap_token)) .route("/admin/agents", get(admin::list_agents)) .route("/admin/agents/:agent_id", get(admin::get_agent)) .route("/admin/agents/:agent_id", delete(admin::deregister_agent)) diff --git a/control-plane/registry/src/services/bootstrap_tokens.rs b/control-plane/registry/src/services/bootstrap_tokens.rs new file mode 100644 index 0000000..5190062 --- /dev/null +++ b/control-plane/registry/src/services/bootstrap_tokens.rs @@ -0,0 +1,185 @@ +use chrono::{DateTime, Utc}; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +const TOKEN_PREFIX: &str = "csf-bootstrap."; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapToken { + pub id: Uuid, + pub token: String, + pub description: Option, + pub created_by: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub max_uses: i32, + pub use_count: i32, + pub revoked: bool, +} + +pub struct BootstrapTokenManager { + db: DatabaseConnection, +} + +impl BootstrapTokenManager { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub fn is_bootstrap_token(token: &str) -> bool { + token.starts_with(TOKEN_PREFIX) + } + + pub async fn create( + &self, + description: Option, + created_by: String, + ttl_hours: i64, + max_uses: i32, + ) -> Result { + let token = format!("{}{}", TOKEN_PREFIX, Uuid::new_v4().simple()); + let expires_at = Utc::now() + chrono::Duration::hours(ttl_hours); + + let model = crate::db::bootstrap_tokens::create( + &self.db, + token.clone(), + description.clone(), + created_by.clone(), + expires_at, + max_uses, + ) + .await + .map_err(|e| format!("Failed to create bootstrap token: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!( + "Created bootstrap token max_uses={} ttl_hours={} id={}", + max_uses, ttl_hours, model.id + ) + ); + + Ok(BootstrapToken { + id: model.id, + token: model.token, + description: model.description, + created_by: model.created_by, + created_at: model.created_at.and_utc(), + expires_at: model.expires_at.and_utc(), + max_uses: model.max_uses, + use_count: model.use_count, + revoked: model.revoked, + }) + } + + pub async fn validate_and_use(&self, token_str: &str) -> Result<(), String> { + let model = crate::db::bootstrap_tokens::get_by_token(&self.db, token_str) + .await + .map_err(|e| format!("Database error: {}", e))? + .ok_or_else(|| "Bootstrap token not found".to_string())?; + + if model.revoked { + crate::log_warn!( + "bootstrap_token_manager", + &format!("Rejected revoked bootstrap token id={}", model.id) + ); + return Err("Bootstrap token has been revoked".to_string()); + } + + if Utc::now().naive_utc() >= model.expires_at { + crate::log_warn!( + "bootstrap_token_manager", + &format!("Rejected expired bootstrap token id={}", model.id) + ); + return Err("Bootstrap token has expired".to_string()); + } + + if model.use_count >= model.max_uses { + crate::log_warn!( + "bootstrap_token_manager", + &format!( + "Rejected exhausted bootstrap token id={} use_count={} max_uses={}", + model.id, model.use_count, model.max_uses + ) + ); + return Err("Bootstrap token use limit reached".to_string()); + } + + crate::db::bootstrap_tokens::increment_use_count(&self.db, model.id) + .await + .map_err(|e| format!("Failed to increment use count: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!( + "Bootstrap token used id={} use_count={}/{}", + model.id, + model.use_count + 1, + model.max_uses + ) + ); + + Ok(()) + } + + pub async fn revoke(&self, id: Uuid) -> Result<(), String> { + crate::db::bootstrap_tokens::revoke(&self.db, id) + .await + .map_err(|e| format!("Failed to revoke token: {}", e))?; + + crate::log_info!( + "bootstrap_token_manager", + &format!("Bootstrap token revoked id={}", id) + ); + + Ok(()) + } + + pub async fn list(&self) -> Vec { + match crate::db::bootstrap_tokens::get_all_active(&self.db).await { + Ok(models) => models + .into_iter() + .map(|m| BootstrapToken { + id: m.id, + token: m.token, + description: m.description, + created_by: m.created_by, + created_at: m.created_at.and_utc(), + expires_at: m.expires_at.and_utc(), + max_uses: m.max_uses, + use_count: m.use_count, + revoked: m.revoked, + }) + .collect(), + Err(e) => { + crate::log_error!( + "bootstrap_token_manager", + &format!("Failed to list bootstrap tokens: {}", e) + ); + vec![] + } + } + } + + pub async fn cleanup_expired(&self) -> usize { + match crate::db::bootstrap_tokens::delete_expired(&self.db).await { + Ok(n) => { + if n > 0 { + crate::log_info!( + "bootstrap_token_manager", + &format!("Cleaned up {} expired bootstrap tokens", n) + ); + } + n as usize + } + Err(e) => { + crate::log_error!( + "bootstrap_token_manager", + &format!("Failed to cleanup expired bootstrap tokens: {}", e) + ); + 0 + } + } + } +} diff --git a/control-plane/registry/src/services/mod.rs b/control-plane/registry/src/services/mod.rs index 6fc0e3a..005823b 100644 --- a/control-plane/registry/src/services/mod.rs +++ b/control-plane/registry/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod api_keys; +pub mod bootstrap_tokens; pub mod pki; pub mod registry; pub mod tokens; diff --git a/control-plane/shared/entity/src/entities/bootstrap_tokens.rs b/control-plane/shared/entity/src/entities/bootstrap_tokens.rs new file mode 100644 index 0000000..34aeec2 --- /dev/null +++ b/control-plane/shared/entity/src/entities/bootstrap_tokens.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "bootstrap_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub token: String, + pub description: Option, + pub created_by: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub max_uses: i32, + pub use_count: i32, + pub revoked: bool, + pub revoked_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/mod.rs b/control-plane/shared/entity/src/entities/mod.rs index 75c1eeb..e5257da 100644 --- a/control-plane/shared/entity/src/entities/mod.rs +++ b/control-plane/shared/entity/src/entities/mod.rs @@ -1,4 +1,5 @@ pub mod agent_api_keys; +pub mod bootstrap_tokens; pub mod failover_events; pub mod agent_certificates; pub mod agent_metrics; @@ -22,6 +23,7 @@ pub mod volumes; pub mod workloads; pub use agent_api_keys::Entity as AgentApiKeys; +pub use bootstrap_tokens::Entity as BootstrapTokens; pub use failover_events::Entity as FailoverEvents; pub use agent_certificates::Entity as AgentCertificates; pub use agent_metrics::Entity as AgentMetrics; diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs index 5d444ed..06c9ccb 100644 --- a/control-plane/shared/migration/src/lib.rs +++ b/control-plane/shared/migration/src/lib.rs @@ -16,6 +16,7 @@ mod m20260306_000000_add_volumes; mod m20260306_120000_add_failover_events; mod m20260307_000000_add_networks; mod m20260308_000000_add_org_scoping; +mod m20260309_000000_add_bootstrap_tokens; pub struct Migrator; @@ -39,6 +40,7 @@ impl MigratorTrait for Migrator { Box::new(m20260306_120000_add_failover_events::Migration), Box::new(m20260307_000000_add_networks::Migration), Box::new(m20260308_000000_add_org_scoping::Migration), + Box::new(m20260309_000000_add_bootstrap_tokens::Migration), ] } } diff --git a/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs b/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs new file mode 100644 index 0000000..0c695eb --- /dev/null +++ b/control-plane/shared/migration/src/m20260309_000000_add_bootstrap_tokens.rs @@ -0,0 +1,49 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(BootstrapTokens::Table) + .if_not_exists() + .col(ColumnDef::new(BootstrapTokens::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(BootstrapTokens::Token).string().not_null().unique_key()) + .col(ColumnDef::new(BootstrapTokens::Description).string().null()) + .col(ColumnDef::new(BootstrapTokens::CreatedBy).string().not_null()) + .col(ColumnDef::new(BootstrapTokens::CreatedAt).date_time().not_null()) + .col(ColumnDef::new(BootstrapTokens::ExpiresAt).date_time().not_null()) + .col(ColumnDef::new(BootstrapTokens::MaxUses).integer().not_null()) + .col(ColumnDef::new(BootstrapTokens::UseCount).integer().not_null().default(0)) + .col(ColumnDef::new(BootstrapTokens::Revoked).boolean().not_null().default(false)) + .col(ColumnDef::new(BootstrapTokens::RevokedAt).date_time().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(BootstrapTokens::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +enum BootstrapTokens { + Table, + Id, + Token, + Description, + CreatedBy, + CreatedAt, + ExpiresAt, + MaxUses, + UseCount, + Revoked, + RevokedAt, +} diff --git a/nixos-node/flake.nix b/nixos-node/flake.nix index e44f951..dbf719f 100644 --- a/nixos-node/flake.nix +++ b/nixos-node/flake.nix @@ -2,15 +2,30 @@ description = "CSF NixOS Node Configuration"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, rust-overlay }: let system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; - csfAgentPkg = pkgs.rustPlatform.buildRustPackage { + rustToolchain = pkgs.rust-bin.stable."1.88.0".default.override { + extensions = [ "rust-src" ]; + targets = [ "x86_64-unknown-linux-gnu" ]; + }; + + csfAgentPkg = (pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }).buildRustPackage { pname = "csf-agent"; version = "0.2.2"; src = ../.; @@ -21,6 +36,10 @@ }; csfDaemonModule = import ./modules/csf-daemon.nix; + + agentSpecialArgs = { + csf.agentPackage = csfAgentPkg; + }; in { nixosConfigurations = { @@ -29,9 +48,22 @@ modules = [ ./modules/iso-configuration.nix ]; }; + csf-node = nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = agentSpecialArgs; + modules = [ + csfDaemonModule + ./modules/node-configuration.nix + ]; + }; + csf-server = nixpkgs.lib.nixosSystem { inherit system; - modules = [ ./modules/server-configuration.nix ]; + specialArgs = agentSpecialArgs; + modules = [ + csfDaemonModule + ./modules/server-configuration.nix + ]; }; }; diff --git a/nixos-node/modules/csf-daemon.nix b/nixos-node/modules/csf-daemon.nix index d5ce327..ea86b10 100644 --- a/nixos-node/modules/csf-daemon.nix +++ b/nixos-node/modules/csf-daemon.nix @@ -2,6 +2,7 @@ let cfg = config.services.csf-daemon; + credentialsFile = "/var/lib/csf-daemon/credentials"; in { options.services.csf-daemon = { @@ -21,7 +22,7 @@ in registrationToken = lib.mkOption { type = lib.types.str; default = ""; - description = "One-time registration token. Consumed on first boot, ignored thereafter."; + description = "Cluster-wide bootstrap token (csf-bootstrap.*) or node-specific pre-register token (reg_*). Ignored once the agent is registered."; }; heartbeatInterval = lib.mkOption { @@ -60,9 +61,10 @@ in environment = { CSF_GATEWAY_URL = cfg.apiGateway; - CSF_REGISTRATION_TOKEN = cfg.registrationToken; CSF_HEARTBEAT_INTERVAL = toString cfg.heartbeatInterval; RUST_LOG = cfg.logLevel; + } // lib.optionalAttrs (cfg.registrationToken != "") { + CSF_REGISTRATION_TOKEN = cfg.registrationToken; }; serviceConfig = { @@ -73,7 +75,6 @@ in Group = "csf-daemon"; StateDirectory = "csf-daemon"; StateDirectoryMode = "0700"; - NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; diff --git a/nixos-node/modules/node-configuration.nix b/nixos-node/modules/node-configuration.nix index dbf4f10..021f9d3 100644 --- a/nixos-node/modules/node-configuration.nix +++ b/nixos-node/modules/node-configuration.nix @@ -1,7 +1,7 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, csf, ... }: { - system.stateVersion = "24.11"; + system.stateVersion = "25.05"; boot.loader.grub = { enable = true; @@ -22,6 +22,15 @@ services.openssh.enable = false; + services.csf-daemon = { + enable = true; + package = csf.agentPackage; + apiGateway = "http://gateway.csf.local:8000"; + registrationToken = "csf-bootstrap.change_me"; + heartbeatInterval = 60; + logLevel = "info"; + }; + nix = { settings = { experimental-features = [ "nix-command" "flakes" ]; diff --git a/nixos-node/modules/server-configuration.nix b/nixos-node/modules/server-configuration.nix index 89d1f58..9b9d1a0 100644 --- a/nixos-node/modules/server-configuration.nix +++ b/nixos-node/modules/server-configuration.nix @@ -1,135 +1,86 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, csf, ... }: +let + composeDir = "/etc/csf-core"; +in { - # System configuration - WICHTIG: Muss mit der ursprünglichen Installation übereinstimmen! system.stateVersion = "25.11"; - # Boot configuration boot = { loader.grub = { enable = true; device = "/dev/sda"; useOSProber = true; }; - - # Hardware-spezifische Einstellungen (von hardware-configuration.nix) initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "virtio_scsi" "sd_mod" "sr_mod" ]; - initrd.kernelModules = [ ]; - kernelModules = [ ]; - extraModulePackages = [ ]; + initrd.kernelModules = []; + kernelModules = []; + extraModulePackages = []; }; - # File Systems (von hardware-configuration.nix) fileSystems."/" = { device = "/dev/disk/by-uuid/e4b27226-e75f-4cef-9dec-fc0c6f2185ac"; fsType = "ext4"; }; - swapDevices = [ ]; + swapDevices = []; - # Platform nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - # Networking networking = { - hostName = "nixos"; # Match existing hostname - - # NetworkManager aktivieren (wie auf dem Zielsystem) + hostName = "csf-node"; networkmanager.enable = true; - firewall = { enable = true; - allowedTCPPorts = [ - 22 # SSH - 80 # HTTP - 443 # HTTPS - 8080 # Docker nginx test - 8000 # CSF-Core Backend - ]; + allowedTCPPorts = [ 22 8000 ]; }; }; - # Time zone - time.timeZone = "Europe/Berlin"; + time.timeZone = "UTC"; - # Locale settings - i18n.defaultLocale = "de_DE.UTF-8"; - i18n.extraLocaleSettings = { - LC_ADDRESS = "de_DE.UTF-8"; - LC_IDENTIFICATION = "de_DE.UTF-8"; - LC_MEASUREMENT = "de_DE.UTF-8"; - LC_MONETARY = "de_DE.UTF-8"; - LC_NAME = "de_DE.UTF-8"; - LC_NUMERIC = "de_DE.UTF-8"; - LC_PAPER = "de_DE.UTF-8"; - LC_TELEPHONE = "de_DE.UTF-8"; - LC_TIME = "de_DE.UTF-8"; - }; - - # Console keymap - console.keyMap = "de"; - - # X11 keymap - services.xserver.xkb = { - layout = "de"; - variant = ""; - }; - - # SSH Server für Remote-Zugriff services.openssh = { enable = true; settings = { - PermitRootLogin = "yes"; # Match existing config - PasswordAuthentication = true; # Match existing config + PermitRootLogin = "prohibit-password"; + PasswordAuthentication = false; }; }; - # Bestehenden User rootcsf übernehmen users.users.rootcsf = { isNormalUser = true; description = "rootcsf"; extraGroups = [ "networkmanager" "wheel" "docker" ]; - packages = with pkgs; []; }; - # Sudo ohne Passwort für wheel-Gruppe (für automatisiertes Deployment) security.sudo.wheelNeedsPassword = false; - # GnuPG Agent (wie auf dem Zielsystem) - programs.mtr.enable = true; - programs.gnupg.agent = { + virtualisation.docker = { enable = true; - enableSSHSupport = true; + enableOnBoot = true; }; - # Docker aktivieren - virtualisation.docker = { + services.csf-daemon = { enable = true; - enableOnBoot = true; + package = csf.agentPackage; + apiGateway = "http://localhost:8000"; + registrationToken = "csf-bootstrap.change_me"; + heartbeatInterval = 60; + logLevel = "info"; }; - # System packages environment.systemPackages = with pkgs; [ - # Docker tools docker-compose - - # System utilities curl wget vim htop git tmux - - # Debugging tools lsof - netcat - tcpdump ]; - # Docker Compose service for CSF-Core Backend - systemd.services.docker-compose-csf-backend = { - description = "Docker Compose CSF-Core Backend Service"; + systemd.services.csf-control-plane = { + description = "CSF Control Plane (Docker Compose)"; after = [ "docker.service" "network-online.target" ]; requires = [ "docker.service" ]; wants = [ "network-online.target" ]; @@ -138,112 +89,201 @@ serviceConfig = { Type = "oneshot"; RemainAfterExit = true; - WorkingDirectory = "/etc/csf-core"; - - # Start containers - ExecStart = "${pkgs.docker-compose}/bin/docker-compose up -d --remove-orphans"; - - # Stop containers gracefully - ExecStop = "${pkgs.docker-compose}/bin/docker-compose down"; - - # Timeout settings - TimeoutStartSec = "300"; + WorkingDirectory = composeDir; + ExecStartPre = "${pkgs.docker}/bin/docker compose pull --quiet"; + ExecStart = "${pkgs.docker}/bin/docker compose up -d --remove-orphans"; + ExecStop = "${pkgs.docker}/bin/docker compose down"; + TimeoutStartSec = "600"; TimeoutStopSec = "120"; }; }; - # Activation script to setup Docker Compose for CSF-Core Backend - system.activationScripts.docker-setup = { + system.activationScripts.csf-core-setup = { text = '' - # Create csf-core directory - mkdir -p /etc/csf-core - - # Create docker-compose.yml for CSF-Core Backend - cat > /etc/csf-core/docker-compose.yml <<'EOF' -version: '3.8' + mkdir -p ${composeDir} + cat > ${composeDir}/docker-compose.yml <<'COMPOSE' services: - postgres: - image: postgres:16-alpine - container_name: csf-postgres - environment: - POSTGRES_USER: csf - POSTGRES_PASSWORD: csfpassword - POSTGRES_DB: csf_core + etcd: + image: gcr.io/etcd-development/etcd:v3.5.21 + container_name: csf-etcd + command: + - etcd + - --advertise-client-urls=http://etcd:2379 + - --listen-client-urls=http://0.0.0.0:2379 + - --data-dir=/etcd-data volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" + - etcd_data:/etcd-data + networks: + - csf-internal restart: unless-stopped + + patroni: + image: ghcr.io/zalando/spilo-15:3.0-p1 + container_name: csf-patroni + hostname: patroni + environment: + PATRONI_NAME: patroni + PATRONI_SCOPE: postgres-csf + PATRONI_ETCD3_HOSTS: "etcd:2379" + PATRONI_ETCD3_PROTOCOL: http + PATRONI_POSTGRESQL_DATA_DIR: /home/postgres/pgdata + PATRONI_POSTGRESQL_LISTEN: "0.0.0.0:5432" + PATRONI_POSTGRESQL_CONNECT_ADDRESS: "patroni:5432" + PATRONI_REPLICATION_USERNAME: replicator + PATRONI_REPLICATION_PASSWORD: replpass + PATRONI_SUPERUSER_USERNAME: postgres + PATRONI_SUPERUSER_PASSWORD: postgrespass + PATRONI_RESTAPI_LISTEN: "0.0.0.0:8008" + PATRONI_RESTAPI_CONNECT_ADDRESS: "patroni:8008" + SPILO_CONFIGURATION: | + bootstrap: + initdb: + - auth-host: md5 + - auth-local: trust + post_bootstrap: /etc/csf-bootstrap.sh + volumes: + - patroni_data:/home/postgres/pgdata + - /etc/csf-core/patroni-bootstrap.sh:/etc/csf-bootstrap.sh:ro + networks: + - csf-internal + depends_on: + - etcd healthcheck: - test: ["CMD-SHELL", "pg_isready -U csf -d csf_core"] + test: ["CMD-SHELL", "curl -sf http://localhost:8008/health | grep -q running || exit 1"] interval: 10s timeout: 5s - retries: 5 + retries: 10 + start_period: 60s + restart: unless-stopped - backend: - image: ghcr.io/cs-foundry/csf-core-backend:latest - container_name: csf-backend + api-gateway: + image: ghcr.io/cs-foundry/csf-ce-api-gateway:0.2.2-alpha.353 + container_name: csf-api-gateway + environment: + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + RUST_LOG: info + JWT_SECRET: change_me_in_production + RSA_KEY_SIZE: "4096" + REGISTRY_SERVICE_URL: http://registry:8001 + SCHEDULER_SERVICE_URL: http://scheduler:8002 + VOLUME_MANAGER_URL: http://volume-manager:8003 + FAILOVER_CONTROLLER_URL: http://failover-controller:8004 + SDN_CONTROLLER_URL: http://sdn-controller:8005 ports: - "8000:8000" + depends_on: + patroni: + condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/system/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + registry: + image: ghcr.io/cs-foundry/csf-ce-registry:0.2.2-alpha.353 + container_name: csf-registry environment: - - RUST_LOG=debug - - DATABASE_URL=postgres://csf:csfpassword@postgres:5432/csf_core - - JWT_SECRET=supersecretkey_change_me_in_production - - FRONTEND_URL=http://localhost:3000 + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + REGISTRY_PORT: "8001" + RUST_LOG: info + SCHEDULER_SERVICE_URL: http://scheduler:8002 depends_on: - postgres: + patroni: condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + + scheduler: + image: ghcr.io/cs-foundry/csf-ce-scheduler:0.2.2-alpha.353 + container_name: csf-scheduler + environment: + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + SCHEDULER_PORT: "8002" + RUST_LOG: info + depends_on: + patroni: + condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + + volume-manager: + image: ghcr.io/cs-foundry/csf-ce-volume-manager:0.2.2-alpha.353 + container_name: csf-volume-manager + environment: + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + ETCD_ENDPOINTS: http://etcd:2379 + VOLUME_MANAGER_PORT: "8003" + RUST_LOG: info + volumes: + - /mnt/csf-volumes:/mnt/csf-volumes + depends_on: + patroni: + condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + + failover-controller: + image: ghcr.io/cs-foundry/csf-ce-failover-controller:0.2.2-alpha.353 + container_name: csf-failover-controller + environment: + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + FAILOVER_CONTROLLER_PORT: "8004" + SCHEDULER_SERVICE_URL: http://scheduler:8002 + VOLUME_MANAGER_URL: http://volume-manager:8003 + RUST_LOG: info + depends_on: + patroni: + condition: service_healthy + networks: + - csf-internal + restart: unless-stopped + + sdn-controller: + image: ghcr.io/cs-foundry/csf-ce-sdn-controller:0.2.2-alpha.353 + container_name: csf-sdn-controller + environment: + DATABASE_URL: postgres://csf:csfpassword@patroni:5432/csf_core + ETCD_URL: http://etcd:2379 + SDN_CONTROLLER_PORT: "8005" + RUST_LOG: info + depends_on: + patroni: + condition: service_healthy + networks: + - csf-internal restart: unless-stopped volumes: - postgres_data: -EOF + etcd_data: + patroni_data: - # Create test script - cat > /root/test-csf-backend.sh <<'EOF' +networks: + csf-internal: + driver: bridge +COMPOSE + + cat > ${composeDir}/patroni-bootstrap.sh <<'BOOTSTRAP' #!/bin/bash -echo "=== CSF-Core Backend Test ===" -echo "Hostname: $(hostname)" -echo "Date: $(date)" -echo "" -echo "Docker version:" -docker --version -echo "" -echo "Docker Compose version:" -docker-compose --version -echo "" -echo "Docker images:" -docker images -echo "" -echo "Running containers:" -docker ps -echo "" -echo "Docker Compose status:" -cd /etc/csf-core && docker-compose ps -echo "" -echo "=== Network Test ===" -echo "Testing backend API:" -curl -s http://localhost:8000/health || echo "Backend not responding" -echo "" -echo "Testing database connection:" -docker exec csf-postgres pg_isready -U csf -d csf_core || echo "Database not ready" -echo "" -echo "=== Test Complete ===" -EOF - chmod +x /root/test-csf-backend.sh +psql -U postgres -c "CREATE USER csf WITH PASSWORD 'csfpassword';" +psql -U postgres -c "CREATE DATABASE csf_core OWNER csf;" +psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE csf_core TO csf;" +BOOTSTRAP + chmod +x ${composeDir}/patroni-bootstrap.sh ''; deps = []; }; - # Automatic updates (optional, aber empfohlen) - system.autoUpgrade = { - enable = false; # Auf true setzen für automatische Updates - dates = "04:00"; - allowReboot = false; - }; - - # Nix settings nix = { settings = { experimental-features = [ "nix-command" "flakes" ];