From 66c80aa7775beaeef0880ded1051ddb0699d81ee Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 11:47:28 +0800 Subject: [PATCH 001/486] =?UTF-8?q?feat(awd):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=E5=94=AF=E4=B8=80=E5=85=A5=E5=8F=A3=20transition=5Fev?= =?UTF-8?q?ent=20=E4=B8=8E=20repo=20=E7=BA=A7=E5=AE=88=E5=8D=AB=EF=BC=88P0?= =?UTF-8?q?-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - event_repo 新增 transition_event:事务内 SELECT FOR UPDATE + 转移校验 + CAS(expected_status 不匹配拒绝)+ TransitionPatch 附属字段原子写入 - update_status/update_phase/mark_verified/clear_verified 全部加守卫, service 层禁裸写 status;event_service/deploy/archive/recovery/precheck /admin.update_network 收敛到 transition_event - 状态机表补 Configuring→Prechecking 路径(Phase 2 集成预期) - 修复 update_network 用外键当主键静默 0 行的历史 bug;quick_archive 同 - 新增 DB-gated 集成测试 awd_transition_guard(CAS/非法转移/原子字段) --- apps/api/src/bootstrap/mod.rs | 50 +-- apps/api/src/entity/awd_events.rs | 1 + apps/api/src/main.rs | 10 +- .../src/modules/event/awd_team/api/admin.rs | 34 +- .../modules/event/awd_team/api/internal.rs | 34 +- .../event/awd_team/domain/event_ext.rs | 106 +++++- .../infrastructure/network/runtime.rs | 6 +- .../modules/event/awd_team/repo/event_repo.rs | 311 +++++++++++++++--- .../event/awd_team/service/archive_service.rs | 27 +- .../event/awd_team/service/deploy_service.rs | 29 +- .../event/awd_team/service/event_service.rs | 83 +++-- .../service/network_policy_service.rs | 13 +- .../awd_team/service/precheck_service.rs | 66 +++- .../awd_team/service/recovery_service.rs | 29 +- .../awd_team/service/wireguard_service.rs | 2 +- .../event/awd_team/system/wireguard.rs | 2 +- apps/api/src/sql/merged.sql | 35 +- .../20260808093622-awd-paused-phase.sql | 16 + apps/api/tests/awd_transition_guard.rs | 291 ++++++++++++++++ apps/api/tests/internal_auth_contract.rs | 130 ++++++++ apps/web/src/entity/awd_events.ts | 1 + 21 files changed, 1136 insertions(+), 140 deletions(-) create mode 100644 apps/api/src/sql/migrations/20260808093622-awd-paused-phase.sql create mode 100644 apps/api/tests/awd_transition_guard.rs create mode 100644 apps/api/tests/internal_auth_contract.rs diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 147c2787..2a0f281a 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -26,11 +26,23 @@ use crate::{ pub use state::{AppState, AwdDependencies}; +/// 启动阶段致命错误(fail-fast)。 +/// +/// Phase 0 P0-2:AWD crypto 初始化失败不允许降级运行(历史存在全零密钥回退, +/// 攻击者可预测所有 token/flag)。`run()` 返回 Err 后由 `main` 打印原因并 `exit(1)`。 +#[derive(Debug, thiserror::Error)] +pub enum BootstrapError { + #[error("AWD crypto initialization failed: {0}")] + Crypto(String), + #[error(transparent)] + Io(#[from] std::io::Error), +} + /// Initialize and run the FloatCTF HTTP server. /// /// This is the single entry point for both the binary and integration tests. -/// Returns `Err` if initialization fails (database, Docker, or S3 connection). -pub async fn run() -> std::io::Result<()> { +/// Returns `Err` if initialization fails (database, Docker, S3, or AWD crypto). +pub async fn run() -> Result<(), BootstrapError> { // Load all process-static settings from TOML — fail fast before touching infrastructure. let config_path = std::env::var_os("FLOATCTF_CONFIG") .map(std::path::PathBuf::from) @@ -162,28 +174,15 @@ pub async fn run() -> std::io::Result<()> { let awd_containers: Arc = Arc::new(fcmc::DockerRuntime::new(docker.get_ref().clone())); - let awd_deps = match AwdCrypto::from_secret_bytes(config.auth.jwt_secret.as_bytes()) { - Ok(crypto) => web::Data::new(AwdDependencies { - crypto: Arc::new(crypto), - publisher: publisher.clone(), - containers: awd_containers.clone(), - network: awd_network.clone(), - }), - Err(e) => { - error!( - "Failed to initialize AWD crypto: {}. AWD features will be unavailable.", - e - ); - web::Data::new(AwdDependencies { - crypto: Arc::new(AwdCrypto::new( - crate::modules::event::awd_team::crypto::AwdSecret::new(vec![0u8; 32]), - )), - publisher: publisher.clone(), - containers: awd_containers, - network: awd_network, - }) - } - }; + let awd_deps = web::Data::new(AwdDependencies { + crypto: Arc::new( + AwdCrypto::from_secret_bytes(config.auth.jwt_secret.as_bytes()) + .map_err(|e| BootstrapError::Crypto(e.to_string()))?, + ), + publisher: publisher.clone(), + containers: awd_containers.clone(), + network: awd_network.clone(), + }); let ip = config.server.listen_ip.clone(); let port = config.server.listen_port; @@ -216,7 +215,8 @@ pub async fn run() -> std::io::Result<()> { }) .bind((ip, port))? .run() - .await + .await?; + Ok(()) } pub use routes::{configure_all_routes, configure_routes}; diff --git a/apps/api/src/entity/awd_events.rs b/apps/api/src/entity/awd_events.rs index 2fe7d05c..54720c36 100644 --- a/apps/api/src/entity/awd_events.rs +++ b/apps/api/src/entity/awd_events.rs @@ -59,6 +59,7 @@ pub struct Model { pub finished_at: Option, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, + pub paused_phase: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 1d56316b..83349c75 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,4 +1,12 @@ #[actix_web::main] async fn main() -> std::io::Result<()> { - floatctf::bootstrap::run().await + match floatctf::bootstrap::run().await { + Ok(()) => Ok(()), + Err(e) => { + // Phase 0 P0-2:启动失败(含 AWD crypto 初始化失败)必须 fail-fast 非 0 退出, + // 不允许带病继续服务。 + eprintln!("fatal: {e}"); + std::process::exit(1); + } + } } diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 110784f3..2abdfe13 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -530,7 +530,9 @@ pub async fn update_network( let mut active: crate::entity::awd_events::ActiveModel = crate::entity::awd_events::ActiveModel { - id: Set(event_id), + // 注意:必须用 awd_events 真实主键 awd_event.id(原实现误用外键 event_id, + // 条件匹配 0 行、字段更新被静默丢弃——Phase 0 修复)。 + id: Set(awd_event.id), ..Default::default() }; @@ -553,11 +555,33 @@ pub async fn update_network( active.judgeserver_ip = Set(ip.clone()); } - // Config change clears verified - active.verified_at = Set(None); - active.verified_revision = Set(None); - active.status = Set(crate::entity::sea_orm_active_enums::AwdEventStatus::Configuring); + // 状态机唯一入口(Phase 0):配置变更 → Configuring 并清除 verified 标记。 + if awd_event.status != crate::entity::sea_orm_active_enums::AwdEventStatus::Configuring { + event_repo::transition_event( + ctx.db.get_ref(), + awd_event.id, + awd_event.status.clone(), + crate::entity::sea_orm_active_enums::AwdEventStatus::Configuring, + event_repo::TransitionPatch::config_changed(), + ) + .await + .map_err(AppError::from)?; + } else { + // 已是 Configuring:仅清除 verified 标记(保持原语义)。 + let mut clear: crate::entity::awd_events::ActiveModel = + crate::entity::awd_events::ActiveModel { + id: Set(awd_event.id), + verified_at: Set(None), + verified_revision: Set(None), + ..Default::default() + }; + clear + .update(ctx.db.get_ref()) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + } + // 最后更新非状态字段(转移成功后)。 active .update(ctx.db.get_ref()) .await diff --git a/apps/api/src/modules/event/awd_team/api/internal.rs b/apps/api/src/modules/event/awd_team/api/internal.rs index 4c2d9f11..30129fe6 100644 --- a/apps/api/src/modules/event/awd_team/api/internal.rs +++ b/apps/api/src/modules/event/awd_team/api/internal.rs @@ -165,8 +165,11 @@ pub async fn judge_callback( let idempotency_key = cb.callback_id.clone(); - // Try to record score (will be ignored if duplicate due to unique constraint) - let _ = score_repo::create_score_event( + // 记录计分(Phase 0 P0-4:禁止静默吞错)。 + // 重复回调由 idempotency_key 唯一约束兜底 → 视为幂等成功; + // 其他失败显式返回错误,JudgeServer 重试(同 callback_id,P3-9), + // 完整的事务化/可重试设计见 Phase 3 P3-8。 + match score_repo::create_score_event( ctx.db.get_ref(), event_id, Some(task.round_id), @@ -179,12 +182,37 @@ pub async fn judge_callback( Some(task.template_id), Some("judge check"), ) - .await; + .await + { + Ok(_) => {} + Err(e) if is_duplicate_key(&e) => { + tracing::debug!( + "[Judge] duplicate score event (callback_id={}) — idempotent skip", + idempotency_key + ); + } + Err(e) => { + tracing::error!( + "[Judge] score write failed for task {} callback {}: {}", + cb.task_id, + idempotency_key, + e + ); + return Err(AppError::Internal(format!("score write failed: {e}"))); + } + } } UniResponse::ok_none().into() } +/// 判断数据库错误是否为唯一约束冲突(幂等重复回调)。 +/// 不依赖 sqlx 直接依赖:以错误文本中的 constraint code / 关键词判定。 +fn is_duplicate_key(e: &sea_orm::DbErr) -> bool { + let msg = e.to_string().to_lowercase(); + msg.contains("23505") || msg.contains("duplicate") +} + /// GET /internal/awd/events/{event_id}/health /// Requires any valid internal token (FlagServer or JudgeServer). #[actix_web::get("/internal/awd/events/{event_id}/health")] diff --git a/apps/api/src/modules/event/awd_team/domain/event_ext.rs b/apps/api/src/modules/event/awd_team/domain/event_ext.rs index c36d5ed0..a7b464d7 100644 --- a/apps/api/src/modules/event/awd_team/domain/event_ext.rs +++ b/apps/api/src/modules/event/awd_team/domain/event_ext.rs @@ -41,7 +41,9 @@ impl AwdEventStatusExt for AwdEventStatus { fn valid_transitions(&self) -> &'static [AwdEventStatus] { match self { Self::Draft => &[Self::Configuring], - Self::Configuring => &[Self::Deploying, Self::Draft], + // Phase 0 补充:配置变更后不重新部署、直接重检(Phase 2 §6 集成路径 + // Configuring→Prechecking→Verified→Running)。 + Self::Configuring => &[Self::Deploying, Self::Draft, Self::Prechecking], Self::Deploying => &[Self::Deployed, Self::DeployFailed], Self::Deployed => &[Self::Prechecking, Self::Configuring], Self::Prechecking => &[Self::Verified, Self::VerificationFailed, Self::Deployed], @@ -75,6 +77,9 @@ pub trait AwdPhaseExt { fn allows_flag_issue(&self) -> bool; fn allows_flag_submission(&self) -> bool; fn allows_judge(&self) -> bool; + /// phase 转移守卫(Phase 0):Pause 可从任意阶段进入;resume 只能回到 Hardening/Attack; + /// Hardening ↔ Attack 可互换;幂等重设允许。 + fn can_transition_to(&self, target: AwdPhase) -> Result<(), String>; } impl AwdPhaseExt for AwdPhase { @@ -89,6 +94,26 @@ impl AwdPhaseExt for AwdPhase { fn allows_judge(&self) -> bool { matches!(self, Self::Attack | Self::Hardening) } + + fn can_transition_to(&self, target: AwdPhase) -> Result<(), String> { + if self == &target { + return Ok(()); + } + match (self, &target) { + // 任意阶段可进入 Pause + (_, AwdPhase::Pause) => Ok(()), + // resume:Pause 回到比赛阶段 + (AwdPhase::Pause, AwdPhase::Hardening) | (AwdPhase::Pause, AwdPhase::Attack) => Ok(()), + // 轮次切换 + (AwdPhase::Hardening, AwdPhase::Attack) | (AwdPhase::Attack, AwdPhase::Hardening) => { + Ok(()) + } + _ => Err(format!( + "Invalid phase transition: {:?} -> {:?}", + self, target + )), + } + } } #[cfg(test)] @@ -151,4 +176,83 @@ mod tests { .unwrap(); assert_eq!(next, AwdEventStatus::Configuring); } + + #[test] + fn running_to_network_error_is_valid() { + assert!( + AwdEventStatus::Running + .can_transition_to(AwdEventStatus::NetworkError) + .is_ok() + ); + } + + #[test] + fn paused_to_network_error_is_invalid() { + assert!( + AwdEventStatus::Paused + .can_transition_to(AwdEventStatus::NetworkError) + .is_err() + ); + } + + #[test] + fn configuring_can_enter_prechecking() { + // Phase 0 补充路径:配置变更后无需重新部署即可重检。 + assert!( + AwdEventStatus::Configuring + .can_transition_to(AwdEventStatus::Prechecking) + .is_ok() + ); + } + + #[test] + fn draft_to_running_is_invalid() { + assert!( + AwdEventStatus::Draft + .can_transition_to(AwdEventStatus::Running) + .is_err() + ); + } + + #[test] + fn phase_guard_allows_round_switches() { + assert!( + AwdPhase::Hardening + .can_transition_to(AwdPhase::Attack) + .is_ok() + ); + assert!( + AwdPhase::Attack + .can_transition_to(AwdPhase::Hardening) + .is_ok() + ); + assert!(AwdPhase::Pause.can_transition_to(AwdPhase::Attack).is_ok()); + assert!( + AwdPhase::Pause + .can_transition_to(AwdPhase::Hardening) + .is_ok() + ); + } + + #[test] + fn phase_guard_allows_pause_from_any_phase() { + assert!( + AwdPhase::Hardening + .can_transition_to(AwdPhase::Pause) + .is_ok() + ); + assert!(AwdPhase::Attack.can_transition_to(AwdPhase::Pause).is_ok()); + assert!(AwdPhase::Pause.can_transition_to(AwdPhase::Pause).is_ok()); + } + + #[test] + fn phase_guard_rejects_nonsense() { + // Pause 只能回到 Hardening/Attack,不存在其他阶段;同阶段幂等允许。 + assert!( + AwdPhase::Hardening + .can_transition_to(AwdPhase::Hardening) + .is_ok() + ); + assert!(AwdPhase::Attack.can_transition_to(AwdPhase::Attack).is_ok()); + } } diff --git a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs index dceb3351..ec02052a 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs @@ -96,7 +96,10 @@ impl Default for HostNetworkRuntime { #[async_trait] impl AwdNetworkRuntime for HostNetworkRuntime { async fn ensure_wireguard(&self, desired: WireGuardDesiredState) -> AwdResult<()> { - // Idempotent-ish: try create; if exists, still set key/port/address best-effort. + // best-effort create:接口已存在时 create 会失败,属预期(幂等重入),忽略后继续配置; + // 其他失败也先继续(configure 会暴露真实问题)。 + // Phase 0 P0-4 吞错扫描:此处为有意幂等语义; + // WG 命令失败的严格失败模型见 Phase 1 P1-15(Active→Rotating→Revoked 生命周期)。 let _ = wireguard::create_interface( self.runner(), &desired.interface, @@ -105,7 +108,6 @@ impl AwdNetworkRuntime for HostNetworkRuntime { &desired.address, ) .await; - // create_interface fails if iface exists — ignore and continue with configure if needed. Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/repo/event_repo.rs b/apps/api/src/modules/event/awd_team/repo/event_repo.rs index 11f7f7bb..8069daf9 100644 --- a/apps/api/src/modules/event/awd_team/repo/event_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/event_repo.rs @@ -2,11 +2,83 @@ use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, - EntityTrait, QueryFilter, + EntityTrait, QueryFilter, QuerySelect, TransactionTrait, }; use uuid::Uuid; -use crate::entity::{awd_events, sea_orm_active_enums::AwdEventStatus}; +use crate::entity::{ + awd_events, + sea_orm_active_enums::{AwdEventStatus, AwdPhase}, +}; +use crate::modules::event::awd_team::{ + AwdError, AwdResult, + domain::{AwdEventStatusExt, AwdPhaseExt}, +}; + +/// 状态切换时需要与 `status` **同事务原子写入**的附属字段。 +/// +/// Phase 0 引入:`transition_event` 是 AWD event status 的**唯一修改入口**, +/// 任何附带字段(verified_* / paused_phase / finished_at / started_at …) +/// 都必须通过本结构体携带,禁止在 service 层先写 status 再补字段。 +#[derive(Debug, Default, Clone)] +pub struct TransitionPatch { + /// 与状态切换同时写入的 phase(如 Paused 时置 `Pause`、Running 时恢复前 phase)。 + pub phase: Option, + pub started_at: Option>, + pub verified_revision: Option, + pub verified_at: Option>, + /// 暂停前所处的比赛阶段(resume 时恢复,P0-1b 迁移新增列)。 + pub paused_phase: Option, + pub pause_remaining_secs: Option, + pub finished_at: Option>, + /// 置空 verified 标记(配置变更 / 重检时清除)。 + pub clear_verified: bool, +} + +impl TransitionPatch { + /// Verified:写入 verified_revision + verified_at(与状态同事务)。 + pub fn verified(revision: &str) -> Self { + Self { + verified_revision: Some(revision.to_string()), + verified_at: Some(chrono::Utc::now()), + ..Default::default() + } + } + + /// Finished:写入 finished_at(与状态同事务)。 + pub fn finished() -> Self { + Self { + finished_at: Some(chrono::Utc::now()), + ..Default::default() + } + } + + /// Paused:置 phase=Pause、记录暂停前 phase 与剩余秒数。 + pub fn paused(paused_from: AwdPhase, remaining_secs: i32) -> Self { + Self { + phase: Some(AwdPhase::Pause), + paused_phase: Some(paused_from), + pause_remaining_secs: Some(remaining_secs), + ..Default::default() + } + } + + /// Running:恢复 phase(resume/start 用)。 + pub fn running(phase: AwdPhase) -> Self { + Self { + phase: Some(phase), + ..Default::default() + } + } + + /// 配置变更:回到 Configuring 并清除 verified 标记。 + pub fn config_changed() -> Self { + Self { + clear_verified: true, + ..Default::default() + } + } +} /// Instantiated repository (preferred for new call sites). pub struct AwdEventRepository { @@ -37,12 +109,19 @@ impl AwdEventRepository { update_status(&self.db, id, status).await } - pub async fn update_phase( + pub async fn update_phase(&self, id: Uuid, phase: AwdPhase) -> Result<(), sea_orm::DbErr> { + update_phase(&self.db, id, phase).await + } + + /// 状态机唯一入口:CAS 语义 + 转移校验 + 附属字段原子写入(Phase 0)。 + pub async fn transition( &self, id: Uuid, - phase: crate::entity::sea_orm_active_enums::AwdPhase, - ) -> Result<(), sea_orm::DbErr> { - update_phase(&self.db, id, phase).await + expected_status: AwdEventStatus, + target_status: AwdEventStatus, + patch: TransitionPatch, + ) -> AwdResult<()> { + transition_event(&self.db, id, expected_status, target_status, patch).await } pub async fn mark_verified(&self, id: Uuid, revision: &str) -> Result<(), sea_orm::DbErr> { @@ -96,62 +175,206 @@ pub async fn find_active_events( .await } -pub async fn update_status( +/// 状态机唯一入口(Phase 0 核心)。 +/// +/// 语义: +/// 1. **锁**:事务内 `SELECT ... FOR UPDATE` 锁定目标行(嵌套事务自动退化为 savepoint)。 +/// 2. **转移合法性**:读锁内当前状态,经 `can_transition_to` 校验;非法跳变直接拒绝。 +/// 3. **CAS 并发保护**:当前状态必须等于调用方 `expected_status`,否则 `Conflict`。 +/// 两台 API 同时 finish/pause 时,后到者拿到锁后读到新状态 → 拒绝。 +/// 4. **附属字段原子写入**:`TransitionPatch` 所有字段与 status 在同一 ActiveModel UPDATE 中提交。 +pub async fn transition_event( db: &C, id: Uuid, - status: AwdEventStatus, -) -> Result<(), sea_orm::DbErr> { - let active: awd_events::ActiveModel = awd_events::ActiveModel { - id: Set(id), - status: Set(status), - ..Default::default() - }; - active.update(db).await?; + expected_status: AwdEventStatus, + target_status: AwdEventStatus, + patch: TransitionPatch, +) -> AwdResult<()> +where + C: ConnectionTrait + TransactionTrait + Send, +{ + use sea_orm::sea_query::LockType; + + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 1. 锁定目标行(防并发状态跳转;嵌套事务自动退化为 savepoint) + let event = awd_events::Entity::find_by_id(id) + .lock(LockType::Update) + .one(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + + // 2. 转移合法性 + event + .status + .can_transition_to(target_status.clone()) + .map_err(AwdError::InvalidState)?; + + // 3. CAS:当前状态必须等于调用方预期 + if event.status != expected_status { + let _ = txn.rollback().await; + return Err(AwdError::Conflict(format!( + "concurrent status transition: event {} expected {:?} but actual is {:?}", + id, expected_status, event.status + ))); + } + + // 4. ActiveModel 原子更新(enum 列由 Set 正确绑定 Postgres enum) + active_model_from_patch(id, target_status, patch) + .update(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; Ok(()) } -pub async fn update_phase( - db: &C, +/// 由 id/target/patch 构造 ActiveModel(未设字段 NotSet,UPDATE 不触碰)。 +fn active_model_from_patch( id: Uuid, - phase: crate::entity::sea_orm_active_enums::AwdPhase, -) -> Result<(), sea_orm::DbErr> { - let active: awd_events::ActiveModel = awd_events::ActiveModel { + target_status: AwdEventStatus, + patch: TransitionPatch, +) -> awd_events::ActiveModel { + let now_ts: chrono::DateTime = chrono::Utc::now().into(); + let mut active: awd_events::ActiveModel = awd_events::ActiveModel { id: Set(id), - phase: Set(phase), + status: Set(target_status), + updated_at: Set(now_ts), ..Default::default() }; - active.update(db).await?; - Ok(()) + if let Some(phase) = patch.phase { + active.phase = Set(phase); + } + if let Some(ts) = patch.started_at { + active.started_at = Set(Some(ts.into())); + } + if let Some(rev) = patch.verified_revision { + active.verified_revision = Set(Some(rev)); + } + if let Some(ts) = patch.verified_at { + active.verified_at = Set(Some(ts.into())); + } + if let Some(ph) = patch.paused_phase { + active.paused_phase = Set(Some(ph)); + } + if let Some(secs) = patch.pause_remaining_secs { + active.pause_remaining_secs = Set(Some(secs)); + } + if let Some(ts) = patch.finished_at { + active.finished_at = Set(Some(ts.into())); + } + if patch.clear_verified { + active.verified_at = Set(None); + active.verified_revision = Set(None); + } + active } -pub async fn mark_verified( +/// 守卫版 `update_status`:最终防线。 +/// +/// 即使上层 service 忘记走 `transition_event`,repo 层也会校验 +/// `can_transition_to` 并拒绝非法跳变(`DbErr::Custom`)。 +pub async fn update_status( db: &C, id: Uuid, - revision: &str, -) -> Result<(), sea_orm::DbErr> { - use chrono::Utc; - let active: awd_events::ActiveModel = awd_events::ActiveModel { - id: Set(id), - verified_at: Set(Some(Utc::now().into())), - verified_revision: Set(Some(revision.to_string())), - status: Set(AwdEventStatus::Verified), - ..Default::default() - }; - active.update(db).await?; - Ok(()) + status: AwdEventStatus, +) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + TransactionTrait + Send, +{ + let event = awd_events::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound("AWD event not found".to_string()))?; + + transition_event(db, id, event.status.clone(), status, Default::default()) + .await + .map_err(|e| sea_orm::DbErr::Custom(e.to_string())) } -pub async fn clear_verified( - db: &C, - id: Uuid, -) -> Result<(), sea_orm::DbErr> { +/// 守卫版 `update_phase`:phase 只能按 `AwdPhaseExt::can_transition_to` 转移。 +pub async fn update_phase(db: &C, id: Uuid, phase: AwdPhase) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + TransactionTrait + Send, +{ + use sea_orm::sea_query::LockType; + + let txn = db.begin().await?; + + let event = awd_events::Entity::find_by_id(id) + .lock(LockType::Update) + .one(&txn) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound("AWD event not found".to_string()))?; + + event + .phase + .can_transition_to(phase.clone()) + .map_err(sea_orm::DbErr::Custom)?; + + let now_ts: chrono::DateTime = chrono::Utc::now().into(); let active: awd_events::ActiveModel = awd_events::ActiveModel { id: Set(id), - verified_at: Set(None), - verified_revision: Set(None), - status: Set(AwdEventStatus::Configuring), + phase: Set(phase), + updated_at: Set(now_ts), ..Default::default() }; - active.update(db).await?; + active.update(&txn).await?; + + txn.commit().await?; Ok(()) } + +/// 守卫版 `mark_verified`:仅允许从 `Prechecking` 进入 `Verified`, +/// verified_revision + verified_at 与状态同事务写入。 +pub async fn mark_verified(db: &C, id: Uuid, revision: &str) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + TransactionTrait + Send, +{ + transition_event( + db, + id, + AwdEventStatus::Prechecking, + AwdEventStatus::Verified, + TransitionPatch::verified(revision), + ) + .await + .map_err(|e| sea_orm::DbErr::Custom(e.to_string())) +} + +/// 守卫版 `clear_verified`:仅允许从 `Verified` / `StartBlocked` 回到 `Configuring`。 +pub async fn clear_verified(db: &C, id: Uuid) -> Result<(), sea_orm::DbErr> +where + C: ConnectionTrait + TransactionTrait + Send, +{ + let event = awd_events::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound("AWD event not found".to_string()))?; + + if !matches!( + event.status, + AwdEventStatus::Verified | AwdEventStatus::StartBlocked + ) { + return Err(sea_orm::DbErr::Custom(format!( + "clear_verified requires Verified/StartBlocked, current is {:?}", + event.status + ))); + } + + transition_event( + db, + id, + event.status.clone(), + AwdEventStatus::Configuring, + TransitionPatch::config_changed(), + ) + .await + .map_err(|e| sea_orm::DbErr::Custom(e.to_string())) +} diff --git a/apps/api/src/modules/event/awd_team/service/archive_service.rs b/apps/api/src/modules/event/awd_team/service/archive_service.rs index 905ab91b..8b4a3311 100644 --- a/apps/api/src/modules/event/awd_team/service/archive_service.rs +++ b/apps/api/src/modules/event/awd_team/service/archive_service.rs @@ -118,10 +118,15 @@ pub async fn archive_event( } } - // 5. Mark as archived - event_repo::update_status(db, awd_event.id, AwdEventStatus::Archived) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // 5. Mark as archived(状态机唯一入口,Phase 0) + event_repo::transition_event( + db, + awd_event.id, + awd_event.status.clone(), + AwdEventStatus::Archived, + Default::default(), + ) + .await?; info!("[Archive] Event {} archived", event_id); Ok(()) @@ -129,8 +134,18 @@ pub async fn archive_event( /// Quick archive: just mark as archived without cleaning Docker (manual cleanup). pub async fn quick_archive(db: &DatabaseConnection, event_id: Uuid) -> AwdResult<()> { - event_repo::update_status(db, event_id, AwdEventStatus::Archived) + // 先按 event_id(外键)解析真实主键,再走状态机唯一入口(Phase 0)。 + let awd_event = event_repo::find_by_event_id(db, event_id) .await - .map_err(|e| AwdError::Database(e.to_string()))?; + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + event_repo::transition_event( + db, + awd_event.id, + awd_event.status.clone(), + AwdEventStatus::Archived, + Default::default(), + ) + .await?; Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/deploy_service.rs b/apps/api/src/modules/event/awd_team/service/deploy_service.rs index d9ddd66b..c139fe60 100644 --- a/apps/api/src/modules/event/awd_team/service/deploy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/deploy_service.rs @@ -58,9 +58,17 @@ pub async fn deploy_event( ))); } - event_repo::update_status(db, awd_event.id, AwdEventStatus::Deploying) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // 状态机唯一入口(Phase 0):仅当尚未处于 Deploying 时才发起转移(幂等重入)。 + if awd_event.status != AwdEventStatus::Deploying { + event_repo::transition_event( + db, + awd_event.id, + awd_event.status.clone(), + AwdEventStatus::Deploying, + Default::default(), + ) + .await?; + } // ── 1. Docker network ── let (docker_network_id, docker_network_name) = @@ -116,10 +124,15 @@ pub async fn deploy_event( // ── 7. Hardening firewall ── ensure_hardening_firewall(db, network, &awd_event, event_id).await?; - // ── 8. Deployed ── - event_repo::update_status(db, awd_event.id, AwdEventStatus::Deployed) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // ── 8. Deployed(状态机唯一入口)── + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Deploying, + AwdEventStatus::Deployed, + Default::default(), + ) + .await?; info!("[Deploy] Event {} fully deployed", event_id); Ok(()) @@ -495,7 +508,7 @@ async fn ensure_wireguard( (kp.private_key, kp.public_key) }; - let _ = public_key; // stored on awd_events for client configs + let _public_key = public_key; // stored on awd_events for client configs let server_addr = format!( "{}/{}", Ipv4Cidr::parse(&awd_event.wireguard_cidr) diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 229e2065..f1de0f9f 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -38,14 +38,19 @@ pub async fn start_event( )); } - // Start with hardening phase - event_repo::update_phase(db, awd_event.id, AwdPhase::Hardening) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - - event_repo::update_status(db, awd_event.id, AwdEventStatus::Running) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // Start with hardening phase + started_at,经状态机唯一入口(Phase 0) + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Verified, + AwdEventStatus::Running, + event_repo::TransitionPatch { + phase: Some(AwdPhase::Hardening), + started_at: Some(chrono::Utc::now()), + ..Default::default() + }, + ) + .await?; // Create the first round let now = chrono::Utc::now(); @@ -83,12 +88,11 @@ pub async fn pause_event( )); } - // Pause the active round - if let Some(round) = round_repo::find_active_round(db, event_id) + let now = chrono::Utc::now(); + let remaining = if let Some(round) = round_repo::find_active_round(db, event_id) .await .map_err(|e| AwdError::Database(e.to_string()))? { - let now = chrono::Utc::now(); let remaining = (round.scheduled_end_at.with_timezone(&chrono::Utc) - now) .num_seconds() .max(0) as i32; @@ -96,15 +100,20 @@ pub async fn pause_event( round_repo::pause_round(db, round.id, remaining) .await .map_err(|e| AwdError::Database(e.to_string()))?; - } - - event_repo::update_status(db, awd_event.id, AwdEventStatus::Paused) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + remaining + } else { + 0 + }; - event_repo::update_phase(db, awd_event.id, AwdPhase::Pause) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // 状态 + phase + paused_phase + pause_remaining_secs 同事务原子写入(Phase 0) + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Running, + AwdEventStatus::Paused, + event_repo::TransitionPatch::paused(awd_event.phase, remaining), + ) + .await?; network_policy_service::apply_phase_policy(db, network, event_id, AwdPhase::Pause).await?; @@ -153,16 +162,24 @@ pub async fn resume_event( .map_err(|e| AwdError::Database(e.to_string()))?; } - // Restore appropriate phase based on round phase or default to attack - event_repo::update_phase(db, awd_event.id, AwdPhase::Attack) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // Restore the pre-pause phase(paused_phase,Phase 0 P0-1b 迁移),缺省回退 Hardening 而非 Attack + let resume_phase = awd_event.paused_phase.unwrap_or(AwdPhase::Hardening); - event_repo::update_status(db, awd_event.id, AwdEventStatus::Running) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // 状态 + phase 同事务原子写入(Phase 0) + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Paused, + AwdEventStatus::Running, + event_repo::TransitionPatch { + phase: Some(resume_phase.clone()), + pause_remaining_secs: Some(0), + ..Default::default() + }, + ) + .await?; - network_policy_service::apply_phase_policy(db, network, event_id, AwdPhase::Attack).await?; + network_policy_service::apply_phase_policy(db, network, event_id, resume_phase).await?; Ok(()) } @@ -190,9 +207,15 @@ pub async fn finish_event(db: &DatabaseConnection, event_id: Uuid) -> AwdResult< .map_err(|e| AwdError::Database(e.to_string()))?; } - event_repo::update_status(db, awd_event.id, AwdEventStatus::Finished) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + // 状态 + finished_at 同事务原子写入(Phase 0) + event_repo::transition_event( + db, + awd_event.id, + awd_event.status.clone(), + AwdEventStatus::Finished, + event_repo::TransitionPatch::finished(), + ) + .await?; Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/network_policy_service.rs b/apps/api/src/modules/event/awd_team/service/network_policy_service.rs index 06439342..0eeaf3bb 100644 --- a/apps/api/src/modules/event/awd_team/service/network_policy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/network_policy_service.rs @@ -134,12 +134,21 @@ pub async fn apply_phase_policy( } // Conntrack flush on phase change so established flows cannot bypass new policy. - let _ = network + // Phase 0 P0-4:不再静默吞错;主策略已成功应用,此处失败显式记录。 + // 完整失败模型(reconcile 失败 → NetworkError Fail Closed)见 Phase 1/3。 + if let Err(e) = network .clear_event_connections(EventNetworkIdentity { event_id, gamebox_cidr: awd_event.gamebox_cidr.clone(), }) - .await; + .await + { + tracing::error!( + "[NetworkPolicy] conntrack cleanup failed for event {}: {}", + event_id, + e + ); + } Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/precheck_service.rs b/apps/api/src/modules/event/awd_team/service/precheck_service.rs index c2a37faa..deff0705 100644 --- a/apps/api/src/modules/event/awd_team/service/precheck_service.rs +++ b/apps/api/src/modules/event/awd_team/service/precheck_service.rs @@ -43,13 +43,47 @@ pub async fn run_precheck( .map_err(|e| AwdError::Database(e.to_string()))? .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; - if !awd_event.status.is_configurable() { + if !awd_event.status.is_configurable() && awd_event.status != AwdEventStatus::Prechecking { return Err(AwdError::InvalidState(format!( "Cannot precheck in {:?} status", awd_event.status ))); } + // 状态机唯一入口(Phase 0):进入 Prechecking。 + match &awd_event.status { + AwdEventStatus::Prechecking => {} + // 已 Verified 的手动重检:先清除 verified 标记回到 Configuring,再进入 Prechecking。 + AwdEventStatus::Verified => { + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Verified, + AwdEventStatus::Configuring, + event_repo::TransitionPatch::config_changed(), + ) + .await?; + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Configuring, + AwdEventStatus::Prechecking, + Default::default(), + ) + .await?; + } + other => { + event_repo::transition_event( + db, + awd_event.id, + other.clone(), + AwdEventStatus::Prechecking, + Default::default(), + ) + .await?; + } + } + // Create precheck run record let run = awd_precheck_runs::ActiveModel { id: Set(Uuid::new_v4()), @@ -151,14 +185,36 @@ pub async fn run_precheck( .map_err(|e| AwdError::Database(e.to_string()))?; if is_passed { - // Mark event as verified + // Mark event as verified(守卫版:要求当前 Prechecking,Phase 0) let revision = compute_revision(&awd_event); - event_repo::mark_verified(db, awd_event.id, &revision) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Prechecking, + AwdEventStatus::Verified, + event_repo::TransitionPatch::verified(&revision), + ) + .await?; info!("[Precheck] Event {} verified", event_id); } else { + // 失败:Prechecking → VerificationFailed(Phase 0) + // 记录失败状态的失败属 best-effort(显式告警,precheck run 记录已落库)。 + if let Err(e) = event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Prechecking, + AwdEventStatus::VerificationFailed, + Default::default(), + ) + .await + { + tracing::warn!( + "[Precheck] failed to record VerificationFailed for event {}: {}", + event_id, + e + ); + } info!( "[Precheck] Event {} failed precheck: {} errors", event_id, diff --git a/apps/api/src/modules/event/awd_team/service/recovery_service.rs b/apps/api/src/modules/event/awd_team/service/recovery_service.rs index 33d1a2e0..0d6a5572 100644 --- a/apps/api/src/modules/event/awd_team/service/recovery_service.rs +++ b/apps/api/src/modules/event/awd_team/service/recovery_service.rs @@ -176,12 +176,21 @@ async fn recover_event( // ── 3. Restore firewall for current phase ── network_policy_service::apply_phase_policy(db, network, event.event_id, event.phase.clone()) .await?; - let _ = network + // conntrack cleanup:策略已应用后尽力清理既有连接,失败显式记录(Phase 0 P0-4 吞错扫描)。 + // 注:Phase 1 nftables reconcile 落地后,conntrack 清理纳入 desired-state 操作的失败模型。 + if let Err(e) = network .clear_event_connections(EventNetworkIdentity { event_id: event.event_id, gamebox_cidr: event.gamebox_cidr.clone(), }) - .await; + .await + { + tracing::error!( + "[Recovery] conntrack cleanup failed for event {}: {}", + event.event_id, + e + ); + } recovered += 1; Ok(recovered) @@ -198,9 +207,21 @@ pub async fn handle_network_error( event_id, error_msg ); - event_repo::update_status(db, event_id, AwdEventStatus::NetworkError) + let awd_event = event_repo::find_by_event_id(db, event_id) .await - .map_err(|e| AwdError::Database(e.to_string()))?; + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + + // 状态机唯一入口(Phase 0):按当前状态发起 NetworkError 转移, + // 非法来源(如 Paused→NetworkError 不在转移表)直接拒绝,Fail Closed。 + event_repo::transition_event( + db, + awd_event.id, + awd_event.status.clone(), + AwdEventStatus::NetworkError, + Default::default(), + ) + .await?; if let Some(round) = crate::modules::event::awd_team::repo::round_repo::find_active_round(db, event_id) diff --git a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs index ab11e5e8..17562694 100644 --- a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs +++ b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs @@ -147,7 +147,7 @@ pub async fn ensure_peer_for_user( ) .await?; - let _ = network; + let _network = network; Ok((peer, kp.private_key)) } diff --git a/apps/api/src/modules/event/awd_team/system/wireguard.rs b/apps/api/src/modules/event/awd_team/system/wireguard.rs index b4e7a008..de669bac 100644 --- a/apps/api/src/modules/event/awd_team/system/wireguard.rs +++ b/apps/api/src/modules/event/awd_team/system/wireguard.rs @@ -88,7 +88,7 @@ pub async fn create_interface( /// Delete a WireGuard interface. pub async fn delete_interface(runner: &dyn CommandRunner, iface: &str) -> AwdResult<()> { - // Bring down first + // Bring down first(best-effort:接口可能已处于 down 状态,Phase 0 P0-4 吞错扫描)。 let _ = runner .run( "ip", diff --git a/apps/api/src/sql/merged.sql b/apps/api/src/sql/merged.sql index e67c8431..91df43aa 100644 --- a/apps/api/src/sql/merged.sql +++ b/apps/api/src/sql/merged.sql @@ -1,8 +1,8 @@ -- ================================================================================================ -- ================================================================================================ -- FloatCTF Merged Migrations --- Generated at: 2026-08-07 11:04:36 +0800 --- Migration count: 15 +-- Generated at: 2026-08-08 09:36:27 +0800 +-- Migration count: 16 -- ================================================================================================ -- ================================================================================================ @@ -1832,6 +1832,37 @@ COMMIT; -- ================================================================================================ +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808093622-awd-paused-phase.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808093622-awd-paused-phase +-- Created at: 2026-08-08 +-- ================================================================================ +-- Phase 0 P0-1b:resume 需要恢复暂停前的比赛阶段(原实现硬编码 Attack 是已知缺陷, +-- Phase 4 P4-8 依赖此列)。暂停时经 transition_event 原子写入 paused_phase。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_events" + ADD COLUMN IF NOT EXISTS "paused_phase" "awd_phase"; + +COMMENT ON COLUMN "awd_events"."paused_phase" IS '暂停前所处的比赛阶段(resume 时恢复,Phase 0 P0-1b 引入)'; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808093622-awd-paused-phase.sql +-- ================================================================================================ +-- ================================================================================================ + + -- ================================================================================================ -- ================================================================================================ -- End of FloatCTF Migrations diff --git a/apps/api/src/sql/migrations/20260808093622-awd-paused-phase.sql b/apps/api/src/sql/migrations/20260808093622-awd-paused-phase.sql new file mode 100644 index 00000000..72e1d87b --- /dev/null +++ b/apps/api/src/sql/migrations/20260808093622-awd-paused-phase.sql @@ -0,0 +1,16 @@ +-- ================================================================================ +-- Migration: 20260808093622-awd-paused-phase +-- Created at: 2026-08-08 +-- ================================================================================ +-- Phase 0 P0-1b:resume 需要恢复暂停前的比赛阶段(原实现硬编码 Attack 是已知缺陷, +-- Phase 4 P4-8 依赖此列)。暂停时经 transition_event 原子写入 paused_phase。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_events" + ADD COLUMN IF NOT EXISTS "paused_phase" "awd_phase"; + +COMMENT ON COLUMN "awd_events"."paused_phase" IS '暂停前所处的比赛阶段(resume 时恢复,Phase 0 P0-1b 引入)'; + +COMMIT; diff --git a/apps/api/tests/awd_transition_guard.rs b/apps/api/tests/awd_transition_guard.rs new file mode 100644 index 00000000..2c6f8d64 --- /dev/null +++ b/apps/api/tests/awd_transition_guard.rs @@ -0,0 +1,291 @@ +//! DB-gated integration tests for the AWD event state-machine guard (Phase 0 P0-1). +//! +//! These tests require a reachable PostgreSQL (soft-skip when unavailable), and run +//! entirely inside a rolled-back transaction so they never leave junk rows. +//! +//! Env: +//! - `DATABASE_URL` (default `postgres://postgres:postgres@127.0.0.1:5432/floatctf_db`) + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, TransactionTrait, +}; +use uuid::Uuid; + +use floatctf::entity::{ + awd_events, events, sea_orm_active_enums::AwdEventStatus, sea_orm_active_enums::AwdPhase, +}; +use floatctf::modules::event::awd_team::{AwdError, domain::AwdEventStatusExt, repo::event_repo}; + +fn db_url() -> String { + std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@127.0.0.1:5432/floatctf_db".into()) +} + +async fn connect_or_skip() -> Option { + match sea_orm::Database::connect(&db_url()).await { + Ok(db) => Some(db), + Err(e) => { + eprintln!("skip awd_transition_guard: DB unreachable ({e})"); + None + } + } +} + +fn base_awd_event(event_id: Uuid, tag: &str) -> awd_events::ActiveModel { + awd_events::ActiveModel { + id: Set(Uuid::new_v4()), + event_id: Set(event_id), + gamebox_cidr: Set("10.42.0.0/16".into()), + wireguard_cidr: Set("172.31.0.0/16".into()), + wireguard_interface_name: Set(format!("wg-test-{}", tag)), + wireguard_listen_port: Set(5_0000 + Uuid::new_v4().as_u128() as i32 % 1000), + flagserver_ip: Set("10.42.0.10".into()), + judgeserver_ip: Set("10.42.0.11".into()), + event_secret_ciphertext: Set(vec![1u8; 32]), + event_secret_nonce: Set(vec![2u8; 24]), + ..Default::default() + } +} + +async fn seed_event(txn: &sea_orm::DatabaseTransaction, tag: &str) -> (Uuid, awd_events::Model) { + let event_id = Uuid::new_v4(); + let parent = events::ActiveModel { + id: Set(event_id), + title: Set(format!("awd-transition-test-{tag}")), + start_time: Set(chrono::Utc::now().into()), + end_time: Set((chrono::Utc::now() + chrono::Duration::hours(1)).into()), + ..Default::default() + }; + parent.insert(txn).await.expect("insert parent events row"); + let model = base_awd_event(event_id, tag) + .insert(txn) + .await + .expect("insert awd_events row"); + (event_id, model) +} + +#[tokio::test] +async fn invalid_transition_rejected_by_guard() { + let Some(db) = connect_or_skip().await else { + return; + }; + let txn = db.begin().await.expect("begin"); + let (event_id, awd) = seed_event(&txn, "invalid").await; + + // draft -> running 非法(状态机表明确拒绝) + let err = event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Draft, + AwdEventStatus::Running, + Default::default(), + ) + .await + .expect_err("Draft->Running must be rejected"); + + assert!( + matches!(err, AwdError::InvalidState(_)), + "expected InvalidState, got {err:?}" + ); + + // DB 状态未被改动 + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, AwdEventStatus::Draft); + + // repo 层最终防线:裸 update_status 同样拒绝 + let err = event_repo::update_status(&txn, awd.id, AwdEventStatus::Running) + .await + .expect_err("update_status must guard invalid transitions"); + assert!(err.to_string().contains("Invalid transition")); + + txn.rollback().await.ok(); + let _ = event_id; +} + +#[tokio::test] +async fn valid_transition_with_patch_is_atomic() { + let Some(db) = connect_or_skip().await else { + return; + }; + let txn = db.begin().await.expect("begin"); + let (event_id, awd) = seed_event(&txn, "atomic").await; + + // 1. Draft -> Configuring + event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Draft, + AwdEventStatus::Configuring, + Default::default(), + ) + .await + .expect("Draft->Configuring valid"); + + // 2. Configuring -> Prechecking(Phase 0 补充路径) + event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Configuring, + AwdEventStatus::Prechecking, + Default::default(), + ) + .await + .expect("Configuring->Prechecking valid"); + + // 3. Prechecking -> Verified:verified_revision + verified_at 与状态同事务写入 + event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Prechecking, + AwdEventStatus::Verified, + event_repo::TransitionPatch::verified("rev-abc123"), + ) + .await + .expect("Prechecking->Verified valid"); + + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, AwdEventStatus::Verified); + assert_eq!(row.verified_revision.as_deref(), Some("rev-abc123")); + assert!( + row.verified_at.is_some(), + "verified_at must be written atomically" + ); + + // 4. Verified -> Running(phase 附属字段同事务) + event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Verified, + AwdEventStatus::Running, + event_repo::TransitionPatch::running(AwdPhase::Hardening), + ) + .await + .expect("Verified->Running valid"); + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, AwdEventStatus::Running); + assert_eq!(row.phase, AwdPhase::Hardening); + + // 5. Running -> Paused:paused_phase + pause_remaining_secs 同事务写入(P0-1b 列) + event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Running, + AwdEventStatus::Paused, + event_repo::TransitionPatch::paused(AwdPhase::Hardening, 120), + ) + .await + .expect("Running->Paused valid"); + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, AwdEventStatus::Paused); + assert_eq!(row.phase, AwdPhase::Pause); + assert_eq!(row.paused_phase, Some(AwdPhase::Hardening)); + assert_eq!(row.pause_remaining_secs, Some(120)); + + txn.rollback().await.ok(); + let _ = event_id; +} + +#[tokio::test] +async fn cas_semantics_rejects_stale_expected_status() { + let Some(db) = connect_or_skip().await else { + return; + }; + let txn = db.begin().await.expect("begin"); + let (event_id, awd) = seed_event(&txn, "cas").await; + + // 先推进到 Running(Paused 从 Running 合法可达) + for (from, to) in [ + (AwdEventStatus::Draft, AwdEventStatus::Configuring), + (AwdEventStatus::Configuring, AwdEventStatus::Prechecking), + (AwdEventStatus::Prechecking, AwdEventStatus::Verified), + (AwdEventStatus::Verified, AwdEventStatus::Running), + ] { + event_repo::transition_event(&txn, awd.id, from, to, Default::default()) + .await + .expect("forward transition valid"); + } + + // 并发场景:expected_status 过期(实际是 Running,却按 Verified 的预期提交) + let err = event_repo::transition_event( + &txn, + awd.id, + AwdEventStatus::Verified, + AwdEventStatus::Paused, + Default::default(), + ) + .await + .expect_err("stale expected status must be rejected"); + + assert!( + matches!(err, AwdError::Conflict(_)), + "expected Conflict for stale CAS, got {err:?}" + ); + + // 状态未被改变 + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.status, AwdEventStatus::Running); + + txn.rollback().await.ok(); + let _ = event_id; +} + +#[tokio::test] +async fn legal_transitions_smoke_across_table() { + let Some(db) = connect_or_skip().await else { + return; + }; + let txn = db.begin().await.expect("begin"); + let (event_id, awd) = seed_event(&txn, "legal").await; + + // 按状态机表全链路合法推进 + let chain = [ + (AwdEventStatus::Draft, AwdEventStatus::Configuring), + (AwdEventStatus::Configuring, AwdEventStatus::Deploying), + (AwdEventStatus::Deploying, AwdEventStatus::Deployed), + (AwdEventStatus::Deployed, AwdEventStatus::Prechecking), + (AwdEventStatus::Prechecking, AwdEventStatus::Verified), + (AwdEventStatus::Verified, AwdEventStatus::Running), + (AwdEventStatus::Running, AwdEventStatus::Paused), + (AwdEventStatus::Paused, AwdEventStatus::Running), + (AwdEventStatus::Running, AwdEventStatus::NetworkError), + (AwdEventStatus::NetworkError, AwdEventStatus::Paused), + (AwdEventStatus::Paused, AwdEventStatus::Finished), + (AwdEventStatus::Finished, AwdEventStatus::Archived), + ]; + for (from, to) in chain { + event_repo::transition_event(&txn, awd.id, from.clone(), to.clone(), Default::default()) + .await + .unwrap_or_else(|e| panic!("{from:?}->{to:?} must be valid: {e}")); + } + + // 终态无后继 + let row = awd_events::Entity::find_by_id(awd.id) + .one(&txn) + .await + .unwrap() + .unwrap(); + assert!(row.status.is_terminal()); + + txn.rollback().await.ok(); + let _ = event_id; +} diff --git a/apps/api/tests/internal_auth_contract.rs b/apps/api/tests/internal_auth_contract.rs new file mode 100644 index 00000000..d6259aa2 --- /dev/null +++ b/apps/api/tests/internal_auth_contract.rs @@ -0,0 +1,130 @@ +//! AWD internal API authentication baseline (Phase 0 P0-3). +//! +//! Two layers: +//! 1. **Source-scan assertion(始终执行)**:`api/internal.rs` 中每个 `#[post]`/`#[get]` +//! 路由 handler 的第一个参数必须是 `auth: AwdInternalAuth` —— 防止新增 internal 端点 +//! 忘记加服务身份认证。 +//! 2. **Live-API 断言(soft-skip)**:若 `FLOATCTF_API_BASE` 可达,无 token 访问三个 +//! `/internal/awd/*` 端点必须 401。 + +mod common; + +use common::{Method, Route, api_reachable, call, json_code}; +use std::path::Path; + +const INTERNAL_RS: &str = "src/modules/event/awd_team/api/internal.rs"; + +fn read_internal_source() -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(INTERNAL_RS); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// 提取所有路由属性行 + 紧随其后的 handler 签名,断言首参为 AwdInternalAuth。 +#[test] +fn every_internal_route_requires_internal_auth() { + let src = read_internal_source(); + let lines: Vec<&str> = src.lines().collect(); + + let mut checked = 0usize; + let mut i = 0usize; + while i < lines.len() { + let line = lines[i].trim(); + // 路由属性:#[post("...")] / #[get("...")] / #[actix_web::post(...)] / #[actix_web::get(...)] + let trimmed = line.trim_start_matches("#[actix_web::"); + let is_route = line.starts_with("#[post(") + || line.starts_with("#[get(") + || trimmed.starts_with("post(") + || trimmed.starts_with("get("); + if !is_route { + i += 1; + continue; + } + // 向后找 handler 签名行(可跳过 doc 注释) + let mut j = i + 1; + while j < lines.len() && (lines[j].trim().starts_with("///") || lines[j].trim().is_empty()) + { + j += 1; + } + // 属性行可能跨行(如 #[post("/...")] 单行——本仓库全部单行) + let sig = lines + .get(j) + .unwrap_or_else(|| panic!("no handler after route attr at line {}", i + 1)); + let sig = sig.trim(); + assert!( + sig.starts_with("pub async fn"), + "route at line {} must be followed by `pub async fn`, got: {sig}", + i + 1 + ); + + // 收集签名(可跨行)直到 `) ->`:首参必须是 AwdInternalAuth + let mut body = String::new(); + let mut k = j; + loop { + body.push_str(lines[k]); + body.push('\n'); + if lines[k].contains(") ->") || lines[k].contains(')') && lines[k].trim().ends_with(')') + { + break; + } + k += 1; + } + let start = body.find('(').expect("handler signature must have params"); + let body = &body[start + 1..]; + let params = body.split(')').next().unwrap_or("").trim(); + let first_param = params.split(',').next().map(|p| p.trim()).unwrap_or(""); + + assert!( + first_param.starts_with("auth: AwdInternalAuth") + || first_param.starts_with("_auth: AwdInternalAuth"), + "route at line {} (handler `{}`) first param must be `auth: AwdInternalAuth`, got `{first_param}`", + i + 1, + lines[j].trim() + ); + checked += 1; + i = k + 1; + } + + assert!( + checked >= 3, + "expected at least 3 internal routes (issue_flag / judge_callback / event_health), found {checked}" + ); +} + +/// 无 token 访问 internal 端点必须 401(live API soft-skip)。 +#[tokio::test] +async fn internal_endpoints_reject_missing_token() { + if !api_reachable().await { + return; + } + // 固定假 event id(合法 UUID 格式,不存在的赛事 → auth 先于业务返回 401) + const EP: &str = "/internal/awd/events/00000000-0000-0000-0000-000000000000"; + for (method, path) in [ + ( + Method::Post, + "/internal/awd/events/00000000-0000-0000-0000-000000000000/flags/issue", + ), + ( + Method::Post, + "/internal/awd/events/00000000-0000-0000-0000-000000000000/judge/callback", + ), + ( + Method::Get, + "/internal/awd/events/00000000-0000-0000-0000-000000000000/health", + ), + ] { + let _ = EP; + let route = Route { + method, + path, + auth: common::Auth::None, + json_body: false, + }; + let resp = call(&route, None).await; + let (status, code, body) = json_code(resp).await; + assert_eq!( + status, 401, + "internal endpoint {path} without token must be 401, got {status} body={body}" + ); + assert_eq!(code, Some(401), "business code for {path}, body={body}"); + } +} diff --git a/apps/web/src/entity/awd_events.ts b/apps/web/src/entity/awd_events.ts index 6d4edc34..5faefdb8 100644 --- a/apps/web/src/entity/awd_events.ts +++ b/apps/web/src/entity/awd_events.ts @@ -39,4 +39,5 @@ export type AwdEvents = { finished_at?: string; created_at: string; updated_at: string; + paused_phase?: AwdPhase; }; From 80e085113f32dd5b100776b42adde48a2c69dcc8 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 12:03:42 +0800 Subject: [PATCH 002/486] =?UTF-8?q?feat(awd):=20native=20nftables=20?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E5=BA=95=E5=BA=A7=EF=BC=88Phase=201=20?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 domain/firewall_state.rs:DesiredFirewallState 全局期望模型 - 新增 infrastructure/firewall/:FirewallRuntime trait + NftablesFirewallRuntime (nft -c 校验 + 原子 batch apply + verify;revision 注释 observability) + 纯函数渲染器(三阶段策略/IPv6 显式策略/banned set/O(N) 规则) + host env 探测与 capability 判定(缺 nftables → Unsupported,不 fallback) - 新增 service/firewall_service.rs:DB→Desired→全局 reconcile;revision 存 settings 表 - 删除旧 iptables 实现(system/firewall.rs / network_policy_service.rs / firewall_cmd) - 全链路接线:event/deploy/recovery/scheduler/admin/bootstrap 走 NftablesFirewallRuntime - 配置抽象 network_runtime=host/noop 取代 host_network(P1-17) --- apps/api/config/development.toml | 2 +- apps/api/src/bootstrap/mod.rs | 19 +- apps/api/src/bootstrap/scheduler.rs | 12 +- apps/api/src/bootstrap/state.rs | 5 +- apps/api/src/core/config.rs | 18 +- apps/api/src/infrastructure/settings.rs | 22 + .../src/modules/event/awd_team/api/admin.rs | 28 +- .../event/awd_team/domain/firewall_state.rs | 148 ++++++ .../src/modules/event/awd_team/domain/mod.rs | 2 + .../awd_team/infrastructure/firewall/env.rs | 100 ++++ .../awd_team/infrastructure/firewall/mod.rs | 79 ++++ .../infrastructure/firewall/nftables.rs | 389 +++++++++++++++ .../infrastructure/firewall/render.rs | 383 +++++++++++++++ .../event/awd_team/infrastructure/mod.rs | 2 + .../awd_team/infrastructure/network/mod.rs | 9 +- .../infrastructure/network/runtime.rs | 32 +- .../modules/event/awd_team/scheduler/mod.rs | 27 +- .../event/awd_team/service/deploy_service.rs | 34 +- .../event/awd_team/service/event_service.rs | 23 +- .../awd_team/service/firewall_service.rs | 229 +++++++++ .../src/modules/event/awd_team/service/mod.rs | 2 +- .../service/network_policy_service.rs | 207 -------- .../awd_team/service/recovery_service.rs | 32 +- .../awd_team/service/wireguard_service.rs | 2 +- .../modules/event/awd_team/system/command.rs | 35 -- .../modules/event/awd_team/system/firewall.rs | 443 ------------------ .../src/modules/event/awd_team/system/mod.rs | 3 +- 27 files changed, 1492 insertions(+), 795 deletions(-) create mode 100644 apps/api/src/modules/event/awd_team/domain/firewall_state.rs create mode 100644 apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs create mode 100644 apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs create mode 100644 apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs create mode 100644 apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs create mode 100644 apps/api/src/modules/event/awd_team/service/firewall_service.rs delete mode 100644 apps/api/src/modules/event/awd_team/service/network_policy_service.rs delete mode 100644 apps/api/src/modules/event/awd_team/system/firewall.rs diff --git a/apps/api/config/development.toml b/apps/api/config/development.toml index 6e79bbe3..bfd150c7 100644 --- a/apps/api/config/development.toml +++ b/apps/api/config/development.toml @@ -32,7 +32,7 @@ web_terminal = true unsafe_sql_admin = true [awd] -host_network = false +network_runtime = "noop" flagserver_image = "floatctf/awd-flagserver:latest" judgeserver_image = "floatctf/awd-judgeserver:latest" diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 2a0f281a..c73e3fe8 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -125,15 +125,30 @@ pub async fn run() -> Result<(), BootstrapError> { // AWD host network runtime (shared by HTTP + scheduler). let awd_network: Arc< dyn crate::modules::event::awd_team::infrastructure::network::AwdNetworkRuntime, - > = if config.awd.host_network { + > = if config.awd.network_runtime == "host" { info!("AWD host network enabled — using HostNetworkRuntime"); Arc::new( crate::modules::event::awd_team::infrastructure::network::HostNetworkRuntime::new(), ) } else { + info!("AWD host network disabled (network_runtime=noop) — NoopNetworkRuntime"); Arc::new(crate::modules::event::awd_team::infrastructure::network::NoopNetworkRuntime) }; + // AWD firewall runtime:唯一生产实现为 native nftables(Phase 1)。 + // Noop 仅用于 unit test / dev mock,且 Noop 永远不允许 Verified(Phase 2 双门禁)。 + let awd_firewall: Arc< + dyn crate::modules::event::awd_team::infrastructure::firewall::FirewallRuntime, + > = if config.awd.network_runtime == "host" { + info!("AWD firewall enabled — using NftablesFirewallRuntime"); + Arc::new( + crate::modules::event::awd_team::infrastructure::firewall::NftablesFirewallRuntime::new(), + ) + } else { + info!("AWD firewall disabled — using NoopFirewallRuntime (dev/mock only)"); + Arc::new(crate::modules::event::awd_team::infrastructure::firewall::NoopFirewallRuntime) + }; + // Initialize scheduler let task_scheduler = scheduler::build_task_scheduler( db.clone(), @@ -141,6 +156,7 @@ pub async fn run() -> Result<(), BootstrapError> { rustfs.clone(), log_service.clone(), awd_network.clone(), + awd_firewall.clone(), ) .await .expect("init startup handlers failed!"); @@ -182,6 +198,7 @@ pub async fn run() -> Result<(), BootstrapError> { publisher: publisher.clone(), containers: awd_containers.clone(), network: awd_network.clone(), + firewall: awd_firewall.clone(), }); let ip = config.server.listen_ip.clone(); diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index b6a48318..231e1429 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -7,9 +7,12 @@ use tracing::info; use crate::{ infrastructure::LogService, infrastructure::{WebDb, WebDocker, WebRustfs}, - modules::event::awd_team::scheduler::{ - AwdArchiveCleanupHandler, AwdAutoPrecheckHandler, AwdEventStartHandler, AwdRoundEndHandler, - AwdRoundGraceEndHandler, AwdRoundStartHandler, + modules::event::awd_team::{ + infrastructure::firewall::FirewallRuntime, + scheduler::{ + AwdArchiveCleanupHandler, AwdAutoPrecheckHandler, AwdEventStartHandler, + AwdRoundEndHandler, AwdRoundGraceEndHandler, AwdRoundStartHandler, + }, }, scheduler::{ CheckPracticeEventHandler, CleanRunningInstancesHandler, CleanUnusedRustFSFilesHandler, @@ -24,6 +27,7 @@ pub async fn build_task_scheduler( rustfs: WebRustfs, logger: LogService, network: Arc, + firewall: Arc, ) -> Result { let mut scheduler = TaskScheduler::new(db.clone(), docker.clone(), rustfs.clone(), logger); @@ -41,11 +45,13 @@ pub async fn build_task_scheduler( Arc::new(AwdEventStartHandler { db: db.clone(), network: network.clone(), + firewall: firewall.clone(), }), Arc::new(AwdRoundStartHandler { db: db.clone(), docker: docker.clone(), network: network.clone(), + firewall: firewall.clone(), }), Arc::new(AwdRoundEndHandler { db: db.clone() }), Arc::new(AwdRoundGraceEndHandler { db: db.clone() }), diff --git a/apps/api/src/bootstrap/state.rs b/apps/api/src/bootstrap/state.rs index afde25f4..99df66d1 100644 --- a/apps/api/src/bootstrap/state.rs +++ b/apps/api/src/bootstrap/state.rs @@ -13,6 +13,7 @@ use crate::infrastructure::logging::LogService; use crate::infrastructure::realtime::EventPublisher; use crate::modules::event::EventModuleRegistry; use crate::modules::event::awd_team::crypto::AwdCrypto; +use crate::modules::event::awd_team::infrastructure::firewall::FirewallRuntime; use crate::modules::event::awd_team::infrastructure::network::AwdNetworkRuntime; use crate::scheduler::TaskScheduler; use fcmc::AwdContainerRuntime; @@ -57,8 +58,10 @@ pub struct AwdDependencies { pub publisher: Arc, /// Docker-backed AWD container/network runtime. pub containers: Arc, - /// Host WireGuard / firewall / conntrack runtime. + /// Host WireGuard / conntrack runtime. pub network: Arc, + /// Native nftables firewall runtime(唯一生产实现,Phase 1)。 + pub firewall: Arc, } impl AppState { diff --git a/apps/api/src/core/config.rs b/apps/api/src/core/config.rs index c61f4cd1..692f3c30 100644 --- a/apps/api/src/core/config.rs +++ b/apps/api/src/core/config.rs @@ -75,7 +75,10 @@ pub struct PathConfig { pub struct AwdStaticConfig { /// Whether AWD crypto could be derived from the shared JWT secret material. pub crypto_from_app_secret: bool, - pub host_network: bool, + /// 网络 runtime 选择(P1-17):`host` = HostNetworkRuntime + NftablesFirewallRuntime; + /// `noop` = 仅 unit test / dev mock(Noop 永远不允许 Verified)。 + /// 取代旧 `host_network = true/false`;不新增 `firewall_backend` 开关。 + pub network_runtime: String, pub flagserver_image: String, pub judgeserver_image: String, } @@ -207,19 +210,24 @@ struct FeaturesToml { struct AwdToml { #[serde(default = "default_true")] crypto_from_app_secret: bool, - #[serde(default)] - host_network: bool, + /// 默认 `noop`(dev/CI 无特权环境);生产配置显式写 `host`。 + #[serde(default = "default_network_runtime")] + network_runtime: String, #[serde(default = "default_flagserver_image")] flagserver_image: String, #[serde(default = "default_judgeserver_image")] judgeserver_image: String, } +fn default_network_runtime() -> String { + "noop".to_string() +} + impl Default for AwdToml { fn default() -> Self { Self { crypto_from_app_secret: true, - host_network: false, + network_runtime: default_network_runtime(), flagserver_image: default_flagserver_image(), judgeserver_image: default_judgeserver_image(), } @@ -298,7 +306,7 @@ impl AppConfig { }, awd: AwdStaticConfig { crypto_from_app_secret: file.awd.crypto_from_app_secret, - host_network: file.awd.host_network, + network_runtime: file.awd.network_runtime, flagserver_image: file.awd.flagserver_image, judgeserver_image: file.awd.judgeserver_image, }, diff --git a/apps/api/src/infrastructure/settings.rs b/apps/api/src/infrastructure/settings.rs index a21ac3db..70f80e0c 100644 --- a/apps/api/src/infrastructure/settings.rs +++ b/apps/api/src/infrastructure/settings.rs @@ -195,6 +195,28 @@ pub async fn get_setting(db: &DbConn, key: &str) -> Result Result<(), anyhow::Error> { + settings::Entity::insert(settings::ActiveModel { + key: Set(key.to_string()), + value: Set(value.to_string()), + r#type: Set(SettingValueType::String), + description: Set("内部系统参数(自动维护)".to_string()), + ..Default::default() + }) + .on_conflict( + OnConflict::column(settings::Column::Key) + .update_column(settings::Column::Value) + .to_owned(), + ) + .exec(db) + .await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 2abdfe13..8a0f7410 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -111,8 +111,13 @@ pub async fn start_awd_event( path: web::Path, ) -> UniResult<()> { let event_id = path.into_inner(); - event_service::start_event(ctx.db.get_ref(), awd.network.as_ref(), event_id) - .await + event_service::start_event( + ctx.db.get_ref(), + awd.network.as_ref(), + awd.firewall.as_ref(), + event_id, + ) + .await .map_err(AppError::from)?; UniResponse::ok_none().into() } @@ -126,8 +131,13 @@ pub async fn pause_awd_event( path: web::Path, ) -> UniResult<()> { let event_id = path.into_inner(); - event_service::pause_event(ctx.db.get_ref(), awd.network.as_ref(), event_id) - .await + event_service::pause_event( + ctx.db.get_ref(), + awd.network.as_ref(), + awd.firewall.as_ref(), + event_id, + ) + .await .map_err(AppError::from)?; UniResponse::ok_none().into() } @@ -141,8 +151,13 @@ pub async fn resume_awd_event( path: web::Path, ) -> UniResult<()> { let event_id = path.into_inner(); - event_service::resume_event(ctx.db.get_ref(), awd.network.as_ref(), event_id) - .await + event_service::resume_event( + ctx.db.get_ref(), + awd.network.as_ref(), + awd.firewall.as_ref(), + event_id, + ) + .await .map_err(AppError::from)?; UniResponse::ok_none().into() } @@ -271,6 +286,7 @@ pub async fn deploy_awd_event( ctx.db.get_ref(), awd.containers.as_ref(), awd.network.as_ref(), + awd.firewall.as_ref(), awd.crypto.as_ref(), &ctx.config.awd, event_id, diff --git a/apps/api/src/modules/event/awd_team/domain/firewall_state.rs b/apps/api/src/modules/event/awd_team/domain/firewall_state.rs new file mode 100644 index 00000000..bae6ee2a --- /dev/null +++ b/apps/api/src/modules/event/awd_team/domain/firewall_state.rs @@ -0,0 +1,148 @@ +//! Desired firewall state model (Phase 1 P1-4, native nftables). +//! +//! DB 是 Desired State 的唯一事实源;`DesiredFirewallState` 是由 DB/配置推导出的 +//! 全局期望防火墙状态,经 `NftablesFirewallRuntime.reconcile()` 收敛到 +//! `table inet floatctf_awd`。 +//! +//! 设计约束(见 chore/plans/awd/01-phase1-network-recovery.md §5.16): +//! - 本模型是 conceptual 数据模型,Phase 1 实现 Rust 结构; +//! - FloatCTF 只管理 `table inet floatctf_awd`,禁止触碰 Docker/firewalld 规则。 + +use uuid::Uuid; + +use crate::entity::sea_orm_active_enums::AwdPhase; + +/// 全局期望防火墙状态:revision + 各赛事策略。 +#[derive(Debug, Clone, Default)] +pub struct DesiredFirewallState { + /// 策略版本:每次 Desired State 变化 +1;reconcile 成功后 + /// `observed_revision = desired_revision`(DB 是事实源,revision 存 DB)。 + pub revision: u64, + /// 进入防火墙 desired set 的赛事策略(全局重建,Event A 更新不影响 Event B)。 + pub events: Vec, +} + +impl DesiredFirewallState { + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn event_keys(&self) -> Vec<&str> { + self.events.iter().map(|e| e.event_key.as_str()).collect() + } +} + +/// 单个赛事的期望策略。 +#[derive(Debug, Clone)] +pub struct DesiredEventPolicy { + /// 短稳定事件 key(如 `ev_ab12cd34`),用于 nft 对象命名,禁止拼完整 UUID。 + pub event_key: String, + pub event_id: Uuid, + /// 当前比赛阶段(Hardening / Attack / Pause)。 + pub phase: AwdPhase, + /// 赛事 GameBox 网络(如 10.42.0.0/16)。 + pub gamebox_network: IpNet, + /// 赛事基础设施网络(FlagServer/JudgeServer 所在子网,如 10.42.0.0/24)。 + pub infrastructure_network: IpNet, + pub flagserver_ip: std::net::Ipv4Addr, + pub judgeserver_ip: std::net::Ipv4Addr, + /// 参赛队伍网络分配。 + pub teams: Vec, + /// 被 ban 的队伍(WG/GameBox 子网进入 banned set)。 + pub banned_teams: Vec, +} + +impl DesiredEventPolicy { + pub fn banned_subnets(&self) -> Vec { + self.teams + .iter() + .filter(|t| self.banned_teams.contains(&t.team_id)) + .map(|t| t.wireguard_network.as_str()) + .collect() + } +} + +/// 单个队伍的期望网络分配。 +#[derive(Debug, Clone)] +pub struct DesiredTeamPolicy { + pub team_id: Uuid, + /// 队伍 WireGuard 玩家子网(如 172.31.1.0/24)。 + pub wireguard_network: IpNet, + /// 队伍 GameBox 子网(如 10.42.1.0/24)。 + pub gamebox_network: IpNet, +} + +/// 网络(CIDR)类型:解析校验 + 确定性字符串表示。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpNet { + pub addr: std::net::Ipv4Addr, + pub prefix_len: u8, +} + +impl IpNet { + pub fn parse(s: &str) -> Result { + let (ip, prefix) = s + .split_once('/') + .ok_or_else(|| format!("invalid CIDR: {s}"))?; + let addr: std::net::Ipv4Addr = ip + .parse() + .map_err(|_| format!("invalid IPv4 in CIDR: {s}"))?; + let prefix_len: u8 = prefix + .parse() + .map_err(|_| format!("invalid prefix in CIDR: {s}"))?; + if prefix_len > 32 { + return Err(format!("invalid prefix length in CIDR: {s}")); + } + Ok(Self { addr, prefix_len }) + } + + pub fn as_str(&self) -> String { + format!("{}/{}", self.addr, self.prefix_len) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipnet_parse_roundtrip() { + let net = IpNet::parse("10.42.1.0/24").unwrap(); + assert_eq!(net.as_str(), "10.42.1.0/24"); + assert_eq!(net.prefix_len, 24); + } + + #[test] + fn ipnet_rejects_garbage() { + assert!(IpNet::parse("10.42.1.0").is_err()); + assert!(IpNet::parse("10.42.1.0/33").is_err()); + assert!(IpNet::parse("not-an-ip/24").is_err()); + } + + #[test] + fn banned_subnets_derived_from_teams() { + let team_a = DesiredTeamPolicy { + team_id: Uuid::new_v4(), + wireguard_network: IpNet::parse("172.31.1.0/24").unwrap(), + gamebox_network: IpNet::parse("10.42.1.0/24").unwrap(), + }; + let team_b = DesiredTeamPolicy { + team_id: Uuid::new_v4(), + wireguard_network: IpNet::parse("172.31.2.0/24").unwrap(), + gamebox_network: IpNet::parse("10.42.2.0/24").unwrap(), + }; + let event = DesiredEventPolicy { + event_key: "ev_ab12cd34".into(), + event_id: Uuid::new_v4(), + phase: AwdPhase::Attack, + gamebox_network: IpNet::parse("10.42.0.0/16").unwrap(), + infrastructure_network: IpNet::parse("10.42.0.0/24").unwrap(), + flagserver_ip: "10.42.0.10".parse().unwrap(), + judgeserver_ip: "10.42.0.11".parse().unwrap(), + teams: vec![team_a.clone(), team_b.clone()], + banned_teams: vec![team_a.team_id], + }; + let banned = event.banned_subnets(); + assert_eq!(banned, vec!["172.31.1.0/24"]); + } +} diff --git a/apps/api/src/modules/event/awd_team/domain/mod.rs b/apps/api/src/modules/event/awd_team/domain/mod.rs index 21c477ca..57e19d6a 100644 --- a/apps/api/src/modules/event/awd_team/domain/mod.rs +++ b/apps/api/src/modules/event/awd_team/domain/mod.rs @@ -10,6 +10,7 @@ //! Generated entity enums are re-exported here for call-site convenience; they must //! never be edited by hand (sea-orm-cli only). +pub mod firewall_state; pub mod flag; pub mod network; pub mod score; @@ -25,6 +26,7 @@ mod gamebox_ext; mod round_ext; pub use event_ext::*; +pub use firewall_state::{DesiredEventPolicy, DesiredFirewallState, DesiredTeamPolicy, IpNet}; pub use flag::{generate_flag, hash_flag, verify_flag}; pub use gamebox_ext::*; pub use network::Ipv4Cidr; diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs new file mode 100644 index 00000000..2f5dd617 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs @@ -0,0 +1,100 @@ +//! Host firewall environment discovery (Phase 1 P1-1/P1-12, Phase 2 P2-13 快照基础)。 +//! +//! 记录宿主 Netfilter/firewall 环境(非密钥,用于故障排查与 capability 判定)。 +//! 最终正确性由 Precheck 的 connectivity probe 验证(Phase 2),这里只做事实收集。 + +use crate::modules::event::awd_team::{ + AwdResult, + system::command::{CommandRunner, RealCommandRunner}, +}; + +/// 宿主防火墙环境快照(非密钥)。 +#[derive(Debug, Clone, Default)] +pub struct HostFirewallEnvironment { + pub nft_version: Option, + pub kernel_version: Option, + pub docker_firewall_backend: Option, + pub firewalld_active: bool, + pub iptables_frontend: Option, + pub notes: Vec, +} + +/// 宿主防火墙 capability 判定。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostNetworkCapability { + /// nft binary + nf_tables + 权限齐备,可以使用 NftablesFirewallRuntime。 + Supported, + /// 缺少 nftables 能力 —— 不允许 fallback iptables,返回 `HostNetworkUnsupported`。 + Unsupported(String), +} + +/// 探测宿主防火墙环境(只读命令,无副作用)。 +pub async fn discover_environment() -> HostFirewallEnvironment { + let runner = RealCommandRunner; + let mut env = HostFirewallEnvironment::default(); + + if let Ok(out) = runner.run("nft", &["--version".into()]).await { + if out.exit_code == 0 { + env.nft_version = Some(out.stdout.trim().to_string()); + } + } + + if let Ok(out) = runner.run("uname", &["-r".into()]).await { + if out.exit_code == 0 { + env.kernel_version = Some(out.stdout.trim().to_string()); + } + } + + if let Ok(out) = runner.run("iptables", &["--version".into()]).await { + if out.exit_code == 0 { + env.iptables_frontend = Some(out.stdout.trim().to_string()); + } + } + + if let Ok(out) = runner.run("firewall-cmd", &["--state".into()]).await { + env.firewalld_active = out.exit_code == 0 && out.stdout.trim() == "running"; + } + + env +} + +/// 判定宿主是否具备 native nftables 能力(P1-12)。 +/// +/// 判定项(§5.21): +/// 1. `nft` binary 存在; +/// 2. kernel nf_tables 可用(`nft list tables` 成功 → 有 netlink 权限); +/// 3. CAP_NET_ADMIN / root helper(通过第 2 项隐式验证)。 +/// +/// 缺任一 → `Unsupported`,**不自动 fallback iptables**。 +pub async fn check_host_capability() -> AwdResult { + let runner = RealCommandRunner; + + let version = runner + .run("nft", &["--version".into()]) + .await + .map_err(|e| crate::modules::event::awd_team::AwdError::Network(format!( + "nft binary unavailable: {e}" + )))?; + if version.exit_code != 0 { + return Ok(HostNetworkCapability::Unsupported(format!( + "nft binary present but --version failed (exit {})", + version.exit_code + ))); + } + + // 真正需要权限的调用:nft list tables 需要 nf_tables + CAP_NET_ADMIN + let probe = runner + .run("nft", &["list".into(), "tables".into()]) + .await + .map_err(|e| crate::modules::event::awd_team::AwdError::Network(format!( + "nft invocation failed: {e}" + )))?; + if probe.exit_code != 0 { + return Ok(HostNetworkCapability::Unsupported(format!( + "nft list tables failed (exit {}): {} — missing nf_tables or CAP_NET_ADMIN", + probe.exit_code, probe.stderr + ))); + } + + Ok(HostNetworkCapability::Supported) +} diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs new file mode 100644 index 00000000..55c3a389 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs @@ -0,0 +1,79 @@ +//! Firewall runtime abstraction (Phase 1 P1-3). +//! +//! 唯一生产实现为 native nftables(`NftablesFirewallRuntime`)。 +//! 不提供 application-facing 的 add/remove rule API —— application 只描述 +//! Desired State,runtime 决定如何收敛(global reconcile)。 + +pub mod env; +pub mod nftables; +pub mod render; + +pub use env::HostFirewallEnvironment; +pub use nftables::NftablesFirewallRuntime; +pub use render::{NftObjectName, ObservedFirewallState, TABLE_NAME}; + +use async_trait::async_trait; + +use crate::modules::event::awd_team::{ + AwdResult, domain::firewall_state::DesiredFirewallState, +}; + +/// 一次 firewall reconcile 的结果。 +#[derive(Debug, Clone)] +pub struct FirewallApplyResult { + pub revision: u64, + pub applied: bool, +} + +/// 防火墙验证结论。 +#[derive(Debug, Clone)] +pub struct FirewallVerification { + pub verified: bool, + pub observed: ObservedFirewallState, + pub notes: Vec, +} + +/// Firewall runtime 抽象(Phase 1 P1-3)。 +/// +/// 三个方法均为 desired-state 语义: +/// - `inspect`:观测 `table inet floatctf_awd` 当前状态; +/// - `reconcile`:把全局 DesiredFirewallState 原子收敛到 nftables(`nft -c` 校验 + `nft -f` 应用 + verify); +/// - `verify`:校验当前观测状态与期望一致(revision 匹配等)。 +#[async_trait] +pub trait FirewallRuntime: Send + Sync { + async fn inspect(&self) -> AwdResult; + async fn reconcile( + &self, + desired: &DesiredFirewallState, + ) -> AwdResult; + async fn verify(&self, desired: &DesiredFirewallState) -> AwdResult; +} + +/// 无操作 runtime:仅用于 unit test / dev mock。 +/// **Noop 永远不允许 Verified**(Phase 2 双门禁 + P1-12 host capability)。 +pub struct NoopFirewallRuntime; + +#[async_trait] +impl FirewallRuntime for NoopFirewallRuntime { + async fn inspect(&self) -> AwdResult { + Ok(ObservedFirewallState::default()) + } + + async fn reconcile( + &self, + _desired: &DesiredFirewallState, + ) -> AwdResult { + Ok(FirewallApplyResult { + revision: 0, + applied: false, + }) + } + + async fn verify(&self, _desired: &DesiredFirewallState) -> AwdResult { + Ok(FirewallVerification { + verified: false, + observed: ObservedFirewallState::default(), + notes: vec!["NoopFirewallRuntime never verifies".into()], + }) + } +} diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs new file mode 100644 index 00000000..59722380 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs @@ -0,0 +1,389 @@ +//! NftablesFirewallRuntime — FloatCTF AWD 唯一生产 firewall runtime(Phase 1 P1-3/5/9)。 +//! +//! 只管理 FloatCTF 自己拥有的 `table inet floatctf_awd`: +//! - 禁止 `nft flush ruleset` +//! - 禁止修改 Docker/firewalld/libvirt/其他程序 tables +//! - 完整 reconcile = render 整个 table → `nft -c` 校验 → `nft -f` 原子应用 → verify +//! - 失败 Fail Closed(调用方据此置 NetworkError) +//! +//! 所有命令走统一 privileged command abstraction(structured argv,禁 shell 拼接)。 + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::modules::event::awd_team::{ + AwdError, AwdResult, + domain::firewall_state::DesiredFirewallState, + system::command::{CommandRunner, RealCommandRunner}, +}; + +use super::{ + FirewallApplyResult, FirewallRuntime, FirewallVerification, ObservedFirewallState, + TABLE_NAME, render, +}; + +/// nft 可执行文件名。 +const NFT_BIN: &str = "nft"; + +/// 生产实现:native nftables。 +pub struct NftablesFirewallRuntime { + runner: Arc, +} + +impl NftablesFirewallRuntime { + pub fn new() -> Self { + Self { + runner: Arc::new(RealCommandRunner), + } + } + + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } + + /// `nft -f `:原子应用。 + async fn apply_file(&self, path: &str) -> AwdResult<()> { + let out = self + .runner + .run(NFT_BIN, &["-f".to_string(), path.to_string()]) + .await + .map_err(|e| AwdError::Network(format!("nft apply failed to run: {e}")))?; + if out.exit_code != 0 { + return Err(AwdError::Network(format!( + "nft -f failed (exit {}): {}", + out.exit_code, out.stderr + ))); + } + Ok(()) + } + + /// `nft -c -f `:语法校验(不改变规则)。 + async fn check_file(&self, path: &str) -> AwdResult<()> { + let out = self + .runner + .run( + NFT_BIN, + &[ + "-c".to_string(), + "-f".to_string(), + path.to_string(), + ], + ) + .await + .map_err(|e| AwdError::Network(format!("nft -c failed to run: {e}")))?; + if out.exit_code != 0 { + return Err(AwdError::Network(format!( + "nft -c syntax check failed (exit {}): {}", + out.exit_code, out.stderr + ))); + } + Ok(()) + } + + /// `nft list table inet floatctf_awd`:观测当前状态。 + async fn list_table(&self) -> AwdResult { + let out = self + .runner + .run( + NFT_BIN, + &[ + "list".to_string(), + "table".to_string(), + "inet".to_string(), + TABLE_NAME.to_string(), + ], + ) + .await + .map_err(|e| AwdError::Network(format!("nft list failed to run: {e}")))?; + if out.exit_code != 0 { + // table 不存在时 nft 返回非 0 —— 视为空观测 + return Ok(String::new()); + } + Ok(out.stdout) + } +} + +impl Default for NftablesFirewallRuntime { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl FirewallRuntime for NftablesFirewallRuntime { + async fn inspect(&self) -> AwdResult { + let out = self.list_table().await?; + let mut state = render::parse_observed_table(&out); + if !state.table_exists && !out.trim().is_empty() { + // 非空输出但无我们的 table —— 观测异常(他方规则存在,我们无权处理) + state.notes.push("nft list returned output without floatctf_awd table".into()); + } + Ok(state) + } + + async fn reconcile( + &self, + desired: &DesiredFirewallState, + ) -> AwdResult { + // 1. render 完整 table(整个 floatctf_awd 内容) + let ruleset = render::render_table(desired); + let tmp = tempfile::NamedTempFile::new() + .map_err(|e| AwdError::Network(format!("nft temp file: {e}")))?; + std::fs::write(tmp.path(), &ruleset) + .map_err(|e| AwdError::Network(format!("nft ruleset write: {e}")))?; + let path = tmp + .path() + .to_str() + .ok_or_else(|| AwdError::Network("temp path not utf8".into()))? + .to_string(); + + // 2. 语法校验(nft -c)——失败不改动数据面 + self.check_file(&path).await?; + + // 3. 原子应用(nft -f):先删除自有 table 再整表重建(唯一允许的 table 级操作) + let delete = format!("delete table inet {TABLE_NAME}\n"); + let delete_tmp = tempfile::NamedTempFile::new() + .map_err(|e| AwdError::Network(format!("nft temp file: {e}")))?; + std::fs::write(delete_tmp.path(), &delete) + .map_err(|e| AwdError::Network(format!("nft ruleset write: {e}")))?; + let delete_path = delete_tmp + .path() + .to_str() + .ok_or_else(|| AwdError::Network("temp path not utf8".into()))? + .to_string(); + + // 3a. 若 table 已存在则删除(仅自有 table,所有权铁律 §4 允许) + let observed = self.inspect().await?; + if observed.table_exists { + self.apply_file(&delete_path).await?; + } + // 3b. 整表重建(原子) + self.apply_file(&path).await?; + + // 4. verify:revision 落定 + let verified = self.verify(desired).await?; + if !verified.verified { + return Err(AwdError::Network(format!( + "nft reconcile verify failed: {}", + verified.notes.join("; ") + ))); + } + + Ok(FirewallApplyResult { + revision: desired.revision, + applied: true, + }) + } + + async fn verify(&self, desired: &DesiredFirewallState) -> AwdResult { + let out = self.list_table().await?; + let observed = render::parse_observed_table(&out); + let mut notes = Vec::new(); + + if !observed.table_exists { + notes.push("table inet floatctf_awd missing".into()); + } + if observed.observed_revision != Some(desired.revision) { + notes.push(format!( + "revision mismatch: desired={} observed={:?}", + desired.revision, observed.observed_revision + )); + } + for event in &desired.events { + let chain = format!("event_{}", render::NftObjectName::event_key(&event.event_id).as_str()); + if !observed.event_chains.contains(&chain) { + notes.push(format!("missing chain {chain}")); + } + } + + Ok(FirewallVerification { + verified: notes.is_empty(), + observed, + notes, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::event::awd_team::{ + domain::firewall_state::{DesiredEventPolicy, DesiredTeamPolicy, IpNet}, + system::command::{CommandOutput, RecordingCommandRunner}, + }; + use uuid::Uuid; + + fn sample_desired(revision: u64) -> DesiredFirewallState { + DesiredFirewallState { + revision, + events: vec![DesiredEventPolicy { + event_key: "ev_test".into(), + event_id: Uuid::new_v4(), + phase: crate::entity::sea_orm_active_enums::AwdPhase::Hardening, + gamebox_network: IpNet::parse("10.42.0.0/16").unwrap(), + infrastructure_network: IpNet::parse("10.42.0.0/24").unwrap(), + flagserver_ip: "10.42.0.10".parse().unwrap(), + judgeserver_ip: "10.42.0.11".parse().unwrap(), + teams: vec![DesiredTeamPolicy { + team_id: Uuid::new_v4(), + wireguard_network: IpNet::parse("172.31.1.0/24").unwrap(), + gamebox_network: IpNet::parse("10.42.1.0/24").unwrap(), + }], + banned_teams: vec![], + }], + } + } + + /// 模拟 nft 行为:list 返回上次 apply 的 ruleset;-c/-f 成功。 + #[derive(Clone)] + struct FakeNftRunner { + store: std::sync::Arc>>, + } + + #[async_trait] + impl CommandRunner for FakeNftRunner { + async fn run( + &self, + program: &str, + args: &[String], + ) -> anyhow::Result { + assert_eq!(program, "nft"); + if args.first().map(|s| s.as_str()) == Some("list") { + let out = self + .store + .lock() + .unwrap() + .clone() + .unwrap_or_default(); + return Ok(CommandOutput { + exit_code: if out.is_empty() { 1 } else { 0 }, + stdout: out, + stderr: String::new(), + }); + } + if args.contains(&"-f".to_string()) { + let idx = args.iter().position(|a| a == "-f").unwrap() + 1; + let path = &args[idx]; + let content = std::fs::read_to_string(path).unwrap(); + let mut store = self.store.lock().unwrap(); + if args.contains(&"-c".to_string()) { + // 校验模式不改 store + return Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }); + } + *store = Some(content); + return Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }); + } + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + + #[tokio::test] + async fn reconcile_renders_applies_and_verifies() { + let runner = Arc::new(FakeNftRunner { + store: Default::default(), + }); + let rt = NftablesFirewallRuntime::with_runner(runner.clone()); + + let desired = sample_desired(11); + let res = rt.reconcile(&desired).await.expect("reconcile ok"); + assert!(res.applied); + assert_eq!(res.revision, 11); + + // verify 通过:revision 一致 + event chain 存在 + let v = rt.verify(&desired).await.unwrap(); + assert!(v.verified, "notes: {:?}", v.notes); + + // 观测状态解析正确 + let obs = rt.inspect().await.unwrap(); + assert!(obs.table_exists); + assert_eq!(obs.observed_revision, Some(11)); + } + + #[tokio::test] + async fn reconcile_verify_fails_on_revision_mismatch() { + let runner = Arc::new(FakeNftRunner { + store: Default::default(), + }); + let rt = NftablesFirewallRuntime::with_runner(runner.clone()); + + let desired = sample_desired(11); + rt.reconcile(&desired).await.unwrap(); + + // 期望 revision 变化但 store 未变 → verify 失败 + let desired2 = sample_desired(12); + let v = rt.verify(&desired2).await.unwrap(); + assert!(!v.verified); + } + + #[tokio::test] + async fn reconcile_uses_check_before_apply() { + // 记录命令序列:必须 -c 在 -f 之前 + let recorder = Arc::new(RecordingCommandRunner::new()); + let rt = NftablesFirewallRuntime::with_runner(recorder.clone()); + // 无 FakeNft 的 store —— RecordingCommandRunner 全部返回成功; + // verify 会失败(store 为空),但先验证命令序列。 + let desired = sample_desired(1); + let _ = rt.reconcile(&desired).await; + + let cmds = recorder.recorded(); + let nft_cmds: Vec<(String, Vec)> = cmds + .into_iter() + .filter(|(p, _)| p == "nft") + .collect(); + // list → (-c -f) → delete(-f) → (-f) → list → list + let check_idx = nft_cmds + .iter() + .position(|(_, a)| a.first().map(|s| s.as_str()) == Some("-c")); + let apply_idx = nft_cmds + .iter() + .rposition(|(_, a)| { + a.first().map(|s| s.as_str()) == Some("-f") + }); + assert!(check_idx.is_some(), "must run nft -c first"); + assert!(apply_idx.is_some(), "must run nft -f"); + assert!(check_idx.unwrap() < apply_idx.unwrap()); + } + + #[tokio::test] + async fn check_failure_does_not_apply() { + struct FailCheck; + #[async_trait] + impl CommandRunner for FailCheck { + async fn run( + &self, + _program: &str, + args: &[String], + ) -> anyhow::Result { + if args.contains(&"-c".to_string()) { + return Ok(CommandOutput { + exit_code: 1, + stdout: String::new(), + stderr: "syntax error at line 1".into(), + }); + } + Ok(CommandOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + }) + } + } + let rt = NftablesFirewallRuntime::with_runner(Arc::new(FailCheck)); + let err = rt.reconcile(&sample_desired(1)).await.expect_err("must fail"); + assert!(err.to_string().contains("syntax")); + } +} diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs new file mode 100644 index 00000000..f2546f5f --- /dev/null +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs @@ -0,0 +1,383 @@ +//! Pure nftables ruleset renderer (Phase 1 P1-8). +//! +//! `DesiredFirewallState` → `table inet floatctf_awd` 完整规则文本。 +//! 纯函数、无 IO、无特权命令 —— 全部渲染逻辑可单测断言。 +//! +//! # 设计原则(chore/plans/awd/01-phase1-network-recovery.md) +//! +//! - base chain `policy accept`:FloatCTF 只做 **restrictive DROP**, +//! 不主动 ACCEPT 业务流量(own-team 放行规则是 hardening 隔离的必要精确表达)。 +//! - 只用 sets,不生成 per-team ban chains;禁 `nft flush ruleset`。 +//! - `inet` family 统一 IPv4/IPv6;IPv6 显式策略占位(AWD v4-only 部署,管理范围默认 DROP)。 +//! - 规则规模 O(N)(N = 队伍数),不生成 O(N²) 的 Team A→Team B 规则。 + +use std::collections::BTreeSet; + +use sha2::{Digest, Sha256}; + +use crate::entity::sea_orm_active_enums::AwdPhase; + +use crate::modules::event::awd_team::domain::firewall_state::{DesiredEventPolicy, DesiredFirewallState}; + +/// FloatCTF 唯一拥有的 nftables table。 +pub const TABLE_NAME: &str = "floatctf_awd"; +/// forward hook 上 FloatCTF 的 priority(平台技术常量,P1-1 决定;见 docs/awd-nftables-host-discovery.md)。 +pub const FORWARD_PRIORITY: i32 = 1; + +/// 短稳定 nft 对象名(P1-5/§5.18)。 +/// +/// 规则:lowercase + 固定前缀 + 长度有界 + 确定性生成;禁止拼完整 event UUID。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NftObjectName(String); + +impl NftObjectName { + /// `ev_` + event_id SHA-256 前 8 个 hex 字符。 + pub fn event_key(event_id: &uuid::Uuid) -> Self { + let hash = Sha256::digest(event_id.as_bytes()); + Self(format!("ev_{}", hex::encode(&hash[..4]))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn fmt_set(name: &str, elements: &BTreeSet) -> String { + let mut out = format!(" set {name} {{\n type ipv4_addr\n flags interval"); + if !elements.is_empty() { + let joined = elements.iter().cloned().collect::>().join(", "); + out.push_str(&format!("\n elements = {{ {joined} }}")); + } + out.push_str("\n }\n"); + out +} + +fn fmt_set_v6(name: &str) -> String { + format!(" set {name} {{\n type ipv6_addr\n flags interval\n }}\n") +} + +/// 渲染完整 `table inet floatctf_awd` 规则文本(含 revision 注释,仅 observability)。 +pub fn render_table(desired: &DesiredFirewallState) -> String { + let mut out = String::new(); + out.push_str("table inet "); + out.push_str(TABLE_NAME); + out.push_str(" {\n"); + out.push_str(&format!( + " comment \"managed-by=floatctf revision={}\"\n", + desired.revision + )); + + // ── base chain ── + out.push_str(" chain awd_forward {\n"); + out.push_str(&format!( + " type filter hook forward priority {FORWARD_PRIORITY}; policy accept;\n" + )); + out.push_str(" # managed by FloatCTF: restrictive DROP only; no iptables/Docker rules touched\n"); + out.push_str(" ip saddr @banned_players_v4 drop\n"); + out.push_str(" ip6 saddr @banned_players_v6 drop\n"); + for event in &desired.events { + out.push_str(&format!( + " jump event_{}\n", + NftObjectName::event_key(&event.event_id).as_str() + )); + } + out.push_str(" }\n\n"); + + // ── 全局 sets ── + let mut all_gameboxes: BTreeSet = BTreeSet::new(); + let mut all_players: BTreeSet = BTreeSet::new(); + let mut infra: BTreeSet = BTreeSet::new(); + let mut banned: BTreeSet = BTreeSet::new(); + for event in &desired.events { + all_gameboxes.insert(event.gamebox_network.as_str()); + for team in &event.teams { + all_players.insert(team.wireguard_network.as_str()); + if event.banned_teams.contains(&team.team_id) { + // ban:阻断该队玩家 WG 子网 + GameBox 子网的入站流量(saddr 匹配) + banned.insert(team.wireguard_network.as_str()); + banned.insert(team.gamebox_network.as_str()); + } + } + infra.insert(event.infrastructure_network.as_str()); + infra.insert(event.flagserver_ip.to_string()); + infra.insert(event.judgeserver_ip.to_string()); + } + out.push_str(&fmt_set("all_gameboxes_v4", &all_gameboxes)); + out.push_str(&fmt_set("infrastructure_v4", &infra)); + out.push_str(&fmt_set("player_wg_v4", &all_players)); + out.push_str(&fmt_set("banned_players_v4", &banned)); + out.push_str(&fmt_set_v6("banned_players_v6")); + out.push('\n'); + + // ── per-event sets + chains ── + for event in &desired.events { + let key = NftObjectName::event_key(&event.event_id); + let mut gb: BTreeSet = BTreeSet::new(); + let mut players: BTreeSet = BTreeSet::new(); + for team in &event.teams { + gb.insert(team.gamebox_network.as_str()); + players.insert(team.wireguard_network.as_str()); + } + out.push_str(&fmt_set(&format!("{}_gameboxes_v4", key.as_str()), &gb)); + out.push_str(&fmt_set(&format!("{}_players_v4", key.as_str()), &players)); + // IPv6 显式策略占位:AWD v4-only,不分配 IPv6;空集即“管理范围默认 DROP” + out.push_str(&fmt_set_v6(&format!("{}_gameboxes_v6", key.as_str()))); + out.push_str(&fmt_set_v6(&format!("{}_players_v6", key.as_str()))); + out.push('\n'); + out.push_str(&render_event_chain(event, &key)); + out.push('\n'); + } + + out.push_str("}\n"); + out +} + +/// 渲染单个赛事的 event chain(phase 规则 + 公共 restrictive 规则)。 +fn render_event_chain(event: &DesiredEventPolicy, key: &NftObjectName) -> String { + let k = key.as_str(); + let mut out = String::new(); + out.push_str(&format!(" chain event_{k} {{\n")); + + match event.phase { + AwdPhase::Hardening => { + // 1) own-team 放行(精确表达 hardening 隔离;否则跨队阻断无法表达“自己除外”) + for team in &event.teams { + out.push_str(&format!( + " # own-team {}: not denied\n", + team.team_id + )); + out.push_str(&format!( + " ip saddr {} ip daddr {} accept\n", + team.wireguard_network.as_str(), + team.gamebox_network.as_str() + )); + } + // 2) 其余 player→gamebox(跨队)默认 DROP + out.push_str(&format!( + " ip saddr @{k}_players_v4 ip daddr @{k}_gameboxes_v4 drop\n" + )); + // 3) gamebox 全出网阻断(防横向移动 + 公网 + infra) + out.push_str(&format!(" ip saddr @{k}_gameboxes_v4 drop\n")); + } + AwdPhase::Attack => { + // 攻击阶段:不阻止跨队 player→gamebox + // gamebox 出网:允许 gamebox↔gamebox(攻击协同)+ flagserver,其余阻断 + out.push_str(&format!( + " ip saddr @{k}_gameboxes_v4 ip daddr @{k}_gameboxes_v4 accept\n" + )); + out.push_str(&format!( + " ip saddr @{k}_gameboxes_v4 ip daddr {} accept\n", + event.flagserver_ip + )); + out.push_str(&format!(" ip saddr @{k}_gameboxes_v4 drop\n")); + } + AwdPhase::Pause => { + // 暂停:阻断比赛网络路径(玩家与 GameBox 双向) + out.push_str(&format!(" ip saddr @{k}_players_v4 drop\n")); + out.push_str(&format!(" ip saddr @{k}_gameboxes_v4 drop\n")); + } + } + + // ── 公共 restrictive 规则(所有 phase)── + // 玩家/GameBox → 基础设施(FlagServer/JudgeServer/infra 网段)阻断 + out.push_str(&format!( + " ip saddr @{k}_players_v4 ip daddr @infrastructure_v4 drop\n" + )); + out.push_str(&format!( + " ip saddr @{k}_gameboxes_v4 ip daddr @infrastructure_v4 drop\n" + )); + // IPv6 显式策略(v6 占位空集 = 管理范围默认 DROP;若未来分配 v6 地址则注入 set 生效) + out.push_str(&format!(" ip6 saddr @{k}_players_v6 drop\n")); + out.push_str(&format!(" ip6 saddr @{k}_gameboxes_v6 drop\n")); + + out.push_str(" }\n"); + out +} + +/// 观测到的防火墙状态(reconcile 后从 `nft list table` 解析)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ObservedFirewallState { + pub table_exists: bool, + /// 从 table comment 解析出的 revision(`managed-by=floatctf revision=N`)。 + pub observed_revision: Option, + /// 观测到的 event chains(`event_`)。 + pub event_chains: Vec, + pub notes: Vec, +} + +/// 从 `nft list table inet floatctf_awd` 输出解析观测状态(P1-9 verify 用)。 +pub fn parse_observed_table(output: &str) -> ObservedFirewallState { + let mut state = ObservedFirewallState::default(); + state.table_exists = output.contains("table inet floatctf_awd"); + for line in output.lines() { + let line = line.trim(); + if let Some(idx) = line.find("managed-by=floatctf revision=") { + let rest = &line[idx + "managed-by=floatctf revision=".len()..]; + if let Ok(n) = rest.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("").parse::() + { + state.observed_revision = Some(n); + } + } + if let Some(rest) = line.strip_prefix("chain event_") { + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + // 还原完整 chain 名(event_),与 verify() 的期望一致 + state.event_chains.push(format!("event_{name}")); + } + } + } + state +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::event::awd_team::domain::firewall_state::{DesiredTeamPolicy, IpNet}; + use uuid::Uuid; + + fn sample_event(phase: AwdPhase, banned: bool) -> DesiredEventPolicy { + let team_a = Uuid::new_v4(); + let team_b = Uuid::new_v4(); + let mut event = DesiredEventPolicy { + event_key: "ev_sample".into(), + event_id: Uuid::new_v4(), + phase, + gamebox_network: IpNet::parse("10.42.0.0/16").unwrap(), + infrastructure_network: IpNet::parse("10.42.0.0/24").unwrap(), + flagserver_ip: "10.42.0.10".parse().unwrap(), + judgeserver_ip: "10.42.0.11".parse().unwrap(), + teams: vec![ + DesiredTeamPolicy { + team_id: team_a, + wireguard_network: IpNet::parse("172.31.1.0/24").unwrap(), + gamebox_network: IpNet::parse("10.42.1.0/24").unwrap(), + }, + DesiredTeamPolicy { + team_id: team_b, + wireguard_network: IpNet::parse("172.31.2.0/24").unwrap(), + gamebox_network: IpNet::parse("10.42.2.0/24").unwrap(), + }, + ], + banned_teams: if banned { vec![team_a] } else { vec![] }, + }; + event.event_key = NftObjectName::event_key(&event.event_id).as_str().to_string(); + event + } + + #[test] + fn render_hardening_blocks_cross_team_not_own() { + let desired = DesiredFirewallState { + revision: 7, + events: vec![sample_event(AwdPhase::Hardening, false)], + }; + let text = render_table(&desired); + assert!(text.contains("table inet floatctf_awd")); + assert!(text.contains("managed-by=floatctf revision=7")); + assert!(text.contains("hook forward priority 1; policy accept;")); + // own-team accept 先于跨队 drop + assert!(text.contains("ip saddr 172.31.1.0/24 ip daddr 10.42.1.0/24 accept")); + assert!(text.contains("ip saddr 172.31.2.0/24 ip daddr 10.42.2.0/24 accept")); + assert!(text.contains("ip saddr @ev_")); + // 无 O(N²):不出现 Team A → Team B 的单条规则 + assert!(!text.contains("172.31.1.0/24 ip daddr 10.42.2.0/24")); + } + + #[test] + fn render_attack_opens_cross_team_and_flagserver() { + let desired = DesiredFirewallState { + revision: 1, + events: vec![sample_event(AwdPhase::Attack, false)], + }; + let text = render_table(&desired); + assert!(text.contains("ip saddr @ev_")); + assert!(text.contains("ip daddr 10.42.0.10 accept")); // flagserver + // attack 无跨队 drop:不存在 players→gameboxes drop + assert!(!text.contains("_players_v4 ip daddr @ev_")); + } + + #[test] + fn render_pause_blocks_all_game_traffic() { + let desired = DesiredFirewallState { + revision: 2, + events: vec![sample_event(AwdPhase::Pause, false)], + }; + let text = render_table(&desired); + assert!(text.contains("ip saddr @ev_")); + assert!(text.contains("drop")); + } + + #[test] + fn render_ban_populates_banned_set() { + let desired = DesiredFirewallState { + revision: 3, + events: vec![sample_event(AwdPhase::Attack, true)], + }; + let text = render_table(&desired); + // banned set 含被 ban 队伍的 WG + GameBox 子网 + assert!(text.contains("banned_players_v4")); + assert!(text.contains("172.31.1.0/24")); + assert!(text.contains("10.42.1.0/24")); + // 无 per-team ban chains + assert!(!text.contains("FCTF-B-")); + } + + #[test] + fn render_ipv6_explicit_policy_present() { + let desired = DesiredFirewallState { + revision: 4, + events: vec![sample_event(AwdPhase::Hardening, false)], + }; + let text = render_table(&desired); + // inet family:显式 IPv6 策略(占位空集)+ banned v6 + assert!(text.contains("_players_v6")); + assert!(text.contains("_gameboxes_v6")); + assert!(text.contains("banned_players_v6")); + assert!(text.contains("ip6 saddr")); + } + + #[test] + fn render_multi_event_keeps_both_chains() { + let e1 = sample_event(AwdPhase::Hardening, false); + let e2 = sample_event(AwdPhase::Attack, false); + let desired = DesiredFirewallState { + revision: 5, + events: vec![e1.clone(), e2], + }; + let text = render_table(&desired); + let k1 = NftObjectName::event_key(&e1.event_id).as_str().to_string(); + assert!(text.contains(&format!("chain event_{k1}"))); + assert!(text.contains(&format!("jump event_{k1}"))); + assert_eq!(text.matches("chain event_").count(), 2); + } + + #[test] + fn parse_observed_table_extracts_revision_and_chains() { + let out = "table inet floatctf_awd {\n comment \"managed-by=floatctf revision=42\"\n chain event_ev_ab12cd34 {\n }\n}\n"; + let state = parse_observed_table(out); + assert!(state.table_exists); + assert_eq!(state.observed_revision, Some(42)); + assert_eq!(state.event_chains, vec!["event_ev_ab12cd34"]); + } + + #[test] + fn parse_observed_table_empty() { + let state = parse_observed_table("table inet floatctf_awd {\n}\n"); + assert!(state.table_exists); + assert_eq!(state.observed_revision, None); + assert!(state.event_chains.is_empty()); + } + + #[test] + fn event_key_is_stable_and_bounded() { + let id = Uuid::new_v4(); + let k1 = NftObjectName::event_key(&id); + let k2 = NftObjectName::event_key(&id); + assert_eq!(k1, k2); + assert!(k1.as_str().len() <= 16); + assert!(k1.as_str().starts_with("ev_")); + assert!(k1.as_str().chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')); + } +} diff --git a/apps/api/src/modules/event/awd_team/infrastructure/mod.rs b/apps/api/src/modules/event/awd_team/infrastructure/mod.rs index 83a7c2f4..5fdf0158 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/mod.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/mod.rs @@ -1,7 +1,9 @@ //! AWD infrastructure: persistence and host network adapters. +pub mod firewall; pub mod network; pub mod persistence; +pub use firewall::{FirewallRuntime, NftablesFirewallRuntime, NoopFirewallRuntime}; pub use network::{AwdNetworkRuntime, HostNetworkRuntime, NoopNetworkRuntime}; pub use persistence::{AwdPersistedEnum, Persist}; diff --git a/apps/api/src/modules/event/awd_team/infrastructure/network/mod.rs b/apps/api/src/modules/event/awd_team/infrastructure/network/mod.rs index 49900695..8aa33069 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/network/mod.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/network/mod.rs @@ -1,14 +1,13 @@ -//! AWD host networking: WireGuard, firewall, conntrack. +//! AWD host networking: WireGuard, conntrack (firewall moved to `infrastructure::firewall`). pub mod keys; pub mod runtime; pub use keys::{WgKeyPair, generate_keypair, public_from_private}; pub use runtime::{ - AwdNetworkRuntime, EventNetworkIdentity, EventNetworkPolicy, HostNetworkRuntime, - NetworkObservedState, NoopNetworkRuntime, PeerIdentity, TeamNetworkIdentity, - WireGuardDesiredState, + AwdNetworkRuntime, EventNetworkIdentity, HostNetworkRuntime, NetworkObservedState, + NoopNetworkRuntime, PeerIdentity, TeamNetworkIdentity, WireGuardDesiredState, }; // Compatibility re-exports of the system command layer. -pub use crate::modules::event::awd_team::system::{command, conntrack, firewall, wireguard}; +pub use crate::modules::event::awd_team::system::{command, conntrack, wireguard}; diff --git a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs index ec02052a..a298183a 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs @@ -7,7 +7,7 @@ use crate::modules::event::awd_team::{ AwdResult, system::{ command::{CommandRunner, RealCommandRunner}, - conntrack, firewall, wireguard, + conntrack, wireguard, }, }; @@ -49,21 +49,14 @@ pub struct NetworkObservedState { pub notes: Vec, } -/// Rendered firewall policy for one apply operation. -#[derive(Debug, Clone)] -pub struct EventNetworkPolicy { - pub event_id: Uuid, - /// Pre-rendered rules from `firewall::render_*`. - pub rules: crate::modules::event::awd_team::system::firewall::RenderedRules, - pub dry_run: bool, -} - -/// Platform host networking for AWD (WG / iptables / conntrack). +/// Platform host networking for AWD (WireGuard / conntrack). +/// +/// 防火墙策略已迁移到独立 `FirewallRuntime`(native nftables,Phase 1); +/// 本 runtime 只管 WG 生命周期与 conntrack 清理。 #[async_trait] pub trait AwdNetworkRuntime: Send + Sync { async fn ensure_wireguard(&self, desired: WireGuardDesiredState) -> AwdResult<()>; async fn remove_wireguard(&self, interface: &str) -> AwdResult<()>; - async fn apply_policy(&self, policy: EventNetworkPolicy) -> AwdResult<()>; async fn revoke_peer(&self, peer: PeerIdentity) -> AwdResult<()>; async fn clear_event_connections(&self, event: EventNetworkIdentity) -> AwdResult<()>; async fn clear_team_connections(&self, team: TeamNetworkIdentity) -> AwdResult<()>; @@ -115,18 +108,6 @@ impl AwdNetworkRuntime for HostNetworkRuntime { wireguard::delete_interface(self.runner(), interface).await } - async fn apply_policy(&self, policy: EventNetworkPolicy) -> AwdResult<()> { - if policy.dry_run { - tracing::info!( - event_id = %policy.event_id, - bytes = policy.rules.iptables_restore_input.len(), - "[Network] dry-run policy (not applied)" - ); - return Ok(()); - } - firewall::apply_rules(self.runner(), &policy.rules).await - } - async fn revoke_peer(&self, peer: PeerIdentity) -> AwdResult<()> { wireguard::remove_peer(self.runner(), &peer.interface, &peer.public_key).await } @@ -161,9 +142,6 @@ impl AwdNetworkRuntime for NoopNetworkRuntime { async fn remove_wireguard(&self, _interface: &str) -> AwdResult<()> { Ok(()) } - async fn apply_policy(&self, _policy: EventNetworkPolicy) -> AwdResult<()> { - Ok(()) - } async fn revoke_peer(&self, _peer: PeerIdentity) -> AwdResult<()> { Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index 80e50a8c..b967d2dd 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -24,9 +24,9 @@ use crate::entity::sea_orm_active_enums::{AwdEventStatus, AwdPhase, RoundStatus} use crate::modules::event::awd_team::{ crypto::{AwdCrypto, EncryptedBlob}, domain::AwdEventStatusExt, - infrastructure::network::AwdNetworkRuntime, + infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, repo::{event_repo, judge_repo, round_repo}, - service::{event_service, judge_service, network_policy_service}, + service::{event_service, firewall_service, judge_service}, }; use fcmc::AwdContainerRuntime; use std::sync::Arc; @@ -134,6 +134,7 @@ pub struct AwdEventStartHandler { pub network: std::sync::Arc< dyn crate::modules::event::awd_team::infrastructure::network::AwdNetworkRuntime, >, + pub firewall: Arc, } #[async_trait] @@ -149,7 +150,13 @@ impl TaskHandler for AwdEventStartHandler { async fn run(&self, task: scheduled_tasks::Model) -> anyhow::Result<()> { let event_id = event_id_from_task(&task)?; info!("[AWD] Starting scheduled event {}", event_id); - event_service::start_event(self.db.get_ref(), self.network.as_ref(), event_id).await?; + event_service::start_event( + self.db.get_ref(), + self.network.as_ref(), + self.firewall.as_ref(), + event_id, + ) + .await?; Ok(()) } } @@ -159,6 +166,7 @@ pub struct AwdRoundStartHandler { pub db: WebDb, pub docker: WebDocker, pub network: Arc, + pub firewall: Arc, } #[async_trait] @@ -245,14 +253,19 @@ impl TaskHandler for AwdRoundStartHandler { new_round.round_number, event_id, phase_debug ); - // Same path as start/pause/resume: phase firewall + conntrack flush. - network_policy_service::apply_phase_policy( + // phase 切换 = DB desired phase → 全局 DesiredFirewallState → nftables reconcile(Phase 1 P1-10) + firewall_service::reconcile_global( self.db.get_ref(), + self.firewall.as_ref(), + firewall_service::next_network_revision(self.db.get_ref()).await?, + ) + .await?; + firewall_service::flush_event_connections( self.network.as_ref(), event_id, - phase, + &awd_event.gamebox_cidr, ) - .await?; + .await; let token_ciphertext = awd_event .judgeserver_token_ciphertext diff --git a/apps/api/src/modules/event/awd_team/service/deploy_service.rs b/apps/api/src/modules/event/awd_team/service/deploy_service.rs index c139fe60..6259aeff 100644 --- a/apps/api/src/modules/event/awd_team/service/deploy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/deploy_service.rs @@ -19,8 +19,12 @@ use crate::modules::event::awd_team::{ AwdError, AwdResult, crypto::{AwdCrypto, EncryptedBlob}, domain::{AwdEventStatusExt, Ipv4Cidr}, - infrastructure::network::{AwdNetworkRuntime, WireGuardDesiredState}, + infrastructure::{ + firewall::FirewallRuntime, + network::{AwdNetworkRuntime, WireGuardDesiredState}, + }, repo::event_repo, + service::firewall_service, }; use fcmc::{AwdContainerRuntime, EventNetworkSpec, GameBoxSpec, InfrastructureContainerSpec}; @@ -39,6 +43,7 @@ pub async fn deploy_event( db: &DatabaseConnection, containers: &dyn AwdContainerRuntime, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, crypto: &AwdCrypto, awd_config: &AwdStaticConfig, event_id: Uuid, @@ -121,8 +126,9 @@ pub async fn deploy_event( // ── 6. WireGuard ── ensure_wireguard(db, network, crypto, awd_config, &awd_event, event_id).await?; - // ── 7. Hardening firewall ── - ensure_hardening_firewall(db, network, &awd_event, event_id).await?; + // ── 7. Hardening firewall(全局 desired-state reconcile,Phase 1 P1-10)── + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; // ── 8. Deployed(状态机唯一入口)── event_repo::transition_event( @@ -535,28 +541,6 @@ async fn ensure_wireguard( Ok(()) } -async fn ensure_hardening_firewall( - db: &DatabaseConnection, - network: &dyn AwdNetworkRuntime, - _awd_event: &awd_events::Model, - event_id: Uuid, -) -> AwdResult<()> { - // Multi-event merge so deploy does not wipe sibling event chains. - crate::modules::event::awd_team::service::network_policy_service::apply_phase_policy( - db, - network, - event_id, - AwdPhase::Hardening, - ) - .await?; - - info!( - "[Deploy] Hardening firewall policy applied for {}", - event_id - ); - Ok(()) -} - async fn resource_exists( db: &DatabaseConnection, event_id: Uuid, diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index f1de0f9f..4f0d33ab 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -7,15 +7,19 @@ use crate::entity::sea_orm_active_enums::{AwdEventStatus, AwdPhase, RoundStatus} use crate::modules::event::awd_team::{ AwdError, AwdResult, domain::AwdEventStatusExt, - infrastructure::network::AwdNetworkRuntime, + infrastructure::{ + firewall::FirewallRuntime, + network::{AwdNetworkRuntime, EventNetworkIdentity}, + }, repo::{event_repo, round_repo}, - service::network_policy_service, + service::firewall_service, }; /// Start an AWD event: validate status, create first round, set hardening phase + policy. pub async fn start_event( db: &DatabaseConnection, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, event_id: Uuid, ) -> AwdResult<()> { let awd_event = event_repo::find_by_event_id(db, event_id) @@ -66,7 +70,10 @@ pub async fn start_event( .await .map_err(|e| AwdError::Database(e.to_string()))?; - network_policy_service::apply_phase_policy(db, network, event_id, AwdPhase::Hardening).await?; + // 全局 desired-state reconcile(nftables)+ conntrack 清理(Phase 1 P1-10) + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) } @@ -75,6 +82,7 @@ pub async fn start_event( pub async fn pause_event( db: &DatabaseConnection, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, event_id: Uuid, ) -> AwdResult<()> { let awd_event = event_repo::find_by_event_id(db, event_id) @@ -115,7 +123,9 @@ pub async fn pause_event( ) .await?; - network_policy_service::apply_phase_policy(db, network, event_id, AwdPhase::Pause).await?; + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) } @@ -124,6 +134,7 @@ pub async fn pause_event( pub async fn resume_event( db: &DatabaseConnection, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, event_id: Uuid, ) -> AwdResult<()> { let awd_event = event_repo::find_by_event_id(db, event_id) @@ -179,7 +190,9 @@ pub async fn resume_event( ) .await?; - network_policy_service::apply_phase_policy(db, network, event_id, resume_phase).await?; + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/firewall_service.rs b/apps/api/src/modules/event/awd_team/service/firewall_service.rs new file mode 100644 index 00000000..c2deb0b0 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/service/firewall_service.rs @@ -0,0 +1,229 @@ +//! Global firewall desired-state reconcile orchestrator (Phase 1 P1-10). +//! +//! 唯一路径:DB(Desired State)→ `DesiredFirewallState`(全局)→ `FirewallRuntime.reconcile()`。 +//! 禁止在任何 service 直接 add/delete 单条 nft rule。 + +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +use crate::entity::{ + awd_events, awd_team_bans, awd_team_networks, + sea_orm_active_enums::AwdEventStatus, +}; +use crate::modules::event::awd_team::{ + AwdError, AwdResult, + domain::firewall_state::{DesiredEventPolicy, DesiredFirewallState, DesiredTeamPolicy, IpNet}, + infrastructure::firewall::FirewallRuntime, +}; + +/// 进入防火墙 desired set 的赛事范围(§5.13b)。 +/// +/// 判定:**event 仍有 managed runtime/network 资源且未完全归档清理**。 +/// 不能只看 Running/Paused —— Deploying(部分资源)/NetworkError(需要隔离保护)等 +/// 非终态也必须保持策略。 +pub fn in_firewall_desired_set(status: &AwdEventStatus) -> bool { + matches!( + status, + AwdEventStatus::Deploying + | AwdEventStatus::Deployed + | AwdEventStatus::Prechecking + | AwdEventStatus::Verified + | AwdEventStatus::Running + | AwdEventStatus::Paused + | AwdEventStatus::NetworkError + | AwdEventStatus::StartBlocked + | AwdEventStatus::DeployFailed + ) +} + +/// 由 DB 构建全局 DesiredFirewallState(revision 由调用方/存储层提供)。 +pub async fn build_desired_state( + db: &C, + revision: u64, +) -> AwdResult { + let events = awd_events::Entity::find().all(db).await.map_err(|e| { + AwdError::Database(format!("load awd_events: {e}")) + })?; + + let mut desired = DesiredFirewallState { + revision, + events: Vec::new(), + }; + + for event in events { + if !in_firewall_desired_set(&event.status) { + continue; + } + + // 队伍网络分配 + let networks = awd_team_networks::Entity::find() + .filter(awd_team_networks::Column::EventId.eq(event.event_id)) + .all(db) + .await + .map_err(|e| AwdError::Database(format!("load awd_team_networks: {e}")))?; + + // 活跃 ban + let bans = awd_team_bans::Entity::find() + .filter(awd_team_bans::Column::EventId.eq(event.event_id)) + .filter(awd_team_bans::Column::Status.eq(crate::entity::sea_orm_active_enums::BanStatus::Active)) + .all(db) + .await + .map_err(|e| AwdError::Database(format!("load awd_team_bans: {e}")))?; + + let mut teams = Vec::new(); + for n in networks { + teams.push(DesiredTeamPolicy { + team_id: n.team_id, + wireguard_network: IpNet::parse(&n.wireguard_subnet) + .map_err(|e| AwdError::Validation(format!("wg subnet: {e}")))?, + gamebox_network: IpNet::parse(&n.gamebox_subnet) + .map_err(|e| AwdError::Validation(format!("gamebox subnet: {e}")))?, + }); + } + + let flagserver_ip: std::net::Ipv4Addr = event + .flagserver_ip + .parse() + .map_err(|_| AwdError::Validation(format!("flagserver_ip {}", event.flagserver_ip)))?; + let judgeserver_ip: std::net::Ipv4Addr = event + .judgeserver_ip + .parse() + .map_err(|_| AwdError::Validation(format!("judgeserver_ip {}", event.judgeserver_ip)))?; + + desired.events.push(DesiredEventPolicy { + event_key: crate::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( + &event.event_id, + ) + .as_str() + .to_string(), + event_id: event.event_id, + phase: event.phase, + gamebox_network: IpNet::parse(&event.gamebox_cidr) + .map_err(|e| AwdError::Validation(format!("gamebox_cidr: {e}")))?, + // 基础设施网络 = FlagServer 所在 /24(分配约定:infra 在 .0.x,队伍从 .1.x 起) + infrastructure_network: infra_network(flagserver_ip), + flagserver_ip, + judgeserver_ip, + banned_teams: bans.into_iter().map(|b| b.team_id).collect(), + teams, + }); + } + + Ok(desired) +} + +/// 基础设施网络:FlagServer 所在 /24 网段(分配约定 infra=.0.x)。 +fn infra_network(flagserver_ip: std::net::Ipv4Addr) -> IpNet { + let octets = flagserver_ip.octets(); + IpNet { + addr: std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], 0), + prefix_len: 24, + } +} + +/// 全局 reconcile:DB → Desired → runtime.reconcile。 +/// +/// 失败 Fail Closed:返回 Err(调用方经 transition_event 置 NetworkError)。 +pub async fn reconcile_global( + db: &sea_orm::DatabaseConnection, + firewall: &dyn FirewallRuntime, + revision: u64, +) -> AwdResult { + let desired = build_desired_state(db, revision).await?; + firewall.reconcile(&desired).await +} + +/// 空态 reconcile:没有任何赛事需要策略时删除整个 `table inet floatctf_awd`(P4-13 用)。 +pub async fn reconcile_empty( + firewall: &dyn FirewallRuntime, + revision: u64, +) -> AwdResult { + firewall + .reconcile(&DesiredFirewallState { + revision, + events: Vec::new(), + }) + .await +} + +/// 网络策略 revision(settings 表,跨重启持久)。 +/// +/// 每次 Desired State 变化 → +1;reconcile 成功后 nft table comment 中的 +/// observed revision 与它一致(Phase 2 Precheck 比对)。 +pub async fn next_network_revision(db: &sea_orm::DatabaseConnection) -> AwdResult { + use crate::infrastructure::settings::{get_setting, upsert_setting}; + const KEY: &str = "AWD_NETWORK_REVISION"; + let current: u64 = get_setting(db, KEY) + .await + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let next = current + 1; + upsert_setting(db, KEY, &next.to_string()) + .await + .map_err(|e| AwdError::Database(format!("revision persist: {e}")))?; + Ok(next) +} + +/// 当前策略 revision(读 settings;不存在视为 0 = 尚未 reconcile)。 +pub async fn current_network_revision(db: &sea_orm::DatabaseConnection) -> u64 { + use crate::infrastructure::settings::get_setting; + get_setting(db, "AWD_NETWORK_REVISION") + .await + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0) +} + +/// 阶段切换后的 conntrack 清理(best-effort + 显式错误日志,P0-4 规则)。 +/// 主策略(nft reconcile)已成功;这里只清理既有连接防绕过。 +pub async fn flush_event_connections( + network: &dyn crate::modules::event::awd_team::infrastructure::network::AwdNetworkRuntime, + event_id: Uuid, + gamebox_cidr: &str, +) { + use crate::modules::event::awd_team::infrastructure::network::EventNetworkIdentity; + if let Err(e) = network + .clear_event_connections(EventNetworkIdentity { + event_id, + gamebox_cidr: gamebox_cidr.to_string(), + }) + .await + { + tracing::error!( + "[Firewall] conntrack cleanup failed for event {}: {}", + event_id, + e + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desired_set_covers_non_terminal_runtime_states() { + assert!(in_firewall_desired_set(&AwdEventStatus::Deploying)); + assert!(in_firewall_desired_set(&AwdEventStatus::Deployed)); + assert!(in_firewall_desired_set(&AwdEventStatus::Prechecking)); + assert!(in_firewall_desired_set(&AwdEventStatus::Verified)); + assert!(in_firewall_desired_set(&AwdEventStatus::Running)); + assert!(in_firewall_desired_set(&AwdEventStatus::Paused)); + assert!(in_firewall_desired_set(&AwdEventStatus::NetworkError)); + assert!(in_firewall_desired_set(&AwdEventStatus::StartBlocked)); + assert!(in_firewall_desired_set(&AwdEventStatus::DeployFailed)); + // 终态/未部署不进入 + assert!(!in_firewall_desired_set(&AwdEventStatus::Draft)); + assert!(!in_firewall_desired_set(&AwdEventStatus::Configuring)); + assert!(!in_firewall_desired_set(&AwdEventStatus::Finished)); + assert!(!in_firewall_desired_set(&AwdEventStatus::Archived)); + assert!(!in_firewall_desired_set(&AwdEventStatus::VerificationFailed)); + } + + #[test] + fn infra_network_is_flagserver_24() { + let net = infra_network("10.42.0.10".parse().unwrap()); + assert_eq!(net.as_str(), "10.42.0.0/24"); + } +} diff --git a/apps/api/src/modules/event/awd_team/service/mod.rs b/apps/api/src/modules/event/awd_team/service/mod.rs index 32d84218..a7d8a184 100644 --- a/apps/api/src/modules/event/awd_team/service/mod.rs +++ b/apps/api/src/modules/event/awd_team/service/mod.rs @@ -3,10 +3,10 @@ pub mod archive_service; pub mod deploy_service; pub mod event_service; +pub mod firewall_service; pub mod flag_service; pub mod gamebox_service; pub mod judge_service; -pub mod network_policy_service; pub mod precheck_service; pub mod recovery_service; pub mod reset_service; diff --git a/apps/api/src/modules/event/awd_team/service/network_policy_service.rs b/apps/api/src/modules/event/awd_team/service/network_policy_service.rs deleted file mode 100644 index 0eeaf3bb..00000000 --- a/apps/api/src/modules/event/awd_team/service/network_policy_service.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Apply phase-specific firewall policy + conntrack flush for an AWD event. - -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; -use uuid::Uuid; - -use crate::entity::{awd_team_networks, sea_orm_active_enums::AwdPhase}; -use crate::modules::event::awd_team::system::firewall::RenderedRules; -use crate::modules::event::awd_team::{ - AwdError, AwdResult, - domain::Ipv4Cidr, - infrastructure::network::{AwdNetworkRuntime, EventNetworkIdentity, EventNetworkPolicy}, - repo::event_repo, - system::firewall, -}; - -/// Pure phase → rules mapping (used by apply path and unit tests). -pub fn render_rules_for_phase( - event_id: &str, - phase: AwdPhase, - gamebox_cidr: &Ipv4Cidr, - wg_cidr: &Ipv4Cidr, - flagserver_ip: &str, - judgeserver_ip: &str, - // (team_id, gamebox_subnet, wireguard_subnet) - team_subnets: &[(String, String, String)], -) -> RenderedRules { - match phase { - AwdPhase::Hardening => firewall::render_hardening_rules( - event_id, - gamebox_cidr, - wg_cidr, - flagserver_ip, - judgeserver_ip, - team_subnets, - ), - AwdPhase::Attack => firewall::render_attack_rules( - event_id, - gamebox_cidr, - wg_cidr, - flagserver_ip, - judgeserver_ip, - ), - AwdPhase::Pause => firewall::render_pause_rules(event_id, gamebox_cidr), - } -} - -/// Collect rendered rules for one event at a given phase. -async fn render_event_rules( - db: &DatabaseConnection, - event_id: Uuid, - phase: AwdPhase, -) -> AwdResult { - let awd_event = event_repo::find_by_event_id(db, event_id) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; - - let gamebox_cidr = Ipv4Cidr::parse(&awd_event.gamebox_cidr) - .map_err(|e| AwdError::Validation(e.to_string()))?; - let wg_cidr = Ipv4Cidr::parse(&awd_event.wireguard_cidr) - .map_err(|e| AwdError::Validation(e.to_string()))?; - - let team_nets = awd_team_networks::Entity::find() - .filter(awd_team_networks::Column::EventId.eq(event_id)) - .all(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - let team_subnets: Vec<(String, String, String)> = team_nets - .into_iter() - .map(|t| (t.team_id.to_string(), t.gamebox_subnet, t.wireguard_subnet)) - .collect(); - - Ok(render_rules_for_phase( - &event_id.to_string(), - phase, - &gamebox_cidr, - &wg_cidr, - &awd_event.flagserver_ip, - &awd_event.judgeserver_ip, - &team_subnets, - )) -} - -/// Render and apply network policy for `event_id` at `phase`, merging all other -/// active events so a single-event restore never wipes sibling FCTF chains. -pub async fn apply_phase_policy( - db: &DatabaseConnection, - network: &dyn AwdNetworkRuntime, - event_id: Uuid, - phase: AwdPhase, -) -> AwdResult<()> { - let awd_event = event_repo::find_by_event_id(db, event_id) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; - - let mut parts: Vec = Vec::new(); - - // Primary event at the requested phase (may not yet be reflected in DB). - parts.push(render_event_rules(db, event_id, phase).await?); - - // Sibling Running/Paused events keep their current phase rules. - let actives = event_repo::find_active_events(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - for other in actives { - if other.event_id == event_id { - continue; - } - match render_event_rules(db, other.event_id, other.phase.clone()).await { - Ok(r) => parts.push(r), - Err(e) => { - tracing::warn!( - event_id = %other.event_id, - "skip sibling event rules in multi-event merge: {}", - e - ); - } - } - } - - let rules = firewall::merge_rendered_rules(&parts); - - if let Err(e) = network - .apply_policy(EventNetworkPolicy { - event_id, - rules, - dry_run: false, - }) - .await - { - // On failure, keep previous rules (HostNetworkRuntime rolls back) and surface error. - return Err(e); - } - - // Conntrack flush on phase change so established flows cannot bypass new policy. - // Phase 0 P0-4:不再静默吞错;主策略已成功应用,此处失败显式记录。 - // 完整失败模型(reconcile 失败 → NetworkError Fail Closed)见 Phase 1/3。 - if let Err(e) = network - .clear_event_connections(EventNetworkIdentity { - event_id, - gamebox_cidr: awd_event.gamebox_cidr.clone(), - }) - .await - { - tracing::error!( - "[NetworkPolicy] conntrack cleanup failed for event {}: {}", - event_id, - e - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn phase_rules_differ_for_hardening_and_attack() { - let gb = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let wg = Ipv4Cidr::parse("10.1.0.0/16").unwrap(); - let h = render_rules_for_phase( - "evt", - AwdPhase::Hardening, - &gb, - &wg, - "10.0.0.1", - "10.0.0.2", - &[], - ); - let a = render_rules_for_phase( - "evt", - AwdPhase::Attack, - &gb, - &wg, - "10.0.0.1", - "10.0.0.2", - &[], - ); - assert_ne!(h.iptables_restore_input, a.iptables_restore_input); - assert!( - h.description.to_lowercase().contains("harden") - || h.iptables_restore_input.contains("FCTF-H-") - ); - assert!( - a.iptables_restore_input.contains("ACCEPT") || !a.iptables_restore_input.is_empty() - ); - } - - #[test] - fn pause_rules_are_non_empty() { - let gb = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let wg = Ipv4Cidr::parse("10.1.0.0/16").unwrap(); - let p = render_rules_for_phase( - "evt", - AwdPhase::Pause, - &gb, - &wg, - "10.0.0.1", - "10.0.0.2", - &[], - ); - assert!(!p.iptables_restore_input.is_empty()); - } -} diff --git a/apps/api/src/modules/event/awd_team/service/recovery_service.rs b/apps/api/src/modules/event/awd_team/service/recovery_service.rs index 0d6a5572..0cea30aa 100644 --- a/apps/api/src/modules/event/awd_team/service/recovery_service.rs +++ b/apps/api/src/modules/event/awd_team/service/recovery_service.rs @@ -14,9 +14,12 @@ use crate::modules::event::awd_team::{ AwdError, AwdResult, crypto::{AwdCrypto, EncryptedBlob}, domain::Ipv4Cidr, - infrastructure::network::{AwdNetworkRuntime, EventNetworkIdentity, WireGuardDesiredState}, + infrastructure::{ + firewall::FirewallRuntime, + network::{AwdNetworkRuntime, WireGuardDesiredState}, + }, repo::event_repo, - service::network_policy_service, + service::firewall_service, }; use fcmc::AwdContainerRuntime; @@ -25,6 +28,7 @@ pub async fn recover_all( db: &DatabaseConnection, containers: &dyn AwdContainerRuntime, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, crypto: &AwdCrypto, ) -> AwdResult { let active_events = event_repo::find_active_events(db) @@ -34,7 +38,7 @@ pub async fn recover_all( let mut recovered = 0u32; for event in &active_events { - match recover_event(db, containers, network, crypto, event).await { + match recover_event(db, containers, network, firewall, crypto, event).await { Ok(n) => { recovered += n; info!( @@ -66,6 +70,7 @@ async fn recover_event( db: &DatabaseConnection, containers: &dyn AwdContainerRuntime, network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, crypto: &AwdCrypto, event: &awd_events::Model, ) -> AwdResult { @@ -173,24 +178,11 @@ async fn recover_event( ); } - // ── 3. Restore firewall for current phase ── - network_policy_service::apply_phase_policy(db, network, event.event_id, event.phase.clone()) + // ── 3. Restore firewall(nftables 全局 desired-state reconcile,DB 是事实源)── + // 失败 Fail Closed:不吞错,由调用方决定是否置 NetworkError。 + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) .await?; - // conntrack cleanup:策略已应用后尽力清理既有连接,失败显式记录(Phase 0 P0-4 吞错扫描)。 - // 注:Phase 1 nftables reconcile 落地后,conntrack 清理纳入 desired-state 操作的失败模型。 - if let Err(e) = network - .clear_event_connections(EventNetworkIdentity { - event_id: event.event_id, - gamebox_cidr: event.gamebox_cidr.clone(), - }) - .await - { - tracing::error!( - "[Recovery] conntrack cleanup failed for event {}: {}", - event.event_id, - e - ); - } + firewall_service::flush_event_connections(network, event.event_id, &event.gamebox_cidr).await; recovered += 1; Ok(recovered) diff --git a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs index 17562694..f4511264 100644 --- a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs +++ b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs @@ -140,7 +140,7 @@ pub async fn ensure_peer_for_user( // Host peer add when AWD_HOST_NETWORK is enabled (mirrors HostNetworkRuntime selection). let allowed = assigned_ip.clone(); add_peer_on_host( - awd_config.host_network, + awd_config.network_runtime == "host", &awd_event.wireguard_interface_name, &kp.public_key, &allowed, diff --git a/apps/api/src/modules/event/awd_team/system/command.rs b/apps/api/src/modules/event/awd_team/system/command.rs index 195255b3..a625efc8 100644 --- a/apps/api/src/modules/event/awd_team/system/command.rs +++ b/apps/api/src/modules/event/awd_team/system/command.rs @@ -139,41 +139,6 @@ pub mod wireguard_cmd { } } -/// Build `iptables` / `iptables-restore` command arguments safely. -pub mod firewall_cmd { - use super::*; - - pub async fn apply_rules(runner: &dyn CommandRunner, rules: &str) -> anyhow::Result<()> { - // Write rules to temp file, then iptables-restore - let tmp = tempfile::NamedTempFile::new()?; - std::fs::write(tmp.path(), rules)?; - let args = vec![ - "iptables-restore".to_string(), - "--noflush".to_string(), - tmp.path().to_string_lossy().to_string(), - ]; - // Actually use iptables-restore as the command - runner.run("iptables-restore", &args[1..]).await?; - Ok(()) - } - - pub async fn save_snapshot( - runner: &dyn CommandRunner, - chain_prefix: &str, - ) -> anyhow::Result { - let out = runner - .run("iptables-save", &["-t".to_string(), "filter".to_string()]) - .await?; - // Filter lines matching chain_prefix - let filtered: Vec<&str> = out - .stdout - .lines() - .filter(|l| l.contains(chain_prefix)) - .collect(); - Ok(filtered.join("\n")) - } -} - /// Build `conntrack` command arguments safely. pub mod conntrack_cmd { use super::*; diff --git a/apps/api/src/modules/event/awd_team/system/firewall.rs b/apps/api/src/modules/event/awd_team/system/firewall.rs deleted file mode 100644 index b29c60d9..00000000 --- a/apps/api/src/modules/event/awd_team/system/firewall.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Firewall rules engine — renders and applies iptables rules from DB state. -//! -//! # Design principles -//! -//! 1. Database is the single source of truth for network policy. -//! 2. Rules are rendered to iptables-restore format (atomic apply). -//! 3. Current AWD chain state is snapshotted before apply (for rollback). -//! 4. Only manage AWD-specific chains (never overwrite Docker's rules). -//! 5. Chain names use short event hash to respect iptables 28-char limit. - -use crate::modules::event::awd_team::{ - AwdError, AwdResult, - domain::Ipv4Cidr, - system::command::{CommandRunner, firewall_cmd}, -}; -use sha2::{Digest, Sha256}; - -/// Short hash for chain naming (first 8 hex chars of SHA-256). -pub fn short_hash(input: &str) -> String { - let hash = Sha256::digest(input.as_bytes()); - hex::encode(&hash[..4]) // 8 hex chars -} - -/// Chain name conventions for AWD. -/// -/// ```text -/// FCTF-AWD — master chain in DOCKER-USER -/// FCTF-A-{event_short} — allow rules (per-event) -/// FCTF-X-{event_short} — deny rules (per-event) -/// FCTF-H-{event_short} — hardening phase rules -/// FCTF-P-{event_short} — pause phase rules -/// FCTF-B-{event_short}-{team_short} — ban rules (per-team) -/// ``` -pub struct ChainNames { - pub master: String, - pub allow: String, - pub deny: String, - pub hardening: String, - pub pause: String, -} - -impl ChainNames { - pub fn for_event(event_id: &str) -> Self { - let h = short_hash(event_id); - Self { - master: "FCTF-AWD".to_string(), - allow: format!("FCTF-A-{}", h), - deny: format!("FCTF-X-{}", h), - hardening: format!("FCTF-H-{}", h), - pause: format!("FCTF-P-{}", h), - } - } - - pub fn ban_chain(event_id: &str, team_id: &str) -> String { - format!("FCTF-B-{}-{}", short_hash(event_id), short_hash(team_id)) - } -} - -/// Rendered iptables rules for a specific event phase. -#[derive(Debug, Clone)] -pub struct RenderedRules { - pub iptables_restore_input: String, - pub description: String, -} - -/// Render hardening phase rules. -/// -/// During hardening: -/// - Players can access their own team's GameBoxes -/// - Players cannot access other teams' GameBoxes -/// - GameBoxes cannot communicate with each other -/// - GameBoxes cannot access WireGuard subnet -/// - GameBoxes cannot access FlagServer/JudgeServer -/// - JudgeServer can reach all GameBoxes -/// - Players cannot access FlagServer/JudgeServer -pub fn render_hardening_rules( - event_id: &str, - gamebox_cidr: &Ipv4Cidr, - _wg_cidr: &Ipv4Cidr, - flagserver_ip: &str, - judgeserver_ip: &str, - // (team_id, gamebox_subnet, wireguard_subnet) — players reach GameBoxes from WG subnet. - team_subnets: &[(String, String, String)], -) -> RenderedRules { - let chains = ChainNames::for_event(event_id); - let mut rules = Vec::new(); - - // Create chain - rules.push(format!(":{} -", chains.hardening)); - - // Allow established/related - rules.push(format!( - "-A {} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", - chains.hardening - )); - - // Allow JudgeServer → all GameBoxes (health checks) - rules.push(format!( - "-A {} -s {} -d {} -j ACCEPT", - chains.hardening, - judgeserver_ip, - gamebox_cidr.to_string() - )); - - // Allow players (team WireGuard /24) → their own team's GameBox subnet. - // Ban chain can still DROP for banned teams (jump target). - for (team_id, gamebox_subnet, wg_subnet) in team_subnets { - let ban_chain = ChainNames::ban_chain(event_id, team_id); - rules.push(format!( - "-A {} -s {} -d {} -j {}", - chains.hardening, wg_subnet, gamebox_subnet, ban_chain - )); - } - - // Block GameBox → other GameBoxes (no lateral movement) - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.hardening, - gamebox_cidr.to_string(), - gamebox_cidr.to_string() - )); - - // Block GameBox → WireGuard subnet - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.hardening, - gamebox_cidr.to_string(), - _wg_cidr.to_string() - )); - - // Block GameBox → FlagServer - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.hardening, - gamebox_cidr.to_string(), - flagserver_ip - )); - - // Block GameBox → JudgeServer - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.hardening, - gamebox_cidr.to_string(), - judgeserver_ip - )); - - // Block players → FlagServer/JudgeServer - rules.push(format!( - "-A {} -s 0.0.0.0/0 -d {} -j DROP", - chains.hardening, flagserver_ip - )); - rules.push(format!( - "-A {} -s 0.0.0.0/0 -d {} -j DROP", - chains.hardening, judgeserver_ip - )); - - // Block public internet from GameBoxes - rules.push(format!( - "-A {} -s {} -d 0.0.0.0/8 -j DROP", - chains.hardening, - gamebox_cidr.to_string() - )); - rules.push(format!( - "-A {} -s {} -d 10.0.0.0/8 -j DROP", - chains.hardening, - gamebox_cidr.to_string() - )); - rules.push(format!( - "-A {} -s {} -d 172.16.0.0/12 -j DROP", - chains.hardening, - gamebox_cidr.to_string() - )); - rules.push(format!( - "-A {} -s {} -d 192.168.0.0/16 -j DROP", - chains.hardening, - gamebox_cidr.to_string() - )); - - // Return to master chain - rules.push(format!("-A {} -j RETURN", chains.hardening)); - - // Wrap in iptables-restore format - let output = format!("*filter\n{}\nCOMMIT\n", rules.join("\n")); - - RenderedRules { - iptables_restore_input: output, - description: format!("Hardening rules for event {}", short_hash(event_id)), - } -} - -/// Render attack phase rules. -/// -/// During attack: -/// - All players can reach all GameBoxes (SSH) -/// - GameBoxes can reach all other GameBoxes -/// - GameBoxes can access FlagServer (for flag requests) -/// - GameBoxes CANNOT access JudgeServer -/// - Players CANNOT access FlagServer/JudgeServer -/// - Cross-team WireGuard isolation still applies -pub fn render_attack_rules( - event_id: &str, - gamebox_cidr: &Ipv4Cidr, - wg_cidr: &Ipv4Cidr, - flagserver_ip: &str, - judgeserver_ip: &str, -) -> RenderedRules { - let chains = ChainNames::for_event(event_id); - let mut rules = Vec::new(); - let wg = wg_cidr.to_string(); - let gb = gamebox_cidr.to_string(); - - rules.push(format!(":{} -", chains.allow)); - - // Allow established/related - rules.push(format!( - "-A {} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", - chains.allow - )); - - // Allow all GameBox ↔ GameBox - rules.push(format!("-A {} -s {} -d {} -j ACCEPT", chains.allow, gb, gb)); - - // Allow GameBox → FlagServer - rules.push(format!( - "-A {} -s {} -d {} -j ACCEPT", - chains.allow, gb, flagserver_ip - )); - - // Block GameBox → JudgeServer - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.allow, gb, judgeserver_ip - )); - - // Players on WireGuard subnet → all GameBoxes (attack phase open) - rules.push(format!("-A {} -s {} -d {} -j ACCEPT", chains.allow, wg, gb)); - - // Block players (WG) → FlagServer/JudgeServer - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.allow, wg, flagserver_ip - )); - rules.push(format!( - "-A {} -s {} -d {} -j DROP", - chains.allow, wg, judgeserver_ip - )); - - rules.push(format!("-A {} -j RETURN", chains.allow)); - - let output = format!("*filter\n{}\nCOMMIT\n", rules.join("\n")); - - RenderedRules { - iptables_restore_input: output, - description: format!("Attack rules for event {}", short_hash(event_id)), - } -} - -/// Render pause phase rules — restrict to WireGuard + SSH only. -pub fn render_pause_rules(event_id: &str, gamebox_cidr: &Ipv4Cidr) -> RenderedRules { - let chains = ChainNames::for_event(event_id); - let mut rules = Vec::new(); - - rules.push(format!(":{} -", chains.pause)); - - // Allow established/related - rules.push(format!( - "-A {} -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", - chains.pause - )); - - // Block ALL new connections from/to GameBoxes - rules.push(format!( - "-A {} -s {} -j DROP", - chains.pause, - gamebox_cidr.to_string() - )); - rules.push(format!( - "-A {} -d {} -j DROP", - chains.pause, - gamebox_cidr.to_string() - )); - - rules.push(format!("-A {} -j RETURN", chains.pause)); - - let output = format!("*filter\n{}\nCOMMIT\n", rules.join("\n")); - - RenderedRules { - iptables_restore_input: output, - description: format!("Pause rules for event {}", short_hash(event_id)), - } -} - -/// Render ban rules for a specific team. -pub fn render_ban_rules(event_id: &str, team_id: &str, team_subnet: &str) -> RenderedRules { - let ban_chain = ChainNames::ban_chain(event_id, team_id); - let mut rules = Vec::new(); - - rules.push(format!(":{} -", ban_chain)); - - // Block all traffic from team's GameBox subnet - rules.push(format!("-A {} -s {}/24 -j DROP", ban_chain, team_subnet)); - rules.push(format!("-A {} -d {}/24 -j DROP", ban_chain, team_subnet)); - - rules.push(format!("-A {} -j RETURN", ban_chain)); - - let output = format!("*filter\n{}\nCOMMIT\n", rules.join("\n")); - - RenderedRules { - iptables_restore_input: output, - description: format!("Ban rules for team {}", short_hash(team_id)), - } -} - -/// Merge multiple per-event rule renders into a single atomic iptables-restore input. -/// -/// Prevents one event's restore from wiping another event's FCTF-* chains. -/// Each input should be a complete `*filter ... COMMIT` blob from `render_*`. -pub fn merge_rendered_rules(parts: &[RenderedRules]) -> RenderedRules { - if parts.is_empty() { - return RenderedRules { - iptables_restore_input: "*filter\nCOMMIT\n".to_string(), - description: "empty multi-event rules".into(), - }; - } - if parts.len() == 1 { - return parts[0].clone(); - } - - let mut body_lines: Vec = Vec::new(); - let mut descs: Vec = Vec::new(); - for p in parts { - descs.push(p.description.clone()); - for line in p.iptables_restore_input.lines() { - let t = line.trim(); - if t.is_empty() || t == "*filter" || t == "COMMIT" { - continue; - } - body_lines.push(line.to_string()); - } - } - // Deduplicate identical chain/rule lines while preserving order. - let mut seen = std::collections::HashSet::new(); - body_lines.retain(|l| seen.insert(l.clone())); - - let output = format!("*filter\n{}\nCOMMIT\n", body_lines.join("\n")); - RenderedRules { - iptables_restore_input: output, - description: format!("merged: {}", descs.join(" | ")), - } -} - -/// Apply rendered rules via iptables-restore with backup/rollback support. -pub async fn apply_rules(runner: &dyn CommandRunner, rules: &RenderedRules) -> AwdResult<()> { - // 1. Save current snapshot - let snapshot = firewall_cmd::save_snapshot(runner, "FCTF-") - .await - .map_err(|e| AwdError::Network(format!("Failed to snapshot rules: {}", e)))?; - - // 2. Apply new rules - if let Err(e) = firewall_cmd::apply_rules(runner, &rules.iptables_restore_input).await { - // 3. Rollback on failure - tracing::error!("Firewall apply failed, rolling back: {}", e); - if let Err(rb_err) = firewall_cmd::apply_rules(runner, &snapshot).await { - tracing::error!("Rollback also failed: {}", rb_err); - return Err(AwdError::Network(format!( - "Firewall apply failed ({}) AND rollback failed ({})", - e, rb_err - ))); - } - return Err(AwdError::Network(format!( - "Firewall apply failed, rolled back: {}", - e - ))); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_short_hash_deterministic() { - let h1 = short_hash("test-event-id"); - let h2 = short_hash("test-event-id"); - assert_eq!(h1, h2); - } - - #[test] - fn test_chain_names_length() { - let chains = ChainNames::for_event("550e8400-e29b-41d4-a716-446655440000"); - assert!(chains.master.len() <= 28, "master chain too long"); - assert!(chains.allow.len() <= 28, "allow chain too long"); - assert!(chains.deny.len() <= 28, "deny chain too long"); - } - - #[test] - fn test_render_hardening_has_established() { - let cidr = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let wg = Ipv4Cidr::parse("10.1.0.0/16").unwrap(); - let rules = render_hardening_rules("test", &cidr, &wg, "10.0.0.1", "10.0.0.2", &[]); - assert!(rules.iptables_restore_input.contains("ESTABLISHED,RELATED")); - } - - #[test] - fn test_render_attack_allows_gamebox_to_flagserver() { - let cidr = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let wg = Ipv4Cidr::parse("10.1.0.0/16").unwrap(); - let rules = render_attack_rules("test", &cidr, &wg, "10.0.0.1", "10.0.0.2"); - assert!(rules.iptables_restore_input.contains("10.0.0.1")); - assert!(rules.iptables_restore_input.contains("ACCEPT")); - } - - #[test] - fn test_render_pause_drops_all_gamebox() { - let cidr = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let rules = render_pause_rules("test", &cidr); - assert!(rules.iptables_restore_input.contains("DROP")); - } - - #[test] - fn test_ban_chain_name_format() { - let ban = ChainNames::ban_chain("event1", "team1"); - assert!(ban.starts_with("FCTF-B-")); - assert!(ban.len() <= 28); - } - - #[test] - fn test_merge_rendered_rules_keeps_both_events() { - let cidr = Ipv4Cidr::parse("10.0.0.0/16").unwrap(); - let wg = Ipv4Cidr::parse("10.1.0.0/16").unwrap(); - let a = render_pause_rules("event-a", &cidr); - let b = render_attack_rules("event-b", &cidr, &wg, "10.0.0.1", "10.0.0.2"); - let m = merge_rendered_rules(&[a, b]); - assert!(m.iptables_restore_input.starts_with("*filter")); - assert!(m.iptables_restore_input.contains("COMMIT")); - // Both event chain hashes should appear. - assert!(m.iptables_restore_input.contains(&short_hash("event-a"))); - assert!(m.iptables_restore_input.contains(&short_hash("event-b"))); - assert_eq!(m.iptables_restore_input.matches("COMMIT").count(), 1); - } -} diff --git a/apps/api/src/modules/event/awd_team/system/mod.rs b/apps/api/src/modules/event/awd_team/system/mod.rs index 88c92c0e..83c80a34 100644 --- a/apps/api/src/modules/event/awd_team/system/mod.rs +++ b/apps/api/src/modules/event/awd_team/system/mod.rs @@ -2,11 +2,10 @@ //! //! # Safety //! -//! This module is the ONLY place where system commands (iptables, wg, ip, conntrack) +//! This module is the ONLY place where system commands (nft, wg, ip, conntrack) //! may be executed. All external command parameters use structured argument //! passing — never shell string concatenation. pub mod command; pub mod conntrack; -pub mod firewall; pub mod wireguard; From c3689825467537b2b89b2a16161cd6230230a73d Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 12:16:27 +0800 Subject: [PATCH 003/486] =?UTF-8?q?feat(awd):=20DeployFailed=20=E5=86=99?= =?UTF-8?q?=E5=85=A5=E8=B7=AF=E5=BE=84=20+=20WG=20=E7=A7=81=E9=92=A5?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E8=BF=94=E5=9B=9E=20+=20P1-0/P1-1?= =?UTF-8?q?=20=E8=B5=84=E4=BA=A7=EF=BC=88Phase=201=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy_event 失败落 DeployFailed(不再停留 Deploying),幂等重入不覆盖新状态 - ensure_infra_container 核验实际容器(DB 有记录但容器消失 → 重建) - awd_wireguard_peers.config_fetched_at 迁移:私钥仅首次拉取返回(P1-15) - docs/awd-nftables-host-discovery.md:P1-1 host 发现与 priority=1 决策依据 - apps/api/scripts/nft_prototype.sh:P1-0 强制 gate 原型脚本(root 环境门控) --- apps/api/scripts/nft_prototype.sh | 191 +++++++ apps/api/src/bootstrap/mod.rs | 3 +- apps/api/src/entity/awd_wireguard_peers.rs | 1 + .../src/modules/event/awd_team/api/admin.rs | 29 +- .../src/modules/event/awd_team/api/player.rs | 14 + .../awd_team/infrastructure/firewall/env.rs | 16 +- .../awd_team/infrastructure/firewall/mod.rs | 14 +- .../infrastructure/firewall/nftables.rs | 58 +- .../infrastructure/firewall/render.rs | 24 +- .../infrastructure/network/runtime.rs | 16 + .../event/awd_team/repo/wireguard_repo.rs | 42 ++ .../event/awd_team/service/deploy_service.rs | 513 +++++++++++++++--- .../event/awd_team/service/event_service.rs | 24 +- .../awd_team/service/firewall_service.rs | 37 +- .../awd_team/service/recovery_service.rs | 8 +- .../awd_team/service/wireguard_service.rs | 458 +++++++++++++++- apps/api/src/sql/merged.sql | 35 +- .../20260808120928-awd-wg-config-fetched.sql | 16 + apps/web/src/entity/awd_wireguard_peers.ts | 1 + docs/awd-nftables-host-discovery.md | 98 ++++ 20 files changed, 1432 insertions(+), 166 deletions(-) create mode 100755 apps/api/scripts/nft_prototype.sh create mode 100644 apps/api/src/sql/migrations/20260808120928-awd-wg-config-fetched.sql create mode 100644 docs/awd-nftables-host-discovery.md diff --git a/apps/api/scripts/nft_prototype.sh b/apps/api/scripts/nft_prototype.sh new file mode 100755 index 00000000..a2343cf9 --- /dev/null +++ b/apps/api/scripts/nft_prototype.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# ============================================================================== +# P1-0 nftables networking prototype(强制 gate) +# +# 在任何 production renderer 上生产环境之前,用最小网络原型验证 +# Netfilter/nftables topology + hook/priority + 三阶段策略矩阵 + Docker 共存。 +# +# 规模:1 event / 2 teams / 每队 1 GameBox / 1 WG-like 源命名空间(模拟玩家) +# +# 使用独立测试 table `floatctf_awd_test`,脚本结束自动 cleanup; +# 不写业务数据库。需要 root / CAP_NET_ADMIN。 +# +# 用法: +# sudo apps/api/scripts/nft_prototype.sh +# ============================================================================== +set -Eeuo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "错误:需要 root(nft 需要 CAP_NET_ADMIN)" >&2 + exit 1 +fi + +TABLE="inet floatctf_awd_test" +PRIORITY=1 # 与 render.rs::FORWARD_PRIORITY 一致 + +failures=0 +pass() { echo " PASS $1"; } +fail() { echo " FAIL $1"; failures=$((failures+1)); } + +cleanup() { + nft delete table $TABLE 2>/dev/null || true + ip link del veth-a 2>/dev/null || true + ip link del veth-b 2>/dev/null || true + ip link del veth-wg 2>/dev/null || true + ip netns del ns-a 2>/dev/null || true + ip netns del ns-b 2>/dev/null || true + ip netns del ns-wg 2>/dev/null || true +} +trap cleanup EXIT +cleanup + +echo "=== P1-0 nftables networking prototype ===" +echo "table: $TABLE, forward priority: $PRIORITY" + +# ── 拓扑:ns-wg(玩家)→ veth → 宿主 → veth → ns-a/ns-b(GameBox 模拟)── +# 玩家经 WG 子网 172.31.0.0/16;GameBox 在 10.42.0.0/16 + +setup_ns() { # name, addr + local ns=$1 addr=$2 + ip netns add "$ns" + ip link add "veth-$ns" type veth peer name "v0-$ns" + ip link set "v0-$ns" netns "$ns" + ip addr add "10.255.255.${3:-10}/24" dev "veth-$ns" 2>/dev/null || true + ip link set "veth-$ns" up + ip netns exec "$ns" ip addr add "$addr" dev "v0-$ns" + ip netns exec "$ns" ip link set "v0-$ns" up + ip netns exec "$ns" ip link set lo up +} + +# 简化:一个 bridge br-awd-test 承载 GameBox 侧 +ip link add br-awd-test type bridge +ip link set br-awd-test up +ip addr add 10.42.0.1/16 dev br-awd-test + +# GameBox A / B 用 network namespace 挂在 bridge 上 +ip netns add gb-a +ip link add veth-gb-a type veth peer name v0-gb-a +ip link set veth-gb-a master br-awd-test +ip link set veth-gb-a up +ip link set v0-gb-a netns gb-a +ip netns exec gb-a ip addr add 10.42.1.10/24 dev v0-gb-a +ip netns exec gb-a ip link set v0-gb-a up +ip netns exec gb-a ip link set lo up +ip netns exec gb-a ip route add default via 10.42.1.1 + +ip netns add gb-b +ip link add veth-gb-b type veth peer name v0-gb-b +ip link set veth-gb-b master br-awd-test +ip link set veth-gb-b up +ip link set v0-gb-b netns gb-b +ip netns exec gb-b ip addr add 10.42.2.10/24 dev v0-gb-b +ip netns exec gb-b ip link set v0-gb-b up +ip netns exec gb-b ip link set lo up +ip netns exec gb-b ip route add default via 10.42.2.1 + +ip addr replace 10.42.1.1/24 dev br-awd-test +ip addr replace 10.42.2.1/24 dev br-awd-test +# 启用 br_netfilter 使 bridge 流量经过 forward hook(否则 iptables/nft 不生效) +modprobe br_netfilter 2>/dev/null || true +sysctl -w net.bridge.bridge-nf-call-iptables=1 >/dev/null 2>&1 || true +sysctl -w net.bridge.bridge-nf-call-ip6tables=1 >/dev/null 2>&1 || true + +# 玩家 WG 命名空间(模拟 Team A 玩家) +ip netns add ns-wg +ip link add veth-wg type veth peer name v0-wg +ip link set veth-wg up +ip addr add 172.31.0.1/24 dev veth-wg +ip link set v0-wg netns ns-wg +ip netns exec ns-wg ip addr add 172.31.0.10/24 dev v0-wg +ip netns exec ns-wg ip link set v0-wg up +ip netns exec ns-wg ip link set lo up +ip netns exec ns-wg ip route add default via 172.31.0.1 + +# 宿主导通测试路径(prototype 简化:宿主直接路由) +ip route add 172.31.0.0/24 dev veth-wg 2>/dev/null || true + +probe() { # src_ns, dst_ip, expect(PASS|BLOCK), label + local src_ns=$1 dst_ip=$2 expect=$3 label=$4 + if ip netns exec "$src_ns" ping -c1 -W1 "$dst_ip" >/dev/null 2>&1; then + if [[ $expect == PASS ]]; then pass "$label ($dst_ip)"; else fail "$label ($dst_ip) 预期 BLOCK 实际可达"; fi + else + if [[ $expect == BLOCK ]]; then pass "$label ($dst_ip)"; else fail "$label ($dst_ip) 预期 PASS 实际不可达"; fi + fi +} + +# ── 装载测试规则(对应 render.rs 三阶段策略)── +load_hardening() { + nft add table $TABLE + nft -f - <<'NFT' +table inet floatctf_awd_test { + chain awd_forward { + type filter hook forward priority 1; policy accept; + jump test_event + } + set ev_players_v4 { type ipv4_addr; flags interval; elements = { 172.31.0.0/24 } } + set ev_gb_a_v4 { type ipv4_addr; flags interval; elements = { 10.42.1.0/24 } } + set ev_gb_b_v4 { type ipv4_addr; flags interval; elements = { 10.42.2.0/24 } } + set ev_gameboxes_v4 { type ipv4_addr; flags interval; elements = { 10.42.1.0/24, 10.42.2.0/24 } } + chain test_event { + # hardening: own-team accept 先于跨队 drop(对应 render_event_chain) + ip saddr 172.31.0.0/24 ip daddr 10.42.1.0/24 accept + ip saddr @ev_players_v4 ip daddr @ev_gameboxes_v4 drop + ip saddr @ev_gameboxes_v4 drop + ip saddr @ev_players_v4 ip daddr 10.42.0.0/24 drop + } +} +NFT +} + +load_attack() { + nft add table $TABLE + nft -f - <<'NFT' +table inet floatctf_awd_test { + chain awd_forward { + type filter hook forward priority 1; policy accept; + jump test_event + } + set ev_players_v4 { type ipv4_addr; flags interval; elements = { 172.31.0.0/24 } } + set ev_gameboxes_v4 { type ipv4_addr; flags interval; elements = { 10.42.1.0/24, 10.42.2.0/24 } } + chain test_event { + ip saddr @ev_gameboxes_v4 ip daddr @ev_gameboxes_v4 accept + ip saddr @ev_gameboxes_v4 drop + ip saddr @ev_players_v4 ip daddr 10.42.0.0/24 drop + } +} +NFT +} + +# ── 验证矩阵 ── +echo +echo "--- Hardening 矩阵 ---" +load_hardening +probe ns-wg 10.42.1.10 PASS "Team A → 自己 GameBox" +probe ns-wg 10.42.2.10 BLOCK "Team A → 对方 GameBox" +probe ns-wg 10.42.0.1 BLOCK "Team A → infra" +probe gb-a 8.8.8.8 BLOCK "GameBox → Internet" + +echo +echo "--- Attack 矩阵 ---" +load_attack +probe ns-wg 10.42.2.10 PASS "Team A → 对方 GameBox(attack)" +probe ns-wg 10.42.0.1 BLOCK "Team A → infra(attack)" +probe gb-a 8.8.8.8 BLOCK "GameBox → Internet(attack)" + +echo +echo "--- Docker 共存冒烟 ---" +if docker ps >/dev/null 2>&1; then + docker ps --format '{{.Names}}' | head -3 | sed 's/^/ Docker 容器存活: /' + pass "Docker daemon 不受影响" +else + fail "docker 不可用(环境问题,非规则问题)" +fi + +echo +if [[ $failures -eq 0 ]]; then + echo "=== P1-0 全部 PASS:topology + priority + 三阶段矩阵验证通过 ===" + exit 0 +else + echo "=== P1-0 存在 $failures 项 FAIL:禁止进入 production renderer ===" + exit 1 +fi diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index c73e3fe8..bfdf38e3 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -142,7 +142,8 @@ pub async fn run() -> Result<(), BootstrapError> { > = if config.awd.network_runtime == "host" { info!("AWD firewall enabled — using NftablesFirewallRuntime"); Arc::new( - crate::modules::event::awd_team::infrastructure::firewall::NftablesFirewallRuntime::new(), + crate::modules::event::awd_team::infrastructure::firewall::NftablesFirewallRuntime::new( + ), ) } else { info!("AWD firewall disabled — using NoopFirewallRuntime (dev/mock only)"); diff --git a/apps/api/src/entity/awd_wireguard_peers.rs b/apps/api/src/entity/awd_wireguard_peers.rs index d8684198..d76ecd94 100644 --- a/apps/api/src/entity/awd_wireguard_peers.rs +++ b/apps/api/src/entity/awd_wireguard_peers.rs @@ -24,6 +24,7 @@ pub struct Model { pub created_at: DateTimeWithTimeZone, pub rotated_at: Option, pub revoked_at: Option, + pub config_fetched_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 8a0f7410..54213e1f 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -70,8 +70,29 @@ pub async fn create_awd_event( .encrypt(&js_token, &js_aad, 1) .map_err(|e| AppError::Internal(e.to_string()))?; + // ── P1-14:跨赛事 CIDR / IP 重叠校验(提交前)── + // 当前赛事尚未入库:存量 awd_events / awd_team_networks 全部视为“其他赛事”, + // 任何重叠/端口 IP 落入即拒绝创建。 + use crate::entity::{awd_events, awd_team_networks}; + let existing_events = awd_events::Entity::find() + .all(ctx.db.get_ref()) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + let existing_networks = awd_team_networks::Entity::find() + .all(ctx.db.get_ref()) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + crate::modules::event::awd_team::service::deploy_service::validate_no_cross_event_overlap( + &existing_events, + &existing_networks, + &b.gamebox_cidr, + &b.wireguard_cidr, + &b.flagserver_ip, + &b.judgeserver_ip, + ) + .map_err(AppError::from)?; + // Create the awd_events record - use crate::entity::awd_events; let model = awd_events::ActiveModel { id: Set(Uuid::new_v4()), event_id: Set(event_id), @@ -118,7 +139,7 @@ pub async fn start_awd_event( event_id, ) .await - .map_err(AppError::from)?; + .map_err(AppError::from)?; UniResponse::ok_none().into() } @@ -138,7 +159,7 @@ pub async fn pause_awd_event( event_id, ) .await - .map_err(AppError::from)?; + .map_err(AppError::from)?; UniResponse::ok_none().into() } @@ -158,7 +179,7 @@ pub async fn resume_awd_event( event_id, ) .await - .map_err(AppError::from)?; + .map_err(AppError::from)?; UniResponse::ok_none().into() } diff --git a/apps/api/src/modules/event/awd_team/api/player.rs b/apps/api/src/modules/event/awd_team/api/player.rs index 96a4aeeb..da83ab3f 100644 --- a/apps/api/src/modules/event/awd_team/api/player.rs +++ b/apps/api/src/modules/event/awd_team/api/player.rs @@ -226,6 +226,20 @@ pub async fn get_wireguard_config( .await .map_err(AppError::from)?; + // P1-15 私钥一次返回:首次拉取才返回私钥(此后 config_fetched_at 置位)。 + // 需要轮换/重新获取走 admin 轮换路径。 + if peer.config_fetched_at.is_some() { + return Err(AppError::Forbidden( + "WireGuard 私钥仅首次拉取返回;如需重新获取请联系管理员轮换密钥".into(), + )); + } + crate::modules::event::awd_team::repo::wireguard_repo::mark_wg_config_fetched( + ctx.db.get_ref(), + peer.id, + ) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + let awd_event = awd_events::Entity::find() .filter(awd_events::Column::EventId.eq(event_id)) .one(ctx.db.get_ref()) diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs index 2f5dd617..d0486734 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/env.rs @@ -72,9 +72,11 @@ pub async fn check_host_capability() -> AwdResult { let version = runner .run("nft", &["--version".into()]) .await - .map_err(|e| crate::modules::event::awd_team::AwdError::Network(format!( - "nft binary unavailable: {e}" - )))?; + .map_err(|e| { + crate::modules::event::awd_team::AwdError::Network(format!( + "nft binary unavailable: {e}" + )) + })?; if version.exit_code != 0 { return Ok(HostNetworkCapability::Unsupported(format!( "nft binary present but --version failed (exit {})", @@ -86,9 +88,11 @@ pub async fn check_host_capability() -> AwdResult { let probe = runner .run("nft", &["list".into(), "tables".into()]) .await - .map_err(|e| crate::modules::event::awd_team::AwdError::Network(format!( - "nft invocation failed: {e}" - )))?; + .map_err(|e| { + crate::modules::event::awd_team::AwdError::Network(format!( + "nft invocation failed: {e}" + )) + })?; if probe.exit_code != 0 { return Ok(HostNetworkCapability::Unsupported(format!( "nft list tables failed (exit {}): {} — missing nf_tables or CAP_NET_ADMIN", diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs index 55c3a389..3ffc53f7 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/mod.rs @@ -14,9 +14,7 @@ pub use render::{NftObjectName, ObservedFirewallState, TABLE_NAME}; use async_trait::async_trait; -use crate::modules::event::awd_team::{ - AwdResult, domain::firewall_state::DesiredFirewallState, -}; +use crate::modules::event::awd_team::{AwdResult, domain::firewall_state::DesiredFirewallState}; /// 一次 firewall reconcile 的结果。 #[derive(Debug, Clone)] @@ -42,10 +40,7 @@ pub struct FirewallVerification { #[async_trait] pub trait FirewallRuntime: Send + Sync { async fn inspect(&self) -> AwdResult; - async fn reconcile( - &self, - desired: &DesiredFirewallState, - ) -> AwdResult; + async fn reconcile(&self, desired: &DesiredFirewallState) -> AwdResult; async fn verify(&self, desired: &DesiredFirewallState) -> AwdResult; } @@ -59,10 +54,7 @@ impl FirewallRuntime for NoopFirewallRuntime { Ok(ObservedFirewallState::default()) } - async fn reconcile( - &self, - _desired: &DesiredFirewallState, - ) -> AwdResult { + async fn reconcile(&self, _desired: &DesiredFirewallState) -> AwdResult { Ok(FirewallApplyResult { revision: 0, applied: false, diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs index 59722380..3c0a45d7 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs @@ -19,8 +19,8 @@ use crate::modules::event::awd_team::{ }; use super::{ - FirewallApplyResult, FirewallRuntime, FirewallVerification, ObservedFirewallState, - TABLE_NAME, render, + FirewallApplyResult, FirewallRuntime, FirewallVerification, ObservedFirewallState, TABLE_NAME, + render, }; /// nft 可执行文件名。 @@ -65,11 +65,7 @@ impl NftablesFirewallRuntime { .runner .run( NFT_BIN, - &[ - "-c".to_string(), - "-f".to_string(), - path.to_string(), - ], + &["-c".to_string(), "-f".to_string(), path.to_string()], ) .await .map_err(|e| AwdError::Network(format!("nft -c failed to run: {e}")))?; @@ -118,15 +114,14 @@ impl FirewallRuntime for NftablesFirewallRuntime { let mut state = render::parse_observed_table(&out); if !state.table_exists && !out.trim().is_empty() { // 非空输出但无我们的 table —— 观测异常(他方规则存在,我们无权处理) - state.notes.push("nft list returned output without floatctf_awd table".into()); + state + .notes + .push("nft list returned output without floatctf_awd table".into()); } Ok(state) } - async fn reconcile( - &self, - desired: &DesiredFirewallState, - ) -> AwdResult { + async fn reconcile(&self, desired: &DesiredFirewallState) -> AwdResult { // 1. render 完整 table(整个 floatctf_awd 内容) let ruleset = render::render_table(desired); let tmp = tempfile::NamedTempFile::new() @@ -192,7 +187,10 @@ impl FirewallRuntime for NftablesFirewallRuntime { )); } for event in &desired.events { - let chain = format!("event_{}", render::NftObjectName::event_key(&event.event_id).as_str()); + let chain = format!( + "event_{}", + render::NftObjectName::event_key(&event.event_id).as_str() + ); if !observed.event_chains.contains(&chain) { notes.push(format!("missing chain {chain}")); } @@ -244,19 +242,10 @@ mod tests { #[async_trait] impl CommandRunner for FakeNftRunner { - async fn run( - &self, - program: &str, - args: &[String], - ) -> anyhow::Result { + async fn run(&self, program: &str, args: &[String]) -> anyhow::Result { assert_eq!(program, "nft"); if args.first().map(|s| s.as_str()) == Some("list") { - let out = self - .store - .lock() - .unwrap() - .clone() - .unwrap_or_default(); + let out = self.store.lock().unwrap().clone().unwrap_or_default(); return Ok(CommandOutput { exit_code: if out.is_empty() { 1 } else { 0 }, stdout: out, @@ -340,19 +329,15 @@ mod tests { let _ = rt.reconcile(&desired).await; let cmds = recorder.recorded(); - let nft_cmds: Vec<(String, Vec)> = cmds - .into_iter() - .filter(|(p, _)| p == "nft") - .collect(); + let nft_cmds: Vec<(String, Vec)> = + cmds.into_iter().filter(|(p, _)| p == "nft").collect(); // list → (-c -f) → delete(-f) → (-f) → list → list let check_idx = nft_cmds .iter() .position(|(_, a)| a.first().map(|s| s.as_str()) == Some("-c")); let apply_idx = nft_cmds .iter() - .rposition(|(_, a)| { - a.first().map(|s| s.as_str()) == Some("-f") - }); + .rposition(|(_, a)| a.first().map(|s| s.as_str()) == Some("-f")); assert!(check_idx.is_some(), "must run nft -c first"); assert!(apply_idx.is_some(), "must run nft -f"); assert!(check_idx.unwrap() < apply_idx.unwrap()); @@ -363,11 +348,7 @@ mod tests { struct FailCheck; #[async_trait] impl CommandRunner for FailCheck { - async fn run( - &self, - _program: &str, - args: &[String], - ) -> anyhow::Result { + async fn run(&self, _program: &str, args: &[String]) -> anyhow::Result { if args.contains(&"-c".to_string()) { return Ok(CommandOutput { exit_code: 1, @@ -383,7 +364,10 @@ mod tests { } } let rt = NftablesFirewallRuntime::with_runner(Arc::new(FailCheck)); - let err = rt.reconcile(&sample_desired(1)).await.expect_err("must fail"); + let err = rt + .reconcile(&sample_desired(1)) + .await + .expect_err("must fail"); assert!(err.to_string().contains("syntax")); } } diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs index f2546f5f..b539df6f 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs @@ -17,7 +17,9 @@ use sha2::{Digest, Sha256}; use crate::entity::sea_orm_active_enums::AwdPhase; -use crate::modules::event::awd_team::domain::firewall_state::{DesiredEventPolicy, DesiredFirewallState}; +use crate::modules::event::awd_team::domain::firewall_state::{ + DesiredEventPolicy, DesiredFirewallState, +}; /// FloatCTF 唯一拥有的 nftables table。 pub const TABLE_NAME: &str = "floatctf_awd"; @@ -72,7 +74,9 @@ pub fn render_table(desired: &DesiredFirewallState) -> String { out.push_str(&format!( " type filter hook forward priority {FORWARD_PRIORITY}; policy accept;\n" )); - out.push_str(" # managed by FloatCTF: restrictive DROP only; no iptables/Docker rules touched\n"); + out.push_str( + " # managed by FloatCTF: restrictive DROP only; no iptables/Docker rules touched\n", + ); out.push_str(" ip saddr @banned_players_v4 drop\n"); out.push_str(" ip6 saddr @banned_players_v6 drop\n"); for event in &desired.events { @@ -213,7 +217,11 @@ pub fn parse_observed_table(output: &str) -> ObservedFirewallState { let line = line.trim(); if let Some(idx) = line.find("managed-by=floatctf revision=") { let rest = &line[idx + "managed-by=floatctf revision=".len()..]; - if let Ok(n) = rest.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("").parse::() + if let Ok(n) = rest + .split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap_or("") + .parse::() { state.observed_revision = Some(n); } @@ -263,7 +271,9 @@ mod tests { ], banned_teams: if banned { vec![team_a] } else { vec![] }, }; - event.event_key = NftObjectName::event_key(&event.event_id).as_str().to_string(); + event.event_key = NftObjectName::event_key(&event.event_id) + .as_str() + .to_string(); event } @@ -378,6 +388,10 @@ mod tests { assert_eq!(k1, k2); assert!(k1.as_str().len() <= 16); assert!(k1.as_str().starts_with("ev_")); - assert!(k1.as_str().chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')); + assert!( + k1.as_str() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + ); } } diff --git a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs index a298183a..38767961 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/network/runtime.rs @@ -58,6 +58,9 @@ pub trait AwdNetworkRuntime: Send + Sync { async fn ensure_wireguard(&self, desired: WireGuardDesiredState) -> AwdResult<()>; async fn remove_wireguard(&self, interface: &str) -> AwdResult<()>; async fn revoke_peer(&self, peer: PeerIdentity) -> AwdResult<()>; + /// 把 peer(public_key + allowed-ips)加回接口(幂等)。Noop 下为 no-op。 + /// Host 实现 = `wg set peer allowed-ips `(system::wireguard::add_peer)。 + async fn add_peer(&self, peer: PeerIdentity, allowed_ips: &str) -> AwdResult<()>; async fn clear_event_connections(&self, event: EventNetworkIdentity) -> AwdResult<()>; async fn clear_team_connections(&self, team: TeamNetworkIdentity) -> AwdResult<()>; async fn inspect(&self, event: EventNetworkIdentity) -> AwdResult; @@ -112,6 +115,16 @@ impl AwdNetworkRuntime for HostNetworkRuntime { wireguard::remove_peer(self.runner(), &peer.interface, &peer.public_key).await } + async fn add_peer(&self, peer: PeerIdentity, allowed_ips: &str) -> AwdResult<()> { + wireguard::add_peer( + self.runner(), + &peer.interface, + &peer.public_key, + allowed_ips, + ) + .await + } + async fn clear_event_connections(&self, event: EventNetworkIdentity) -> AwdResult<()> { conntrack::flush_event_gamebox_traffic(self.runner(), &event.gamebox_cidr).await } @@ -145,6 +158,9 @@ impl AwdNetworkRuntime for NoopNetworkRuntime { async fn revoke_peer(&self, _peer: PeerIdentity) -> AwdResult<()> { Ok(()) } + async fn add_peer(&self, _peer: PeerIdentity, _allowed_ips: &str) -> AwdResult<()> { + Ok(()) + } async fn clear_event_connections(&self, _event: EventNetworkIdentity) -> AwdResult<()> { Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/repo/wireguard_repo.rs b/apps/api/src/modules/event/awd_team/repo/wireguard_repo.rs index 53d121bb..9cf97063 100644 --- a/apps/api/src/modules/event/awd_team/repo/wireguard_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/wireguard_repo.rs @@ -7,6 +7,18 @@ use uuid::Uuid; use crate::entity::{awd_wireguard_peers, sea_orm_active_enums::WgPeerStatus}; +pub async fn find_peer_by_id( + db: &DatabaseConnection, + event_id: Uuid, + peer_id: Uuid, +) -> Result, sea_orm::DbErr> { + awd_wireguard_peers::Entity::find() + .filter(awd_wireguard_peers::Column::Id.eq(peer_id)) + .filter(awd_wireguard_peers::Column::EventId.eq(event_id)) + .one(db) + .await +} + pub async fn find_peer_by_user( db: &DatabaseConnection, event_id: Uuid, @@ -53,19 +65,49 @@ pub async fn revoke_peer(db: &DatabaseConnection, id: Uuid) -> Result<(), sea_or Ok(()) } +/// 标记 WG 配置(含私钥)已首次拉取(P1-15 私钥一次返回)。 +pub async fn mark_wg_config_fetched( + db: &DatabaseConnection, + id: Uuid, +) -> Result<(), sea_orm::DbErr> { + let mut active: awd_wireguard_peers::ActiveModel = awd_wireguard_peers::ActiveModel { + id: Set(id), + config_fetched_at: Set(Some(chrono::Utc::now().into())), + ..Default::default() + }; + active.update(db).await?; + Ok(()) +} + +/// 轮换中间态:置 Rotating + 记录 rotated_at(host 操作前的 DB 标记,配合 Fail Closed)。 +pub async fn mark_peer_rotating(db: &DatabaseConnection, id: Uuid) -> Result<(), sea_orm::DbErr> { + let mut active: awd_wireguard_peers::ActiveModel = awd_wireguard_peers::ActiveModel { + id: Set(id), + status: Set(WgPeerStatus::Rotating), + rotated_at: Set(Some(chrono::Utc::now().into())), + ..Default::default() + }; + active.update(db).await?; + Ok(()) +} + +/// 轮换成功落库:置回 Active + 新密钥 + 事件当前 key_version(不再硬编码 1)。 pub async fn rotate_peer( db: &DatabaseConnection, id: Uuid, new_public_key: &str, private_key_ciphertext: &[u8], private_key_nonce: &[u8], + key_version: i32, ) -> Result<(), sea_orm::DbErr> { let mut active: awd_wireguard_peers::ActiveModel = awd_wireguard_peers::ActiveModel { id: Set(id), + status: Set(WgPeerStatus::Active), public_key: Set(new_public_key.to_string()), private_key_ciphertext: Set(private_key_ciphertext.to_vec()), private_key_nonce: Set(private_key_nonce.to_vec()), rotated_at: Set(Some(chrono::Utc::now().into())), + key_version: Set(key_version), ..Default::default() }; active.update(db).await?; diff --git a/apps/api/src/modules/event/awd_team/service/deploy_service.rs b/apps/api/src/modules/event/awd_team/service/deploy_service.rs index 6259aeff..d310716c 100644 --- a/apps/api/src/modules/event/awd_team/service/deploy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/deploy_service.rs @@ -5,6 +5,7 @@ use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, TransactionTrait, }; use tracing::{info, warn}; use uuid::Uuid; @@ -75,73 +76,110 @@ pub async fn deploy_event( .await?; } - // ── 1. Docker network ── - let (docker_network_id, docker_network_name) = - ensure_docker_network(db, containers, &awd_event, event_id).await?; - awd_event.docker_network_id = Some(docker_network_id.clone()); - awd_event.docker_network_name = Some(docker_network_name.clone()); + // ── 部署步骤(失败 → DeployFailed,P1-16)── + let deploy_result: AwdResult<()> = async { + // 1. Docker network + let (docker_network_id, docker_network_name) = + ensure_docker_network(db, containers, &awd_event, event_id).await?; + awd_event.docker_network_id = Some(docker_network_id.clone()); + awd_event.docker_network_name = Some(docker_network_name.clone()); - // ── 2–3. FlagServer + JudgeServer ── - ensure_infra_container( - db, - containers, - crypto, - &awd_event, - event_id, - "flagserver", - &awd_event.flagserver_ip, - &docker_network_name, - awd_config.flagserver_image.clone(), - &awd_event.flagserver_token_ciphertext, - &awd_event.flagserver_token_nonce, - ) - .await?; - ensure_infra_container( - db, - containers, - crypto, - &awd_event, - event_id, - "judgeserver", - &awd_event.judgeserver_ip, - &docker_network_name, - awd_config.judgeserver_image.clone(), - &awd_event.judgeserver_token_ciphertext, - &awd_event.judgeserver_token_nonce, - ) - .await?; + // ── 2–3. FlagServer + JudgeServer ── + ensure_infra_container( + db, + containers, + crypto, + &awd_event, + event_id, + "flagserver", + &awd_event.flagserver_ip, + &docker_network_name, + awd_config.flagserver_image.clone(), + &awd_event.flagserver_token_ciphertext, + &awd_event.flagserver_token_nonce, + ) + .await?; + ensure_infra_container( + db, + containers, + crypto, + &awd_event, + event_id, + "judgeserver", + &awd_event.judgeserver_ip, + &docker_network_name, + awd_config.judgeserver_image.clone(), + &awd_event.judgeserver_token_ciphertext, + &awd_event.judgeserver_token_nonce, + ) + .await?; - // ── 4–5. Team nets + GameBoxes ── - ensure_teams_and_gameboxes( - db, - containers, - crypto, - &awd_event, - event_id, - &docker_network_id, - &docker_network_name, - ) - .await?; + // ── 4–5. Team nets + GameBoxes ── + ensure_teams_and_gameboxes( + db, + containers, + crypto, + &awd_event, + event_id, + &docker_network_id, + &docker_network_name, + ) + .await?; - // ── 6. WireGuard ── - ensure_wireguard(db, network, crypto, awd_config, &awd_event, event_id).await?; + // ── 6. WireGuard ── + ensure_wireguard(db, network, crypto, awd_config, &awd_event, event_id).await?; - // ── 7. Hardening firewall(全局 desired-state reconcile,Phase 1 P1-10)── - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + // ── 7. Hardening firewall(全局 desired-state reconcile,Phase 1 P1-10)── + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) .await?; - // ── 8. Deployed(状态机唯一入口)── - event_repo::transition_event( - db, - awd_event.id, - AwdEventStatus::Deploying, - AwdEventStatus::Deployed, - Default::default(), - ) - .await?; + // 8. Deployed(状态机唯一入口) + event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Deploying, + AwdEventStatus::Deployed, + Default::default(), + ) + .await?; - info!("[Deploy] Event {} fully deployed", event_id); - Ok(()) + info!("[Deploy] Event {} fully deployed", event_id); + Ok(()) + } + .await; + + match deploy_result { + Ok(()) => Ok(()), + Err(e) => { + // DeployFailed 写入路径(P1-16):部署失败不再停留在 Deploying。 + // 仅当仍处于 Deploying 时转移(幂等重入/并发场景不覆盖新状态)。 + if let Ok(Some(ev)) = event_repo::find_by_event_id(db, event_id).await { + if ev.status == AwdEventStatus::Deploying { + if let Err(te) = event_repo::transition_event( + db, + ev.id, + AwdEventStatus::Deploying, + AwdEventStatus::DeployFailed, + Default::default(), + ) + .await + { + tracing::error!( + "[Deploy] failed to record DeployFailed for event {}: {}", + event_id, + te + ); + } + } + } + tracing::error!("[Deploy] Event {} deployment failed: {}", event_id, e); + Err(e) + } + } } async fn ensure_docker_network( @@ -208,10 +246,27 @@ async fn ensure_infra_container( let container_name = format!("fctf-{}-{}", kind, &event_id.to_string()[..8]); let resource_id = container_name.clone(); - if resource_exists(db, event_id, kind, &resource_id).await? { - info!("[Deploy] {} already tracked — skip create", kind); + // P1-16:仅查 DB 行不够——必须核验实际容器。DB 有记录但容器已消失 → 重建。 + let db_tracked = resource_exists(db, event_id, kind, &resource_id).await?; + let container_alive = containers + .inspect_container(&container_name) + .await + .map(|state| state.running) + .unwrap_or(false); + + if db_tracked && container_alive { + info!( + "[Deploy] {} already tracked and running — skip create", + kind + ); return Ok(()); } + if db_tracked && !container_alive { + warn!( + "[Deploy] {} tracked in DB but container missing/stopped — recreating", + kind + ); + } let token = match (token_ct, token_nonce) { (Some(ct), Some(nonce)) => { @@ -272,8 +327,34 @@ async fn ensure_teams_and_gameboxes( docker_network_id: &str, docker_network_name: &str, ) -> AwdResult<()> { + // ── P1-14:部署前跨赛事重叠校验(其他赛事维度)── + // 当前赛事的 gamebox_cidr / wireguard_cidr / flagserver_ip / judgeserver_ip + // 不得与其他赛事已配置的 CIDR / 队伍子网冲突;冲突则 Fail Closed,不进入分配。 + let other_events = awd_events::Entity::find() + .filter(awd_events::Column::EventId.ne(event_id)) + .all(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + let other_networks = awd_team_networks::Entity::find() + .filter(awd_team_networks::Column::EventId.ne(event_id)) + .all(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + validate_no_cross_event_overlap( + &other_events, + &other_networks, + &awd_event.gamebox_cidr, + &awd_event.wireguard_cidr, + &awd_event.flagserver_ip, + &awd_event.judgeserver_ip, + )?; + + // ── P1-14:teams 枚举确定性:ORDER BY created_at(+ id 兜底)── + // 保证子网按队伍创建顺序稳定分配,避免无序扫描导致分配顺序漂移。 let teams = event_teams::Entity::find() .filter(event_teams::Column::EventId.eq(event_id)) + .order_by_asc(event_teams::Column::CreatedAt) + .order_by_asc(event_teams::Column::Id) .all(db) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -296,6 +377,16 @@ async fn ensure_teams_and_gameboxes( .await .map_err(|e| AwdError::Database(e.to_string()))?; + // ── P1-14:队伍子网分配 + awd_team_networks 插入事务化 ── + // 单个 DB 事务内完成「查询已有行 → 无则分配 /24 并插入」,commit 后统一生效; + // 任一步失败整体 rollback,不留半截分配状态。已有 awd_team_networks 行的队伍 + // 跳过分配(幂等保持),与原有行为一致。 + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + let mut team_networks: Vec<(&event_teams::Model, awd_team_networks::Model)> = Vec::new(); for (team_index, team) in teams.iter().enumerate() { let team_net = gamebox_subnets.get(team_index).ok_or_else(|| { AwdError::Network(format!( @@ -311,7 +402,7 @@ async fn ensure_teams_and_gameboxes( let team_network = match awd_team_networks::Entity::find() .filter(awd_team_networks::Column::EventId.eq(event_id)) .filter(awd_team_networks::Column::TeamId.eq(team.id)) - .one(db) + .one(&txn) .await .map_err(|e| AwdError::Database(e.to_string()))? { @@ -336,12 +427,21 @@ async fn ensure_teams_and_gameboxes( next_wireguard_host: Set(2), ..Default::default() } - .insert(db) + .insert(&txn) .await .map_err(|e| AwdError::Database(e.to_string()))? } }; + team_networks.push((team, team_network)); + } + + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // ── GameBox 实例行 + 容器创建(事务外:Docker 调用不占用事务连接)── + for (team, team_network) in team_networks { // Decrypt SSH password for container create let password = { let blob = EncryptedBlob { @@ -605,3 +705,288 @@ fn random_password(len: usize) -> String { .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) .collect() } + +// ── P1-14:跨赛事 CIDR / IP 重叠校验(纯函数)── +// +// 调用方负责把 `events` / `networks` 过滤为**其他赛事**(不含当前赛事): +// - `events`:其他赛事的 awd_events 行(gamebox_cidr / wireguard_cidr) +// - `networks`:其他赛事已分配的队伍子网(awd_team_networks.gamebox_subnet / wireguard_subnet) +// - 其余四个参数:当前赛事的配置 +// +// 校验规则: +// 1. 当前赛事 gamebox_cidr / wireguard_cidr 不得与其他赛事的 gamebox_cidr / wireguard_cidr 重叠; +// 2. 当前赛事 flagserver_ip / judgeserver_ip 不得落在其他赛事的 gamebox_cidr / wireguard_cidr / +// 队伍子网(gamebox_subnet / wireguard_subnet)内。 +fn parse_cidr(cidr: &str, name: &str) -> AwdResult { + Ipv4Cidr::parse(cidr).map_err(|e| AwdError::Validation(format!("{name} ({cidr}) invalid: {e}"))) +} + +fn parse_ip(ip: &str, name: &str) -> AwdResult { + ip.parse::() + .map_err(|e| AwdError::Validation(format!("{name} ({ip}) invalid: {e}"))) +} + +pub fn validate_no_cross_event_overlap( + events: &[awd_events::Model], + networks: &[awd_team_networks::Model], + gamebox_cidr: &str, + wireguard_cidr: &str, + flagserver_ip: &str, + judgeserver_ip: &str, +) -> AwdResult<()> { + let gb = parse_cidr(gamebox_cidr, "gamebox_cidr")?; + let wg = parse_cidr(wireguard_cidr, "wireguard_cidr")?; + let fs_ip = parse_ip(flagserver_ip, "flagserver_ip")?; + let js_ip = parse_ip(judgeserver_ip, "judgeserver_ip")?; + + // 1. 当前赛事 CIDR vs 其他赛事 CIDR(双向交叉:gamebox / wireguard) + for other in events { + let other_gb = parse_cidr(&other.gamebox_cidr, "other gamebox_cidr")?; + let other_wg = parse_cidr(&other.wireguard_cidr, "other wireguard_cidr")?; + + if gb.overlaps(&other_gb) || gb.overlaps(&other_wg) { + return Err(AwdError::Conflict(format!( + "gamebox_cidr {gamebox_cidr} overlaps other event {} (gamebox {} / wireguard {})", + other.event_id, other.gamebox_cidr, other.wireguard_cidr + ))); + } + if wg.overlaps(&other_gb) || wg.overlaps(&other_wg) { + return Err(AwdError::Conflict(format!( + "wireguard_cidr {wireguard_cidr} overlaps other event {} (gamebox {} / wireguard {})", + other.event_id, other.gamebox_cidr, other.wireguard_cidr + ))); + } + + // 2a. 端口 IP(flagserver / judgeserver)不得落在其他赛事 CIDR 内 + if other_gb.contains(fs_ip) || other_wg.contains(fs_ip) { + return Err(AwdError::Conflict(format!( + "flagserver_ip {flagserver_ip} falls inside other event {} (gamebox {} / wireguard {})", + other.event_id, other.gamebox_cidr, other.wireguard_cidr + ))); + } + if other_gb.contains(js_ip) || other_wg.contains(js_ip) { + return Err(AwdError::Conflict(format!( + "judgeserver_ip {judgeserver_ip} falls inside other event {} (gamebox {} / wireguard {})", + other.event_id, other.gamebox_cidr, other.wireguard_cidr + ))); + } + } + + // 2b. 端口 IP 不得落在其他赛事已分配队伍子网内 + for net in networks { + let net_gb = parse_cidr(&net.gamebox_subnet, "other team gamebox_subnet")?; + let net_wg = parse_cidr(&net.wireguard_subnet, "other team wireguard_subnet")?; + + if net_gb.contains(fs_ip) || net_wg.contains(fs_ip) { + return Err(AwdError::Conflict(format!( + "flagserver_ip {flagserver_ip} falls inside other team subnet {} of event {}", + net.gamebox_subnet, net.event_id + ))); + } + if net_gb.contains(js_ip) || net_wg.contains(js_ip) { + return Err(AwdError::Conflict(format!( + "judgeserver_ip {judgeserver_ip} falls inside other team subnet {} of event {}", + net.gamebox_subnet, net.event_id + ))); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 构造一个最小 awd_events::Model(仅校验所需的 CIDR/IP 字段有意义)。 + fn event_model( + id: u128, + gamebox_cidr: &str, + wireguard_cidr: &str, + flagserver_ip: &str, + judgeserver_ip: &str, + ) -> awd_events::Model { + let now: chrono::DateTime = chrono::Utc::now().into(); + awd_events::Model { + id: Uuid::from_u128(id), + event_id: Uuid::from_u128(id + 1), + status: AwdEventStatus::Draft, + phase: AwdPhase::Hardening, + gamebox_cidr: gamebox_cidr.to_string(), + wireguard_cidr: wireguard_cidr.to_string(), + wireguard_interface_name: format!("wg-{id}"), + wireguard_listen_port: 51820, + flagserver_ip: flagserver_ip.to_string(), + judgeserver_ip: judgeserver_ip.to_string(), + docker_network_id: None, + docker_network_name: None, + event_secret_ciphertext: vec![], + event_secret_nonce: vec![], + flagserver_token_ciphertext: None, + flagserver_token_nonce: None, + judgeserver_token_ciphertext: None, + judgeserver_token_nonce: None, + wg_server_private_key_ciphertext: None, + wg_server_private_key_nonce: None, + wg_server_public_key: None, + key_version: 1, + free_reset_count: 0, + extra_reset_penalty: 0, + reset_protection_secs: 0, + judge_max_concurrency: 0, + judge_default_timeout_secs: 0, + judge_retry_interval_secs: 0, + judge_grace_period_secs: 0, + round_duration_secs: 60, + archive_retention_hours: 0, + verified_at: None, + verified_revision: None, + pause_remaining_secs: None, + started_at: None, + finished_at: None, + created_at: now.clone(), + updated_at: now, + paused_phase: None, + } + } + + /// 构造一个最小 awd_team_networks::Model(队伍子网)。 + fn network_model( + event_id: Uuid, + gamebox_subnet: &str, + wireguard_subnet: &str, + ) -> awd_team_networks::Model { + let now: chrono::DateTime = chrono::Utc::now().into(); + awd_team_networks::Model { + id: Uuid::new_v4(), + event_id, + team_id: Uuid::new_v4(), + gamebox_subnet: gamebox_subnet.to_string(), + wireguard_subnet: wireguard_subnet.to_string(), + ssh_password_ciphertext: vec![], + ssh_password_nonce: vec![], + key_version: 1, + next_gamebox_host: 2, + next_wireguard_host: 2, + status: "allocated".to_string(), + created_at: now.clone(), + updated_at: now, + } + } + + #[test] + fn test_validate_disjoint_cidrs_ok() { + // 当前赛事与其他赛事 CIDR / 端口 IP 完全不相交 → 通过 + let events = vec![event_model( + 0x1000, + "10.20.0.0/16", + "172.20.0.0/16", + "10.20.0.2", + "10.20.0.3", + )]; + let networks = vec![network_model( + events[0].event_id, + "10.20.1.0/24", + "172.20.1.0/24", + )]; + let result = validate_no_cross_event_overlap( + &events, + &networks, + "10.30.0.0/16", + "172.30.0.0/16", + "10.30.0.2", + "10.30.0.3", + ); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_same_gamebox_cidr_rejected() { + // 当前 gamebox_cidr 与其他赛事 gamebox_cidr 完全相同 → 重叠报错 + let events = vec![event_model( + 0x1000, + "10.20.0.0/16", + "172.20.0.0/16", + "10.20.0.2", + "10.20.0.3", + )]; + let result = validate_no_cross_event_overlap( + &events, + &[], + "10.20.0.0/16", + "172.30.0.0/16", + "10.30.0.2", + "10.30.0.3", + ); + assert!(matches!(result, Err(AwdError::Conflict(_)))); + } + + #[test] + fn test_validate_wireguard_overlapping_other_gamebox_rejected() { + // 跨 CIDR 域交叉:当前 wireguard_cidr 与其他赛事 gamebox_cidr 重叠 + let events = vec![event_model( + 0x1000, + "10.20.0.0/16", + "172.20.0.0/16", + "10.20.0.2", + "10.20.0.3", + )]; + let result = validate_no_cross_event_overlap( + &events, + &[], + "10.30.0.0/16", + "10.20.128.0/16", + "10.30.0.2", + "10.30.0.3", + ); + assert!(matches!(result, Err(AwdError::Conflict(_)))); + } + + #[test] + fn test_validate_flagserver_ip_in_other_team_subnet_rejected() { + // 端口 IP 冲突:当前 flagserver_ip 落在其他赛事已分配队伍子网内 + // (队伍子网不在其赛事父 CIDR 内的异常数据,专门走队伍子网校验分支) + let events = vec![event_model( + 0x1000, + "10.20.0.0/16", + "172.20.0.0/16", + "10.20.0.2", + "10.20.0.3", + )]; + let networks = vec![network_model( + events[0].event_id, + "10.99.1.0/24", + "172.99.1.0/24", + )]; + let result = validate_no_cross_event_overlap( + &events, + &networks, + "10.30.0.0/16", + "172.30.0.0/16", + "10.99.1.5", + "10.30.0.3", + ); + assert!(matches!(result, Err(AwdError::Conflict(_)))); + } + + #[test] + fn test_validate_judgeserver_ip_in_other_wireguard_rejected() { + // 端口 IP 冲突:当前 judgeserver_ip 落在其他赛事 wireguard_cidr 内 + let events = vec![event_model( + 0x1000, + "10.20.0.0/16", + "172.20.0.0/16", + "10.20.0.2", + "10.20.0.3", + )]; + let result = validate_no_cross_event_overlap( + &events, + &[], + "10.30.0.0/16", + "172.30.0.0/16", + "10.30.0.2", + "172.20.5.9", + ); + assert!(matches!(result, Err(AwdError::Conflict(_)))); + } +} diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 4f0d33ab..794d0676 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -71,8 +71,12 @@ pub async fn start_event( .map_err(|e| AwdError::Database(e.to_string()))?; // 全局 desired-state reconcile(nftables)+ conntrack 清理(Phase 1 P1-10) - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) @@ -123,8 +127,12 @@ pub async fn pause_event( ) .await?; - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) @@ -190,8 +198,12 @@ pub async fn resume_event( ) .await?; - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; Ok(()) diff --git a/apps/api/src/modules/event/awd_team/service/firewall_service.rs b/apps/api/src/modules/event/awd_team/service/firewall_service.rs index c2deb0b0..682f8a5a 100644 --- a/apps/api/src/modules/event/awd_team/service/firewall_service.rs +++ b/apps/api/src/modules/event/awd_team/service/firewall_service.rs @@ -7,8 +7,7 @@ use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter}; use uuid::Uuid; use crate::entity::{ - awd_events, awd_team_bans, awd_team_networks, - sea_orm_active_enums::AwdEventStatus, + awd_events, awd_team_bans, awd_team_networks, sea_orm_active_enums::AwdEventStatus, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, @@ -41,9 +40,10 @@ pub async fn build_desired_state( db: &C, revision: u64, ) -> AwdResult { - let events = awd_events::Entity::find().all(db).await.map_err(|e| { - AwdError::Database(format!("load awd_events: {e}")) - })?; + let events = awd_events::Entity::find() + .all(db) + .await + .map_err(|e| AwdError::Database(format!("load awd_events: {e}")))?; let mut desired = DesiredFirewallState { revision, @@ -65,7 +65,10 @@ pub async fn build_desired_state( // 活跃 ban let bans = awd_team_bans::Entity::find() .filter(awd_team_bans::Column::EventId.eq(event.event_id)) - .filter(awd_team_bans::Column::Status.eq(crate::entity::sea_orm_active_enums::BanStatus::Active)) + .filter( + awd_team_bans::Column::Status + .eq(crate::entity::sea_orm_active_enums::BanStatus::Active), + ) .all(db) .await .map_err(|e| AwdError::Database(format!("load awd_team_bans: {e}")))?; @@ -85,17 +88,17 @@ pub async fn build_desired_state( .flagserver_ip .parse() .map_err(|_| AwdError::Validation(format!("flagserver_ip {}", event.flagserver_ip)))?; - let judgeserver_ip: std::net::Ipv4Addr = event - .judgeserver_ip - .parse() - .map_err(|_| AwdError::Validation(format!("judgeserver_ip {}", event.judgeserver_ip)))?; + let judgeserver_ip: std::net::Ipv4Addr = event.judgeserver_ip.parse().map_err(|_| { + AwdError::Validation(format!("judgeserver_ip {}", event.judgeserver_ip)) + })?; desired.events.push(DesiredEventPolicy { - event_key: crate::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( - &event.event_id, - ) - .as_str() - .to_string(), + event_key: + crate::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( + &event.event_id, + ) + .as_str() + .to_string(), event_id: event.event_id, phase: event.phase, gamebox_network: IpNet::parse(&event.gamebox_cidr) @@ -218,7 +221,9 @@ mod tests { assert!(!in_firewall_desired_set(&AwdEventStatus::Configuring)); assert!(!in_firewall_desired_set(&AwdEventStatus::Finished)); assert!(!in_firewall_desired_set(&AwdEventStatus::Archived)); - assert!(!in_firewall_desired_set(&AwdEventStatus::VerificationFailed)); + assert!(!in_firewall_desired_set( + &AwdEventStatus::VerificationFailed + )); } #[test] diff --git a/apps/api/src/modules/event/awd_team/service/recovery_service.rs b/apps/api/src/modules/event/awd_team/service/recovery_service.rs index 0cea30aa..d5cbaf94 100644 --- a/apps/api/src/modules/event/awd_team/service/recovery_service.rs +++ b/apps/api/src/modules/event/awd_team/service/recovery_service.rs @@ -180,8 +180,12 @@ async fn recover_event( // ── 3. Restore firewall(nftables 全局 desired-state reconcile,DB 是事实源)── // 失败 Fail Closed:不吞错,由调用方决定是否置 NetworkError。 - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; firewall_service::flush_event_connections(network, event.event_id, &event.gamebox_cidr).await; recovered += 1; diff --git a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs index f4511264..1142d1a9 100644 --- a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs +++ b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs @@ -17,12 +17,14 @@ use crate::core::config::AwdStaticConfig; use crate::entity::{ awd_events, awd_team_networks, awd_wireguard_peers, sea_orm_active_enums::WgPeerStatus, }; +use tracing::warn; + use crate::modules::event::awd_team::{ AwdError, AwdResult, crypto::{AwdCrypto, EncryptedBlob}, domain::Ipv4Cidr, - infrastructure::network::{AwdNetworkRuntime, WgKeyPair, generate_keypair}, - repo::wireguard_repo, + infrastructure::network::{AwdNetworkRuntime, PeerIdentity, WgKeyPair, generate_keypair}, + repo::{ban_repo, wireguard_repo}, }; /// Generate a peer keypair (client private + public). @@ -88,12 +90,19 @@ pub async fn ensure_peer_for_user( } } - let awd_event = awd_events::Entity::find() - .filter(awd_events::Column::EventId.eq(event_id)) - .one(db) + // 创建前检查:该 team 有 active ban 时拒绝创建 peer(ban 语义 = host 移除 + 禁止新建)。 + if ban_repo::find_active_ban(db, event_id, team_id) .await .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + .is_some() + { + return Err(AwdError::Forbidden(format!( + "Team {} is banned for event {} — WG peer creation denied", + team_id, event_id + ))); + } + + let awd_event = load_awd_event(db, event_id).await?; let mut team_net = awd_team_networks::Entity::find() .filter(awd_team_networks::Column::EventId.eq(event_id)) @@ -191,26 +200,451 @@ pub async fn load_active_peers( .map_err(|e| AwdError::Database(e.to_string())) } +/// 查 peer(event 内,跨赛事隔离)。 +async fn find_peer( + db: &DatabaseConnection, + event_id: Uuid, + peer_id: Uuid, +) -> AwdResult { + wireguard_repo::find_peer_by_id(db, event_id, peer_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound(format!("WireGuard peer {} not found", peer_id))) +} + +/// 查 AWD event(wireguard_interface_name / key_version 等)。 +async fn load_awd_event(db: &DatabaseConnection, event_id: Uuid) -> AwdResult { + awd_events::Entity::find() + .filter(awd_events::Column::EventId.eq(event_id)) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into())) +} + /// Revoke a peer (disables the WireGuard tunnel for that user). -pub async fn revoke_peer(db: &DatabaseConnection, peer_id: Uuid) -> AwdResult<()> { +/// +/// 闭环:host 先移除 peer(`wg set peer remove`,经 network runtime), +/// 成功后再写 DB 置 Revoked + revoked_at。host 移除失败则返回错误、DB 保持原状(可重试)。 +pub async fn revoke_peer( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + event_id: Uuid, + peer_id: Uuid, +) -> AwdResult<()> { + let peer = find_peer(db, event_id, peer_id).await?; + let awd_event = load_awd_event(db, event_id).await?; + remove_peer_from_host( + network, + &awd_event.wireguard_interface_name, + &peer.public_key, + ) + .await?; wireguard_repo::revoke_peer(db, peer_id) .await - .map_err(|e| AwdError::Database(e.to_string())) + .map_err(|e| AwdError::Database(e.to_string()))?; + Ok(()) +} + +/// host 侧移除 peer(与 DB 写入分离,便于 Noop 语义与测试)。 +async fn remove_peer_from_host( + network: &dyn AwdNetworkRuntime, + iface: &str, + public_key: &str, +) -> AwdResult<()> { + network + .revoke_peer(PeerIdentity { + interface: iface.to_string(), + public_key: public_key.to_string(), + }) + .await +} + +/// 轮换前置守卫:仅 Active 状态允许轮换(幂等:已 Revoked / Rotating 拒绝)。 +fn assert_peer_rotatable(peer: &awd_wireguard_peers::Model) -> AwdResult<()> { + if peer.status != WgPeerStatus::Active { + return Err(AwdError::InvalidState(format!( + "WireGuard peer {} status is {:?}, rotation requires Active", + peer.id, peer.status + ))); + } + Ok(()) +} + +/// host 侧轮换:先加新 peer(同一 allowed-ips 立即接管路由,隧道不中断),再移除旧 peer。 +async fn apply_rotation_to_host( + network: &dyn AwdNetworkRuntime, + iface: &str, + old_public_key: &str, + new_public_key: &str, + assigned_ip: &str, +) -> AwdResult<()> { + network + .add_peer( + PeerIdentity { + interface: iface.to_string(), + public_key: new_public_key.to_string(), + }, + assigned_ip, + ) + .await?; + network + .revoke_peer(PeerIdentity { + interface: iface.to_string(), + public_key: old_public_key.to_string(), + }) + .await?; + Ok(()) +} + +/// Fail Closed:host 轮换失败 → 尽力把 peer 置 Revoked(记录告警,随后向上返回原始错误)。 +async fn fail_closed_revoke(db: &DatabaseConnection, peer_id: Uuid, cause: &AwdError) { + if let Err(db_err) = wireguard_repo::revoke_peer(db, peer_id).await { + warn!( + "[WireGuard] Failed to mark peer {} Revoked after rotation error ({}): {}", + peer_id, cause, db_err + ); + } else { + warn!( + "[WireGuard] Peer {} marked Revoked after rotation failure: {}", + peer_id, cause + ); + } } /// Rotate a peer's keys while preserving the assigned IP. +/// +/// 生命周期闭环:Active → Rotating(DB 标记 + rotated_at)→ host 轮换(加新 peer、移除旧 peer) +/// → 成功置回 Active 并更新 public_key/private_key(key_version 用事件当前值);host 失败置 Revoked +/// (Fail Closed)。幂等:peer 非 Active(已 Revoked / 轮换中)直接拒绝。 +/// +/// 返回新私钥**一次**,供调用方渲染客户端配置(旧私钥不再可读)。 pub async fn rotate_peer_keys( db: &DatabaseConnection, crypto: &AwdCrypto, + network: &dyn AwdNetworkRuntime, event_id: Uuid, peer_id: Uuid, -) -> AwdResult<()> { +) -> AwdResult { + let peer = find_peer(db, event_id, peer_id).await?; + assert_peer_rotatable(&peer)?; + let awd_event = load_awd_event(db, event_id).await?; + + // 新 keypair + 用事件当前 key_version 加密(不再硬编码 1)。 let kp = generate_keypair(); let aad = AwdCrypto::build_aad(event_id, "wg_peer_private_key"); let blob = crypto - .encrypt(kp.private_key.as_bytes(), &aad, 1) + .encrypt(kp.private_key.as_bytes(), &aad, awd_event.key_version) .map_err(|e| AwdError::Crypto(e.to_string()))?; - wireguard_repo::rotate_peer(db, peer_id, &kp.public_key, &blob.ciphertext, &blob.nonce) + + // 1) DB 先置 Rotating(记录 rotated_at)——此后 host 失败则 Fail Closed 落到 Revoked。 + wireguard_repo::mark_peer_rotating(db, peer_id) .await - .map_err(|e| AwdError::Database(e.to_string())) + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 2) host 轮换:加新 peer + 移除旧 peer。 + if let Err(e) = apply_rotation_to_host( + network, + &awd_event.wireguard_interface_name, + &peer.public_key, + &kp.public_key, + &peer.assigned_ip, + ) + .await + { + fail_closed_revoke(db, peer_id, &e).await; + return Err(e); + } + + // 3) host 成功 → DB 置回 Active + 新密钥。 + wireguard_repo::rotate_peer( + db, + peer_id, + &kp.public_key, + &blob.ciphertext, + &blob.nonce, + awd_event.key_version, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + Ok(kp.private_key) +} + +/// Recovery 辅助:把 event 下全部 Active peers(public_key + assigned_ip)幂等加回 host。 +/// Noop runtime 下 add_peer 为 no-op。返回成功恢复的 peer 数量。 +/// 注意:本函数只写恢复辅助,接线(recover_all 流程)由上层负责。 +pub async fn restore_active_peers_to_host( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + event_id: Uuid, +) -> AwdResult { + let awd_event = load_awd_event(db, event_id).await?; + let peers = load_active_peers(db, event_id).await?; + restore_peers_to_host(network, &awd_event.wireguard_interface_name, &peers).await +} + +/// 逐个把 Active peer 加回 host(`wg set peer allowed-ips `,幂等)。 +async fn restore_peers_to_host( + network: &dyn AwdNetworkRuntime, + iface: &str, + peers: &[awd_wireguard_peers::Model], +) -> AwdResult { + let mut restored = 0usize; + for peer in peers { + network + .add_peer( + PeerIdentity { + interface: iface.to_string(), + public_key: peer.public_key.clone(), + }, + &peer.assigned_ip, + ) + .await?; + restored += 1; + } + Ok(restored) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::event::awd_team::{ + infrastructure::network::{ + EventNetworkIdentity, NetworkObservedState, TeamNetworkIdentity, WireGuardDesiredState, + }, + system::command::RecordingCommandRunner, + system::wireguard, + }; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + + /// 记录型 mock network runtime:只记录 host 调用序列,不执行真实命令 + /// (参考 nftables.rs 的 FakeNftRunner 风格)。 + struct MockNetworkRuntime { + calls: Arc>>, + fail_add_peer: bool, + } + + impl MockNetworkRuntime { + fn new() -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + fail_add_peer: false, + } + } + + /// add_peer 必败的 runtime(验证 Fail Closed 路径)。 + fn failing_add() -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + fail_add_peer: true, + } + } + + fn recorded(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn record(&self, entry: String) { + self.calls.lock().unwrap().push(entry); + } + } + + #[async_trait] + impl AwdNetworkRuntime for MockNetworkRuntime { + async fn ensure_wireguard(&self, desired: WireGuardDesiredState) -> AwdResult<()> { + self.record(format!("ensure_wireguard:{}", desired.interface)); + Ok(()) + } + async fn remove_wireguard(&self, interface: &str) -> AwdResult<()> { + self.record(format!("remove_wireguard:{}", interface)); + Ok(()) + } + async fn revoke_peer(&self, peer: PeerIdentity) -> AwdResult<()> { + self.record(format!( + "revoke_peer:{}:{}", + peer.interface, peer.public_key + )); + Ok(()) + } + async fn add_peer(&self, peer: PeerIdentity, allowed_ips: &str) -> AwdResult<()> { + self.record(format!( + "add_peer:{}:{}:{}", + peer.interface, peer.public_key, allowed_ips + )); + if self.fail_add_peer { + return Err(AwdError::Network("mock add_peer failure".into())); + } + Ok(()) + } + async fn clear_event_connections(&self, event: EventNetworkIdentity) -> AwdResult<()> { + self.record(format!("clear_event:{}", event.event_id)); + Ok(()) + } + async fn clear_team_connections(&self, team: TeamNetworkIdentity) -> AwdResult<()> { + self.record(format!("clear_team:{}", team.team_id)); + Ok(()) + } + async fn inspect(&self, _event: EventNetworkIdentity) -> AwdResult { + Ok(NetworkObservedState::default()) + } + } + + fn sample_peer(status: WgPeerStatus) -> awd_wireguard_peers::Model { + awd_wireguard_peers::Model { + id: Uuid::new_v4(), + event_id: Uuid::new_v4(), + team_id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + status, + assigned_ip: "10.42.1.2/32".to_string(), + public_key: "PUB_KEY_OLD".to_string(), + private_key_ciphertext: vec![1, 2, 3], + private_key_nonce: vec![4, 5, 6], + key_version: 1, + created_at: chrono::Utc::now().into(), + rotated_at: None, + revoked_at: None, + config_fetched_at: None, + } + } + + #[test] + fn rotation_requires_active_status() { + // Active 可轮换 + assert!(assert_peer_rotatable(&sample_peer(WgPeerStatus::Active)).is_ok()); + // 幂等:已 Revoked / Rotating 拒绝 + for status in [WgPeerStatus::Revoked, WgPeerStatus::Rotating] { + let err = assert_peer_rotatable(&sample_peer(status)).unwrap_err(); + assert!(matches!(err, AwdError::InvalidState(_)), "got {:?}", err); + } + } + + #[tokio::test] + async fn apply_rotation_to_host_adds_new_then_removes_old() { + let net = MockNetworkRuntime::new(); + apply_rotation_to_host( + &net, + "fctf-awd-1", + "PUB_KEY_OLD", + "PUB_KEY_NEW", + "10.42.1.2/32", + ) + .await + .unwrap(); + + let cmds = net.recorded(); + assert_eq!( + cmds, + vec![ + // 先加新 peer(allowed-ips 立即接管),再移除旧 peer + "add_peer:fctf-awd-1:PUB_KEY_NEW:10.42.1.2/32".to_string(), + "revoke_peer:fctf-awd-1:PUB_KEY_OLD".to_string(), + ] + ); + } + + #[tokio::test] + async fn apply_rotation_to_host_propagates_add_failure() { + let net = MockNetworkRuntime::failing_add(); + let err = apply_rotation_to_host( + &net, + "fctf-awd-1", + "PUB_KEY_OLD", + "PUB_KEY_NEW", + "10.42.1.2/32", + ) + .await + .unwrap_err(); + assert!(matches!(err, AwdError::Network(_)), "got {:?}", err); + // add 失败即中止,不会走到移除旧 peer(Fail Closed 由调用方落 Revoked) + assert_eq!( + net.recorded(), + vec!["add_peer:fctf-awd-1:PUB_KEY_NEW:10.42.1.2/32".to_string()] + ); + } + + #[tokio::test] + async fn remove_peer_from_host_uses_event_interface_and_pubkey() { + let net = MockNetworkRuntime::new(); + remove_peer_from_host(&net, "fctf-awd-2", "PUB_KEY_OLD") + .await + .unwrap(); + assert_eq!( + net.recorded(), + vec!["revoke_peer:fctf-awd-2:PUB_KEY_OLD".to_string()] + ); + } + + #[tokio::test] + async fn restore_peers_to_host_adds_each_active_peer() { + let net = MockNetworkRuntime::new(); + let mut peers = vec![]; + for i in 0..3 { + let mut p = sample_peer(WgPeerStatus::Active); + p.public_key = format!("PUB_KEY_{}", i); + p.assigned_ip = format!("10.42.1.{}/32", i + 2); + peers.push(p); + } + + let restored = restore_peers_to_host(&net, "fctf-awd-1", &peers) + .await + .unwrap(); + assert_eq!(restored, 3); + let cmds = net.recorded(); + assert_eq!(cmds.len(), 3); + assert_eq!(cmds[0], "add_peer:fctf-awd-1:PUB_KEY_0:10.42.1.2/32"); + assert_eq!(cmds[1], "add_peer:fctf-awd-1:PUB_KEY_1:10.42.1.3/32"); + assert_eq!(cmds[2], "add_peer:fctf-awd-1:PUB_KEY_2:10.42.1.4/32"); + } + + #[tokio::test] + async fn restore_peers_to_host_propagates_add_failure() { + let net = MockNetworkRuntime::failing_add(); + let peers = vec![sample_peer(WgPeerStatus::Active)]; + assert!( + restore_peers_to_host(&net, "fctf-awd-1", &peers) + .await + .is_err() + ); + } + + #[tokio::test] + async fn wireguard_cmd_uses_structured_args() { + // 命令序列断言:wg set peer allowed-ips / ... remove + let runner = RecordingCommandRunner::new(); + wireguard::add_peer(&runner, "fctf-awd-1", "PUB_KEY_NEW", "10.42.1.2/32") + .await + .unwrap(); + wireguard::remove_peer(&runner, "fctf-awd-1", "PUB_KEY_OLD") + .await + .unwrap(); + + let cmds = runner.recorded(); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].0, "wg"); + assert_eq!( + cmds[0].1, + vec![ + "set".to_string(), + "fctf-awd-1".to_string(), + "peer".to_string(), + "PUB_KEY_NEW".to_string(), + "allowed-ips".to_string(), + "10.42.1.2/32".to_string(), + ] + ); + assert_eq!(cmds[1].0, "wg"); + assert_eq!( + cmds[1].1, + vec![ + "set".to_string(), + "fctf-awd-1".to_string(), + "peer".to_string(), + "PUB_KEY_OLD".to_string(), + "remove".to_string(), + ] + ); + } } diff --git a/apps/api/src/sql/merged.sql b/apps/api/src/sql/merged.sql index 91df43aa..19c73048 100644 --- a/apps/api/src/sql/merged.sql +++ b/apps/api/src/sql/merged.sql @@ -1,8 +1,8 @@ -- ================================================================================================ -- ================================================================================================ -- FloatCTF Merged Migrations --- Generated at: 2026-08-08 09:36:27 +0800 --- Migration count: 16 +-- Generated at: 2026-08-08 12:09:35 +0800 +-- Migration count: 17 -- ================================================================================================ -- ================================================================================================ @@ -1863,6 +1863,37 @@ COMMIT; -- ================================================================================================ +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808120928-awd-wg-config-fetched.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808120928-awd-wg-config-fetched +-- ================================================================================ +-- Phase 1 P1-15:WireGuard 私钥一次性返回。 +-- player 首次拉取 WG 配置时返回私钥并记录 config_fetched_at; +-- 之后再次请求不再返回私钥(防 token 泄漏后私钥被反复拉取)。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_wireguard_peers" + ADD COLUMN IF NOT EXISTS "config_fetched_at" TIMESTAMPTZ; + +COMMENT ON COLUMN "awd_wireguard_peers"."config_fetched_at" IS 'WG 配置(含私钥)首次拉取时间;NULL=尚未拉取(Phase 1 P1-15)'; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808120928-awd-wg-config-fetched.sql +-- ================================================================================================ +-- ================================================================================================ + + -- ================================================================================================ -- ================================================================================================ -- End of FloatCTF Migrations diff --git a/apps/api/src/sql/migrations/20260808120928-awd-wg-config-fetched.sql b/apps/api/src/sql/migrations/20260808120928-awd-wg-config-fetched.sql new file mode 100644 index 00000000..61e96f6d --- /dev/null +++ b/apps/api/src/sql/migrations/20260808120928-awd-wg-config-fetched.sql @@ -0,0 +1,16 @@ +-- ================================================================================ +-- Migration: 20260808120928-awd-wg-config-fetched +-- ================================================================================ +-- Phase 1 P1-15:WireGuard 私钥一次性返回。 +-- player 首次拉取 WG 配置时返回私钥并记录 config_fetched_at; +-- 之后再次请求不再返回私钥(防 token 泄漏后私钥被反复拉取)。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_wireguard_peers" + ADD COLUMN IF NOT EXISTS "config_fetched_at" TIMESTAMPTZ; + +COMMENT ON COLUMN "awd_wireguard_peers"."config_fetched_at" IS 'WG 配置(含私钥)首次拉取时间;NULL=尚未拉取(Phase 1 P1-15)'; + +COMMIT; diff --git a/apps/web/src/entity/awd_wireguard_peers.ts b/apps/web/src/entity/awd_wireguard_peers.ts index 3c6be33b..bb3108ce 100644 --- a/apps/web/src/entity/awd_wireguard_peers.ts +++ b/apps/web/src/entity/awd_wireguard_peers.ts @@ -14,4 +14,5 @@ export type AwdWireguardPeers = { created_at: string; rotated_at?: string; revoked_at?: string; + config_fetched_at?: string; }; diff --git a/docs/awd-nftables-host-discovery.md b/docs/awd-nftables-host-discovery.md new file mode 100644 index 00000000..fd457038 --- /dev/null +++ b/docs/awd-nftables-host-discovery.md @@ -0,0 +1,98 @@ +# FloatCTF AWD Host Netfilter / nftables Discovery(P1-1) + +> 本文档记录 FloatCTF AWD 防火墙在宿主上的 Netfilter 事实与决策。 +> **priority 是平台技术常量,不是赛事配置**——由本发现确定,实现为 +> `infrastructure/firewall/render.rs` 的 `FORWARD_PRIORITY`。 + +## 1. 当前环境实测(2026-08-08,开发机) + +| 检查项 | 命令 | 结果 | +|---|---|---| +| nft 版本 | `nft --version` | `nftables v1.1.6 (Commodore Bullmoose #7)` | +| kernel | `uname -r` | `7.1.5-arch1-2` | +| Docker firewall backend | `docker info` | `Firewall Backend: iptables` | +| iptables 前端 | `iptables --version`(无 sudo 时不可用) | 待 root 环境确认 | +| firewalld | `firewall-cmd --state`(无 sudo 时不可用) | 待 root 环境确认 | +| 已有 nftables base chains | `sudo nft list ruleset` | 待 root 环境确认 | +| CAP_NET_ADMIN | `nft list tables` | 无 sudo 下失败 → capability 判定为 Unsupported | + +## 2. root 环境必跑检查清单 + +```bash +# 1. iptables 前端模式(legacy vs nf_tables) +iptables --version +# 若输出含 "nf_tables" → iptables-nft(内核里是 nftables 实现) +# 若输出 "legacy" → iptables-legacy(独立 Netfilter 路径) + +# 2. Docker firewall backend 证据 +docker info | grep -i firewall +cat /etc/docker/daemon.json 2>/dev/null # 是否 iptables=false 等 + +# 3. firewalld +firewall-cmd --state + +# 4. 宿主现有 nftables base chains(hooks/priorities) +sudo nft list ruleset | grep -E "hook (forward|input|output)" + +# 5. 我们的 capability 判定应返回 Supported +# (apps/api 启动日志 + Phase 2 Precheck Host env 快照) +``` + +## 3. Priority 决策 + +**决策:`FLOATCTF_FORWARD_PRIORITY = 1`**(`type filter hook forward priority 1`)。 + +依据(Netfilter hook 语义): + +- forward hook 上多个 base chain 按 priority 数值升序执行;`NF_ACCEPT` 后 + netfilter core 会继续尝试下一个已注册 hook function。 +- iptables `filter` FORWARD 与 firewalld 的 forward chain 常规注册在 **priority 0**。 +- FloatCTF 需要**在 Docker/firewalld 放行之后仍能施加 restrictive DROP**, + 因此选择 `1`(紧随 0 之后执行): + - Docker/firewalld ACCEPT 的包仍会到达我们的 chain → 我们可以 DROP; + - 我们 DROP 的包不会回滚到 Docker(DROP 是终止裁决)。 + +**必须由 P1-0 prototype(root 环境)验证**: + +```text +□ 宿主 SSH 不受影响(INPUT 不动,只挂 forward) +□ Docker 正常业务网络不受影响 +□ Hardening/Attack/Pause 矩阵实测通过(apps/api/scripts/nft_prototype.sh) +□ 与 firewalld(若运行)共存无冲突 +□ 若发现 firewalld/Docker 使用了非 0 priority 的 forward chain, + 按实际冲突调整本常量并更新此文档 +``` + +## 4. Ownership 边界(铁律 §4 落地) + +FloatCTF 只允许: + +```text +create / replace / delete table inet floatctf_awd +``` + +以及该 table 下的 chains / sets / maps / rules。 + +禁止:`nft flush ruleset`、修改 Docker-owned tables、firewalld tables、 +libvirt tables、管理员自定义 tables、直接修改 DOCKER chain / DOCKER-USER。 + +## 5. 与 Docker backend 的兼容结论 + +Docker backend = iptables(当前开发机)时:FloatCTF nftables 与 Docker iptables +经 Netfilter 共同作用于 forward 数据流,互不修改对方规则 —— 可工作。 + +Docker backend 未来切换 native nftables 时:双方各自维护自己的 tables, +FloatCTF 业务架构不变(Scenario I,Phase 5)。 + +## 6. capability 判定(P1-12 实现) + +`infrastructure/firewall/env.rs::check_host_capability`: + +```text +nft binary exists ++ nft list tables 成功(隐式验证 nf_tables + CAP_NET_ADMIN) +→ Supported + +缺任一 → HostNetworkCapability::Unsupported +→ 不自动 fallback iptables(Noop 仅 unit test / dev mock,永远不允许 Verified) +``` From 1fd95a5cef7463b133bf9805a7e6fc81b180f18f Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 12:48:34 +0800 Subject: [PATCH 004/486] =?UTF-8?q?feat(awd):=20recover=5Fall=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=90=AF=E5=8A=A8=EF=BC=88Firewall=20Recovery=20?= =?UTF-8?q?=E5=85=88=E4=BA=8E=20Scheduler=EF=BC=8CP1-16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/bootstrap/mod.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index bfdf38e3..93218b54 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -150,6 +150,32 @@ pub async fn run() -> Result<(), BootstrapError> { Arc::new(crate::modules::event::awd_team::infrastructure::firewall::NoopFirewallRuntime) }; + // AWD crypto(fail-fast,Phase 0 P0-2) + let awd_crypto = Arc::new( + AwdCrypto::from_secret_bytes(config.auth.jwt_secret.as_bytes()) + .map_err(|e| BootstrapError::Crypto(e.to_string()))?, + ); + + // AWD startup recovery(Phase 1 P1-16 接线): + // Firewall Recovery 必须先于 AWD Scheduler(§5.14 启动顺序)。 + // recover_all 失败不阻塞启动——记录错误,交由 Precheck/Start gate 判定。 + { + let awd_containers_for_recovery: Arc = + Arc::new(fcmc::DockerRuntime::new(docker.get_ref().clone())); + match crate::modules::event::awd_team::service::recovery_service::recover_all( + db.get_ref(), + awd_containers_for_recovery.as_ref(), + awd_network.as_ref(), + awd_firewall.as_ref(), + awd_crypto.as_ref(), + ) + .await + { + Ok(n) => info!("[Recovery] startup recovery complete: {n} resources reconciled"), + Err(e) => error!("[Recovery] startup recovery failed: {}", e), + } + } + // Initialize scheduler let task_scheduler = scheduler::build_task_scheduler( db.clone(), @@ -192,10 +218,7 @@ pub async fn run() -> Result<(), BootstrapError> { Arc::new(fcmc::DockerRuntime::new(docker.get_ref().clone())); let awd_deps = web::Data::new(AwdDependencies { - crypto: Arc::new( - AwdCrypto::from_secret_bytes(config.auth.jwt_secret.as_bytes()) - .map_err(|e| BootstrapError::Crypto(e.to_string()))?, - ), + crypto: awd_crypto.clone(), publisher: publisher.clone(), containers: awd_containers.clone(), network: awd_network.clone(), From 7a4618517681d5f351bdd7e1fd157ded44a14cda Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 12:52:26 +0800 Subject: [PATCH 005/486] =?UTF-8?q?feat(awd):=20configuration=5Fgeneration?= =?UTF-8?q?=20=E6=9C=BA=E5=88=B6=E4=B8=8E=20Start=20Gate=EF=BC=88Phase=202?= =?UTF-8?q?=20P2-9/10/11/12=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移:awd_events 增 configuration_generation / verified_generation(P2-9) - TransitionPatch.verified_with_generation:Precheck 成功记录配置代数 - touch_configuration:配置写入口代数 +1(update_network 已接线,P2-10) - start_event gate:generation 失配 → StartBlocked(AWD_CONFIG_CHANGED,P2-11) - schedule_event_start:计划开始一次性任务(P2-12,CreateAwdEventRequest.planned_start_at) - DB-gated 测试:generation 匹配 start 通过 / touch 后 AWD_CONFIG_CHANGED --- apps/api/src/entity/awd_events.rs | 2 + .../src/modules/event/awd_team/api/admin.rs | 8 +- .../api/src/modules/event/awd_team/api/dto.rs | 3 + .../modules/event/awd_team/repo/event_repo.rs | 36 ++++++++ .../modules/event/awd_team/scheduler/mod.rs | 48 +++++++++++ .../event/awd_team/service/event_service.rs | 22 ++++- .../awd_team/service/precheck_service.rs | 19 ++++- apps/api/src/sql/merged.sql | 38 ++++++++- ...808124905-awd-configuration-generation.sql | 19 +++++ apps/api/tests/awd_transition_guard.rs | 83 ++++++++++++++++++- apps/web/src/entity/awd_events.ts | 2 + 11 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/sql/migrations/20260808124905-awd-configuration-generation.sql diff --git a/apps/api/src/entity/awd_events.rs b/apps/api/src/entity/awd_events.rs index 54720c36..6469789d 100644 --- a/apps/api/src/entity/awd_events.rs +++ b/apps/api/src/entity/awd_events.rs @@ -60,6 +60,8 @@ pub struct Model { pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, pub paused_phase: Option, + pub configuration_generation: i64, + pub verified_generation: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 54213e1f..aa66fd40 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -14,7 +14,7 @@ use crate::{ modules::event::awd_team::{ domain::AwdEventStatusExt, repo::{ban_repo, event_repo, round_repo}, - scheduler::schedule_auto_precheck, + scheduler::{schedule_auto_precheck, schedule_event_start}, service::{event_service, score_service}, }, }; @@ -118,6 +118,7 @@ pub async fn create_awd_event( .await .map_err(|e| AppError::Database(e.to_string()))?; schedule_auto_precheck(&txn, event_id, event.start_time, chrono::Utc::now()).await?; + schedule_event_start(&txn, event_id, b.planned_start_at).await?; txn.commit().await?; UniResponse::ok(event_id.into()).into() @@ -618,6 +619,11 @@ pub async fn update_network( .map_err(|e| AppError::Database(e.to_string()))?; } + // P2-9:配置代数 +1(使 Start Gate 拦截旧 verified_generation) + event_repo::touch_configuration(ctx.db.get_ref(), awd_event.id) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + // 最后更新非状态字段(转移成功后)。 active .update(ctx.db.get_ref()) diff --git a/apps/api/src/modules/event/awd_team/api/dto.rs b/apps/api/src/modules/event/awd_team/api/dto.rs index 542a0ac0..c34db61c 100644 --- a/apps/api/src/modules/event/awd_team/api/dto.rs +++ b/apps/api/src/modules/event/awd_team/api/dto.rs @@ -16,6 +16,9 @@ pub struct CreateAwdEventRequest { pub judgeserver_ip: String, #[serde(default = "default_round_duration")] pub round_duration_secs: i32, + /// 计划开始时间(可选;设置后创建 awd.event.start 一次性任务,P2-12)。 + #[serde(default)] + pub planned_start_at: Option>, } fn default_round_duration() -> i32 { diff --git a/apps/api/src/modules/event/awd_team/repo/event_repo.rs b/apps/api/src/modules/event/awd_team/repo/event_repo.rs index 8069daf9..f57fe120 100644 --- a/apps/api/src/modules/event/awd_team/repo/event_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/event_repo.rs @@ -33,6 +33,8 @@ pub struct TransitionPatch { pub finished_at: Option>, /// 置空 verified 标记(配置变更 / 重检时清除)。 pub clear_verified: bool, + /// 已验证配置代数(P2-9:Precheck 成功时记录 configuration_generation)。 + pub verified_generation: Option, } impl TransitionPatch { @@ -45,6 +47,16 @@ impl TransitionPatch { } } + /// Verified:附配置代数(P2-9)。调用方先读 configuration_generation 再传入。 + pub fn verified_with_generation(revision: &str, generation: i64) -> Self { + Self { + verified_revision: Some(revision.to_string()), + verified_at: Some(chrono::Utc::now()), + verified_generation: Some(generation), + ..Default::default() + } + } + /// Finished:写入 finished_at(与状态同事务)。 pub fn finished() -> Self { Self { @@ -273,9 +285,33 @@ fn active_model_from_patch( active.verified_at = Set(None); active.verified_revision = Set(None); } + if let Some(gen_val) = patch.verified_generation { + active.verified_generation = Set(Some(gen_val)); + } active } +/// 配置代数 +1(P2-9/P2-10):所有影响 runtime 的配置写入口调用。 +/// 不改变状态;仅递增 configuration_generation(会使 verified_generation 失配 → StartBlocked)。 +pub async fn touch_configuration( + db: &C, + id: Uuid, +) -> Result<(), sea_orm::DbErr> { + let event = awd_events::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound("AWD event not found".to_string()))?; + let next = event.configuration_generation + 1; + let active: awd_events::ActiveModel = awd_events::ActiveModel { + id: Set(id), + configuration_generation: Set(next), + updated_at: Set(chrono::Utc::now().into()), + ..Default::default() + }; + active.update(db).await?; + Ok(()) +} + /// 守卫版 `update_status`:最终防线。 /// /// 即使上层 service 忘记走 `transition_event`,repo 层也会校验 diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index b967d2dd..36113049 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -94,6 +94,54 @@ pub async fn schedule_auto_precheck( Ok(Some(task)) } +/// Create the one-shot event-start task at `planned_start_at`(P2-12)。 +/// 幂等:同一 event 已存在 AwdEventStart 任务则跳过。 +/// 无 planned start 时间时返回 None(手动开始)。 +pub async fn schedule_event_start( + db: &C, + event_id: Uuid, + planned_start_at: Option>, +) -> Result, sea_orm::DbErr> { + let Some(start_at) = planned_start_at else { + return Ok(None); + }; + + let task_key = TaskKey::AwdEventStart.to_string(); + let exists = scheduled_tasks::Entity::find() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq(&task_key)) + .one(db) + .await?; + if exists.is_some() { + return Ok(None); + } + + let now = Utc::now(); + let task = scheduled_tasks::ActiveModel { + id: Set(Uuid::new_v4()), + group_id: Set(Some(event_id)), + task_name: Set(format!("AWD Event {event_id} planned start")), + description: Set(Some( + "Planned start: begin competition at scheduled time".into(), + )), + task_key: Set(task_key), + trigger_type: Set("once".into()), + status: Set("pending".into()), + execute_at: Set(Some(start_at)), + expires_at: Set(Some(start_at + Duration::hours(6))), + payload: Set(Some(serde_json::json!({ "event_id": event_id }))), + enabled: Set(true), + protected: Set(true), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await?; + + Ok(Some(task)) +} + fn event_id_from_task(task: &scheduled_tasks::Model) -> anyhow::Result { let payload: RoundTaskPayload = serde_json::from_value(task.payload.clone().unwrap_or_default())?; diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 794d0676..8466ef76 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -38,7 +38,27 @@ pub async fn start_event( // Validate: must have verified_at set if awd_event.verified_at.is_none() { return Err(AwdError::InvalidState( - "Cannot start event: precheck has not passed (no verified_at)".into(), + "Cannot start event: precheck has not passed (AWD_NOT_VERIFIED)".into(), + )); + } + + // P2-11 Start Gate:配置代数必须匹配(配置在验证后变更 → StartBlocked) + if awd_event + .verified_generation + .map(|g| g != awd_event.configuration_generation) + .unwrap_or(true) + { + // 进入 StartBlocked(状态机合法路径) + let _ = event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Verified, + AwdEventStatus::StartBlocked, + event_repo::TransitionPatch::config_changed(), + ) + .await; + return Err(AwdError::InvalidState( + "Cannot start event: configuration changed since verification (AWD_CONFIG_CHANGED)".into(), )); } diff --git a/apps/api/src/modules/event/awd_team/service/precheck_service.rs b/apps/api/src/modules/event/awd_team/service/precheck_service.rs index deff0705..73f1c15e 100644 --- a/apps/api/src/modules/event/awd_team/service/precheck_service.rs +++ b/apps/api/src/modules/event/awd_team/service/precheck_service.rs @@ -185,14 +185,15 @@ pub async fn run_precheck( .map_err(|e| AwdError::Database(e.to_string()))?; if is_passed { - // Mark event as verified(守卫版:要求当前 Prechecking,Phase 0) + // Mark event as verified(守卫版:要求当前 Prechecking,Phase 0;P2-9 记录配置代数) let revision = compute_revision(&awd_event); + let generation = read_configuration_generation(db, awd_event.id).await?; event_repo::transition_event( db, awd_event.id, AwdEventStatus::Prechecking, AwdEventStatus::Verified, - event_repo::TransitionPatch::verified(&revision), + event_repo::TransitionPatch::verified_with_generation(&revision, generation), ) .await?; @@ -288,6 +289,20 @@ fn validate_config(event: &awd_events::Model) -> Result<(), String> { Ok(()) } +/// 读取当前配置代数(P2-9)。 +async fn read_configuration_generation( + db: &sea_orm::DatabaseConnection, + awd_event_id: Uuid, +) -> AwdResult { + use sea_orm::EntityTrait; + let row = awd_events::Entity::find_by_id(awd_event_id) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + Ok(row.configuration_generation) +} + /// Compute a configuration revision hash for verification tracking. fn compute_revision(event: &awd_events::Model) -> String { use sha2::{Digest, Sha256}; diff --git a/apps/api/src/sql/merged.sql b/apps/api/src/sql/merged.sql index 19c73048..ff5a9294 100644 --- a/apps/api/src/sql/merged.sql +++ b/apps/api/src/sql/merged.sql @@ -1,8 +1,8 @@ -- ================================================================================================ -- ================================================================================================ -- FloatCTF Merged Migrations --- Generated at: 2026-08-08 12:09:35 +0800 --- Migration count: 17 +-- Generated at: 2026-08-08 12:49:14 +0800 +-- Migration count: 18 -- ================================================================================================ -- ================================================================================================ @@ -1894,6 +1894,40 @@ COMMIT; -- ================================================================================================ +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808124905-awd-configuration-generation.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808124905-awd-configuration-generation +-- ================================================================================ +-- Phase 2 P2-9:configuration_generation / verified_generation 机制。 +-- 所有影响 runtime 的配置写入口调用 touch_configuration → configuration_generation += 1; +-- Precheck 成功:verified_generation = configuration_generation; +-- Start 校验两者相等,不匹配 → StartBlocked(AWD_CONFIG_CHANGED)。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_events" + ADD COLUMN IF NOT EXISTS "configuration_generation" BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "verified_generation" BIGINT NULL; + +COMMENT ON COLUMN "awd_events"."configuration_generation" IS '配置代数:影响 runtime 的配置每次变更 +1(Phase 2 P2-9)'; +COMMENT ON COLUMN "awd_events"."verified_generation" IS '已验证代数:Precheck 成功时记录当时的 configuration_generation(Phase 2 P2-9)'; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808124905-awd-configuration-generation.sql +-- ================================================================================================ +-- ================================================================================================ + + -- ================================================================================================ -- ================================================================================================ -- End of FloatCTF Migrations diff --git a/apps/api/src/sql/migrations/20260808124905-awd-configuration-generation.sql b/apps/api/src/sql/migrations/20260808124905-awd-configuration-generation.sql new file mode 100644 index 00000000..27600071 --- /dev/null +++ b/apps/api/src/sql/migrations/20260808124905-awd-configuration-generation.sql @@ -0,0 +1,19 @@ +-- ================================================================================ +-- Migration: 20260808124905-awd-configuration-generation +-- ================================================================================ +-- Phase 2 P2-9:configuration_generation / verified_generation 机制。 +-- 所有影响 runtime 的配置写入口调用 touch_configuration → configuration_generation += 1; +-- Precheck 成功:verified_generation = configuration_generation; +-- Start 校验两者相等,不匹配 → StartBlocked(AWD_CONFIG_CHANGED)。 +-- ================================================================================ + +BEGIN; + +ALTER TABLE "awd_events" + ADD COLUMN IF NOT EXISTS "configuration_generation" BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "verified_generation" BIGINT NULL; + +COMMENT ON COLUMN "awd_events"."configuration_generation" IS '配置代数:影响 runtime 的配置每次变更 +1(Phase 2 P2-9)'; +COMMENT ON COLUMN "awd_events"."verified_generation" IS '已验证代数:Precheck 成功时记录当时的 configuration_generation(Phase 2 P2-9)'; + +COMMIT; diff --git a/apps/api/tests/awd_transition_guard.rs b/apps/api/tests/awd_transition_guard.rs index 2c6f8d64..bb11db30 100644 --- a/apps/api/tests/awd_transition_guard.rs +++ b/apps/api/tests/awd_transition_guard.rs @@ -47,7 +47,7 @@ fn base_awd_event(event_id: Uuid, tag: &str) -> awd_events::ActiveModel { } } -async fn seed_event(txn: &sea_orm::DatabaseTransaction, tag: &str) -> (Uuid, awd_events::Model) { +async fn seed_event(conn: &C, tag: &str) -> (Uuid, awd_events::Model) { let event_id = Uuid::new_v4(); let parent = events::ActiveModel { id: Set(event_id), @@ -56,9 +56,9 @@ async fn seed_event(txn: &sea_orm::DatabaseTransaction, tag: &str) -> (Uuid, awd end_time: Set((chrono::Utc::now() + chrono::Duration::hours(1)).into()), ..Default::default() }; - parent.insert(txn).await.expect("insert parent events row"); + parent.insert(conn).await.expect("insert parent events row"); let model = base_awd_event(event_id, tag) - .insert(txn) + .insert(conn) .await .expect("insert awd_events row"); (event_id, model) @@ -289,3 +289,80 @@ async fn legal_transitions_smoke_across_table() { txn.rollback().await.ok(); let _ = event_id; } + +#[tokio::test] +async fn configuration_generation_gates_start() { + use floatctf::modules::event::awd_team::{ + infrastructure::{ + firewall::NoopFirewallRuntime, network::NoopNetworkRuntime, + }, + service::event_service, + }; + + let Some(db) = connect_or_skip().await else { return }; + + // 推进到 Verified 的辅助:直接在主连接上操作(start_event 需要 &DatabaseConnection) + async fn to_verified( + db: &sea_orm::DatabaseConnection, + awd_id: Uuid, + ) { + for (from, to) in [ + (AwdEventStatus::Draft, AwdEventStatus::Configuring), + (AwdEventStatus::Configuring, AwdEventStatus::Prechecking), + ] { + event_repo::transition_event(db, awd_id, from, to, Default::default()) + .await + .expect("forward"); + } + event_repo::transition_event( + db, + awd_id, + AwdEventStatus::Prechecking, + AwdEventStatus::Verified, + event_repo::TransitionPatch::verified_with_generation("rev-1", 0), + ) + .await + .expect("verified"); + } + + // 用例 1:generation 匹配 → start 成功 + let (_event_id, awd) = seed_event(&db, "gen").await; + to_verified(&db, awd.id).await; + let network = NoopNetworkRuntime; + let firewall = NoopFirewallRuntime; + event_service::start_event(&db, &network, &firewall, _event_id) + .await + .expect("start must pass when verified_generation == configuration_generation"); + + // 用例 2:touch_configuration 后失配 → StartBlocked(AWD_CONFIG_CHANGED) + let (_eid2, awd2) = seed_event(&db, "gen2").await; + to_verified(&db, awd2.id).await; + event_repo::touch_configuration(&db, awd2.id) + .await + .expect("touch_configuration"); + let err = event_service::start_event(&db, &network, &firewall, _eid2) + .await + .expect_err("start must be blocked after config change"); + assert!( + err.to_string().contains("AWD_CONFIG_CHANGED"), + "expected AWD_CONFIG_CHANGED, got {err}" + ); + let row = awd_events::Entity::find_by_id(awd2.id).one(&db).await.unwrap().unwrap(); + assert_eq!(row.status, AwdEventStatus::StartBlocked); + + // 清理:删除轮次/事件/父行(settings revision 全局保留) + use sea_orm::QueryFilter; + use floatctf::entity::awd_rounds; + let _ = awd_rounds::Entity::delete_many() + .filter(awd_rounds::Column::EventId.is_in([_event_id, _eid2])) + .exec(&db) + .await; + let _ = awd_events::Entity::delete_many() + .filter(awd_events::Column::EventId.is_in([_event_id, _eid2])) + .exec(&db) + .await; + let _ = events::Entity::delete_many() + .filter(events::Column::Id.is_in([_event_id, _eid2])) + .exec(&db) + .await; +} diff --git a/apps/web/src/entity/awd_events.ts b/apps/web/src/entity/awd_events.ts index 5faefdb8..aea7e047 100644 --- a/apps/web/src/entity/awd_events.ts +++ b/apps/web/src/entity/awd_events.ts @@ -40,4 +40,6 @@ export type AwdEvents = { created_at: string; updated_at: string; paused_phase?: AwdPhase; + configuration_generation: number; + verified_generation?: string; }; From 0b54982881a55b5831ae935a4db4ac1ce1df6782 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 12:57:13 +0800 Subject: [PATCH 006/486] =?UTF-8?q?feat(awd):=20round=5Fservice=20?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E9=97=AD=E7=8E=AF=EF=BC=88Phase=203=20P3-1/2?= =?UTF-8?q?/3/4/6=20=E6=A0=B8=E5=BF=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start_round:事务内 lock event→检查 Running→find-or-create round(幂等) →更新 event phase→插入 RoundEnd 任务;COMMIT 后 reconcile + conntrack + judge dispatch - end_round:Active→Grace(写 grace_ends_at)+ 插入 GraceEnd 任务;幂等跳过 - grace_end_round:Grace→Completed;event 仍 Running 则调度 RoundStart(N+1) - RoundTaskPayload 带 round_id(end/grace_end 幂等维度) - round_repo/judge_repo 关键函数泛型化支持事务内调用 --- .../modules/event/awd_team/repo/judge_repo.rs | 5 +- .../modules/event/awd_team/repo/round_repo.rs | 16 +- .../src/modules/event/awd_team/service/mod.rs | 1 + .../event/awd_team/service/round_service.rs | 397 ++++++++++++++++++ 4 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/modules/event/awd_team/service/round_service.rs diff --git a/apps/api/src/modules/event/awd_team/repo/judge_repo.rs b/apps/api/src/modules/event/awd_team/repo/judge_repo.rs index 4a49275e..326cc151 100644 --- a/apps/api/src/modules/event/awd_team/repo/judge_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/judge_repo.rs @@ -1,7 +1,8 @@ //! Judge batch and task repository. use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, + EntityTrait, QueryFilter, }; use uuid::Uuid; @@ -54,7 +55,7 @@ pub async fn update_task_status( } pub async fn timeout_pending_tasks( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), round_id: Uuid, ) -> Result { let result = awd_judge_tasks::Entity::update_many() diff --git a/apps/api/src/modules/event/awd_team/repo/round_repo.rs b/apps/api/src/modules/event/awd_team/repo/round_repo.rs index ebd438e4..e9f88e6e 100644 --- a/apps/api/src/modules/event/awd_team/repo/round_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/round_repo.rs @@ -1,15 +1,15 @@ //! Round repository. use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, - QueryOrder, TransactionTrait, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, + EntityTrait, QueryFilter, QueryOrder, }; use uuid::Uuid; use crate::entity::{awd_rounds, sea_orm_active_enums::RoundStatus}; pub async fn find_active_round( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), event_id: Uuid, ) -> Result, sea_orm::DbErr> { awd_rounds::Entity::find() @@ -24,7 +24,7 @@ pub async fn find_active_round( } pub async fn find_latest_round( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), event_id: Uuid, ) -> Result, sea_orm::DbErr> { awd_rounds::Entity::find() @@ -35,7 +35,7 @@ pub async fn find_latest_round( } pub async fn find_round_by_id( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), id: Uuid, ) -> Result, sea_orm::DbErr> { awd_rounds::Entity::find_by_id(id).one(db).await @@ -43,7 +43,7 @@ pub async fn find_round_by_id( /// Create a new round in a transaction, ensuring at most one active round. pub async fn create_round( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), event_id: Uuid, round_number: i32, phase: crate::entity::sea_orm_active_enums::AwdPhase, @@ -64,7 +64,7 @@ pub async fn create_round( } pub async fn update_round_status( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), id: Uuid, status: RoundStatus, ) -> Result<(), sea_orm::DbErr> { @@ -82,7 +82,7 @@ pub async fn update_round_status( } pub async fn pause_round( - db: &DatabaseConnection, + db: &(impl ConnectionTrait + Send), id: Uuid, remaining_secs: i32, ) -> Result<(), sea_orm::DbErr> { diff --git a/apps/api/src/modules/event/awd_team/service/mod.rs b/apps/api/src/modules/event/awd_team/service/mod.rs index a7d8a184..d444da3f 100644 --- a/apps/api/src/modules/event/awd_team/service/mod.rs +++ b/apps/api/src/modules/event/awd_team/service/mod.rs @@ -10,6 +10,7 @@ pub mod judge_service; pub mod precheck_service; pub mod recovery_service; pub mod reset_service; +pub mod round_service; pub mod score_service; pub mod submission_service; pub mod wireguard_service; diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs new file mode 100644 index 00000000..622a09e6 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -0,0 +1,397 @@ +//! Round lifecycle service (Phase 3 P3-1..P3-6). +//! +//! Round 调度闭环: +//! +//! ```text +//! RoundStart(N) → 事务(lock event → 检查 Running → find-or-create round(N) +//! → 更新 event phase → 插入 RoundEnd(N) 任务) → COMMIT +//! → firewall reconcile(DB desired phase → 全局 nftables) +//! → conntrack flush → dispatch judge → publish +//! RoundEnd(N) → 事务(lock round → Grace + grace_ends_at → 插入 GraceEnd(N)) → COMMIT +//! GraceEnd(N) → 事务(lock round → Completed + completed_at) +//! → 若 event 仍 Running → 插入 RoundStart(N+1) +//! ``` +//! +//! 设计约束(chore/plans/awd/03-phase3-core-loop.md §5.1/§5.4): +//! - 外部副作用(nft reconcile / conntrack / judge dispatch)**不进长 DB 事务**; +//! - round 任务幂等:`find-or-create` + 状态机守卫,scheduler retry 不产生重复 round; +//! - phase 切换唯一路径 = DB desired phase → 全局 DesiredFirewallState → reconcile。 + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, + QueryOrder, TransactionTrait, +}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::entity::{ + awd_events, awd_rounds, scheduled_tasks, + sea_orm_active_enums::{AwdEventStatus, AwdPhase, RoundStatus}, +}; +use crate::modules::event::awd_team::{ + AwdError, AwdResult, + domain::AwdEventStatusExt, + infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, + repo::{event_repo, judge_repo, round_repo}, + service::{firewall_service, judge_service}, +}; +use crate::scheduler::TaskKey; + +/// Round 任务 payload:end/grace_end 任务带 round_id(幂等键维度)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoundTaskPayload { + pub event_id: Uuid, + pub round_id: Option, +} + +/// RoundStart 处理结果。 +#[derive(Debug, Clone)] +pub struct RoundStarted { + pub round_id: Uuid, + pub round_number: i32, + pub phase: AwdPhase, + /// true = 本次实际创建了新 round;false = 幂等命中已存在 round(retry)。 + pub created: bool, +} + +/// 在事务内创建一次性 round 任务(end/grace_end/start)。 +async fn schedule_round_task( + txn: &C, + event_id: Uuid, + task_key: TaskKey, + execute_at: chrono::DateTime, + round_id: Option, +) -> Result<(), sea_orm::DbErr> { + let now = chrono::Utc::now(); + scheduled_tasks::ActiveModel { + id: Set(Uuid::new_v4()), + group_id: Set(Some(event_id)), + task_name: Set(format!("AWD round task {task_key} event {event_id}")), + description: Set(Some(format!("round_id={}", round_id.unwrap_or_default()))), + task_key: Set(task_key.to_string()), + trigger_type: Set("once".into()), + status: Set("pending".into()), + execute_at: Set(Some(execute_at)), + payload: Set(Some( + serde_json::to_value(RoundTaskPayload { event_id, round_id }) + .map_err(|e| sea_orm::DbErr::Json(e.to_string()))?, + )), + enabled: Set(true), + protected: Set(true), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(txn) + .await?; + Ok(()) +} + +/// 开始一轮(P3-1/P3-2/P3-3/P3-6)。 +/// +/// 幂等:同 (event_id, round_number) 已存在 → 返回既有 round(retry 安全)。 +pub async fn start_round( + db: &sea_orm::DatabaseConnection, + network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, + event_id: Uuid, +) -> AwdResult { + // ── 事务内:DB 状态变更 ── + let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + + let awd_event = event_repo::find_by_event_id(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + + if awd_event.status != AwdEventStatus::Running { + warn!( + "[Round] Event {} is not Running ({:?}) — skip round start", + event_id, awd_event.status + ); + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Err(AwdError::InvalidState(format!( + "Cannot start round in {:?} status", + awd_event.status + ))); + } + + // 上一轮残留清理(崩溃恢复场景:active 轮次未 Completed) + if let Some(prev) = round_repo::find_active_round(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + { + info!("[Round] Completing leftover active round {} for event {}", prev.round_number, event_id); + let timed_out = judge_repo::timeout_pending_tasks(&txn, prev.id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + if timed_out > 0 { + warn!("[Round] {timed_out} pending judge tasks timed out"); + } + round_repo::update_round_status(&txn, prev.id, RoundStatus::Completed) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + } + + // round number:latest + 1(或 1) + let latest = round_repo::find_latest_round(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + let round_number = latest.map(|r| r.round_number + 1).unwrap_or(1); + + // 幂等:同 round_number 已存在 → 直接返回(retry 不重复创建) + let existing = awd_rounds::Entity::find() + .filter(awd_rounds::Column::EventId.eq(event_id)) + .filter(awd_rounds::Column::RoundNumber.eq(round_number)) + .one(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + if let Some(round) = existing { + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Ok(RoundStarted { + round_id: round.id, + round_number: round.round_number, + phase: round.phase, + created: false, + }); + } + + // phase:round 1 = Hardening,后续 = Attack + let phase = if round_number == 1 { + AwdPhase::Hardening + } else { + AwdPhase::Attack + }; + + // 更新 event phase(守卫 repo 方法:Hardening↔Attack 合法) + event_repo::update_phase(&txn, awd_event.id, phase.clone()) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + let now = chrono::Utc::now(); + let round_end = now + chrono::Duration::seconds(awd_event.round_duration_secs as i64); + let round = round_repo::create_round(&txn, event_id, round_number, phase.clone(), round_end) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 插入 RoundEnd 任务 + schedule_round_task( + &txn, + event_id, + TaskKey::AwdRoundEnd, + round_end.fixed_offset(), + Some(round.id), + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + + info!( + "[Round] Round {} (phase {:?}) started for event {}", + round.round_number, phase, event_id + ); + + // ── COMMIT 后:网络副作用(横切规则 3)── + // phase 切换 = DB desired → 全局 DesiredFirewallState → nftables reconcile(P3-6) + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; + + // judge batch 分发 + dispatch_judge_for_round(db, &awd_event, round.id, event_id).await?; + + Ok(RoundStarted { + round_id: round.id, + round_number: round.round_number, + phase, + created: true, + }) +} + +/// RoundEnd:进入 Grace(P3-4)。 +/// +/// 幂等:非 Active 状态(已 Grace/Completed)→ 直接成功跳过。 +pub async fn end_round( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, + round_id: Uuid, +) -> AwdResult<()> { + let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + + let round = awd_rounds::Entity::find_by_id(round_id) + .one(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("Round not found".into()))?; + if round.event_id != event_id { + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Err(AwdError::Forbidden("Round belongs to another event".into())); + } + + if round.status != RoundStatus::Active { + warn!("[Round] Round {} already {:?} — skip grace", round_id, round.status); + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Ok(()); + } + + // 超时未回 judge 任务 + let timed_out = judge_repo::timeout_pending_tasks(&txn, round_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + if timed_out > 0 { + warn!("[Round] Round {round_id} end: {timed_out} pending judge tasks timed out"); + } + + // grace 周期 + let awd_event = event_repo::find_by_event_id(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + let grace_ends_at = + chrono::Utc::now() + chrono::Duration::seconds(awd_event.judge_grace_period_secs as i64); + + let mut active: awd_rounds::ActiveModel = awd_rounds::ActiveModel { + id: Set(round_id), + status: Set(RoundStatus::Grace), + grace_ends_at: Set(Some(grace_ends_at.into())), + ..Default::default() + }; + active.update(&txn).await.map_err(|e| AwdError::Database(e.to_string()))?; + + // 插入 GraceEnd 任务 + schedule_round_task( + &txn, + event_id, + TaskKey::AwdRoundGraceEnd, + grace_ends_at.fixed_offset(), + Some(round_id), + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + + info!("[Round] Round {round_id} entered grace until {grace_ends_at}"); + Ok(()) +} + +/// GraceEnd:Complete 当前轮,若赛事仍 Running 则调度下一轮(N+1,P3-1)。 +/// +/// 幂等:非 Grace 状态 → 跳过。 +pub async fn grace_end_round( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, + round_id: Uuid, +) -> AwdResult<()> { + let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + + let round = awd_rounds::Entity::find_by_id(round_id) + .one(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("Round not found".into()))?; + if round.event_id != event_id { + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Err(AwdError::Forbidden("Round belongs to another event".into())); + } + + if round.status != RoundStatus::Grace { + warn!("[Round] Round {round_id} not in Grace ({:?}) — skip", round.status); + txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + return Ok(()); + } + + round_repo::update_round_status(&txn, round_id, RoundStatus::Completed) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 若赛事仍 Running → RoundStart(N+1) + let awd_event = event_repo::find_by_event_id(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; + if awd_event.status == AwdEventStatus::Running { + schedule_round_task( + &txn, + event_id, + TaskKey::AwdRoundStart, + chrono::Utc::now().fixed_offset(), + None, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + } + + txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + + info!("[Round] Round {round_id} completed for event {event_id}"); + Ok(()) +} + +/// 分发 judge batch(沿用原 handler 逻辑)。 +async fn dispatch_judge_for_round( + db: &sea_orm::DatabaseConnection, + awd_event: &awd_events::Model, + round_id: Uuid, + event_id: Uuid, +) -> AwdResult<()> { + let token_ciphertext = awd_event + .judgeserver_token_ciphertext + .clone() + .ok_or_else(|| AwdError::NotFound("JudgeServer token not configured".into()))?; + let token_nonce = awd_event + .judgeserver_token_nonce + .clone() + .ok_or_else(|| AwdError::NotFound("JudgeServer token nonce not configured".into()))?; + let crypto = crate::modules::event::awd_team::crypto::AwdCrypto::from_config_secret() + .map_err(|e| AwdError::Crypto(e.to_string()))?; + let token = crypto + .decrypt( + &crate::modules::event::awd_team::crypto::EncryptedBlob { + ciphertext: token_ciphertext, + nonce: token_nonce, + key_version: awd_event.key_version, + }, + &crate::modules::event::awd_team::crypto::AwdCrypto::build_aad( + event_id, + "internal_token", + ), + ) + .map_err(|e| AwdError::Crypto(e.to_string()))?; + let token = String::from_utf8(token).map_err(|_| AwdError::Crypto("token not utf8".into()))?; + + let batch_id = judge_service::create_batch(db, event_id, round_id).await?; + let judgeserver_url = format!("http://{}:8082", awd_event.judgeserver_ip); + judge_service::dispatch_batch(db, batch_id, &judgeserver_url, &token).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_round_trips_with_round_id() { + let p = RoundTaskPayload { + event_id: Uuid::new_v4(), + round_id: Some(Uuid::new_v4()), + }; + let v = serde_json::to_value(&p).unwrap(); + let back: RoundTaskPayload = serde_json::from_value(v).unwrap(); + assert_eq!(back.event_id, p.event_id); + assert_eq!(back.round_id, p.round_id); + } + + #[test] + fn payload_defaults_round_id_none() { + let v = serde_json::json!({ "event_id": Uuid::new_v4() }); + let back: RoundTaskPayload = serde_json::from_value(v).unwrap(); + assert!(back.round_id.is_none()); + } +} From a505a0a4eeda94adb0ab1954e53b0397cbf3b089 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:00:14 +0800 Subject: [PATCH 007/486] =?UTF-8?q?feat(awd):=20judge=20deadline=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=AE=A1=E5=88=86=EF=BC=88P3-5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - score_judge_timeouts:round 超时任务按 -down_points 计分(JudgeDown + reason 标注) - 幂等键 judge-timeout:{task_id};接入 start_round 残留轮次清理 - 修复 deploy_service 测试 Model 构造缺新列 --- .../event/awd_team/domain/execution.rs | 18 +++++ .../src/modules/event/awd_team/domain/mod.rs | 2 + .../infrastructure/firewall/render.rs | 3 + .../event/awd_team/service/deploy_service.rs | 2 + .../event/awd_team/service/round_service.rs | 75 ++++++++++++++++++- 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/event/awd_team/domain/execution.rs diff --git a/apps/api/src/modules/event/awd_team/domain/execution.rs b/apps/api/src/modules/event/awd_team/domain/execution.rs new file mode 100644 index 00000000..42962da2 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/domain/execution.rs @@ -0,0 +1,18 @@ +//! 执行上下文(Phase 2 P2-1 / 计划 §5.1)。 +//! +//! Precheck 与正式比赛共用底层运行时,但**隔离**约束不同: +//! - `Competition`:正式比赛路径 —— 允许写正式 round / score / awd_flag_issues 等表; +//! - `Precheck`:预检路径 —— 只读 / 隔离,不得污染正式比赛状态。 +//! +//! 本 Phase(P2-1..P2-8)先落地类型与参数接线;正式 issue / judge 调用链在 Phase 3 接入。 + +use uuid::Uuid; + +/// 执行上下文:区分预检(隔离)与正式比赛(可写)路径。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecutionContext { + /// 正式比赛:写正式状态。 + Competition, + /// 预检:只读/隔离路径,带 run_id 标记,不写正式 awd_flag_issues / score 表。 + Precheck { run_id: Uuid }, +} diff --git a/apps/api/src/modules/event/awd_team/domain/mod.rs b/apps/api/src/modules/event/awd_team/domain/mod.rs index 57e19d6a..b372bb1c 100644 --- a/apps/api/src/modules/event/awd_team/domain/mod.rs +++ b/apps/api/src/modules/event/awd_team/domain/mod.rs @@ -10,6 +10,7 @@ //! Generated entity enums are re-exported here for call-site convenience; they must //! never be edited by hand (sea-orm-cli only). +pub mod execution; pub mod firewall_state; pub mod flag; pub mod network; @@ -26,6 +27,7 @@ mod gamebox_ext; mod round_ext; pub use event_ext::*; +pub use execution::ExecutionContext; pub use firewall_state::{DesiredEventPolicy, DesiredFirewallState, DesiredTeamPolicy, IpNet}; pub use flag::{generate_flag, hash_flag, verify_flag}; pub use gamebox_ext::*; diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs index b539df6f..740e61eb 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/render.rs @@ -206,12 +206,15 @@ pub struct ObservedFirewallState { pub observed_revision: Option, /// 观测到的 event chains(`event_`)。 pub event_chains: Vec, + /// 原始 `nft list table` 输出(P2-5 结构检查用:chain awd_forward / hook forward)。 + pub raw_output: String, pub notes: Vec, } /// 从 `nft list table inet floatctf_awd` 输出解析观测状态(P1-9 verify 用)。 pub fn parse_observed_table(output: &str) -> ObservedFirewallState { let mut state = ObservedFirewallState::default(); + state.raw_output = output.to_string(); state.table_exists = output.contains("table inet floatctf_awd"); for line in output.lines() { let line = line.trim(); diff --git a/apps/api/src/modules/event/awd_team/service/deploy_service.rs b/apps/api/src/modules/event/awd_team/service/deploy_service.rs index d310716c..18634a7f 100644 --- a/apps/api/src/modules/event/awd_team/service/deploy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/deploy_service.rs @@ -830,6 +830,8 @@ mod tests { wg_server_private_key_nonce: None, wg_server_public_key: None, key_version: 1, + configuration_generation: 0, + verified_generation: None, free_reset_count: 0, extra_reset_penalty: 0, reset_protection_secs: 0, diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 622a09e6..b237bbc3 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -26,14 +26,14 @@ use tracing::{info, warn}; use uuid::Uuid; use crate::entity::{ - awd_events, awd_rounds, scheduled_tasks, - sea_orm_active_enums::{AwdEventStatus, AwdPhase, RoundStatus}, + awd_events, awd_judge_tasks, awd_rounds, scheduled_tasks, + sea_orm_active_enums::{AwdEventStatus, AwdPhase, JudgeTaskStatus, RoundStatus, ScoreEventType}, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, domain::AwdEventStatusExt, infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, - repo::{event_repo, judge_repo, round_repo}, + repo::{event_repo, judge_repo, round_repo, score_repo}, service::{firewall_service, judge_service}, }; use crate::scheduler::TaskKey; @@ -128,6 +128,8 @@ pub async fn start_round( .map_err(|e| AwdError::Database(e.to_string()))?; if timed_out > 0 { warn!("[Round] {timed_out} pending judge tasks timed out"); + // P3-5:deadline 超时计分(在事务外,读已提交的 JudgeTimeout 状态) + score_judge_timeouts(db, prev.id).await?; } round_repo::update_round_status(&txn, prev.id, RoundStatus::Completed) .await @@ -240,7 +242,7 @@ pub async fn end_round( return Ok(()); } - // 超时未回 judge 任务 + // 超时未回 judge 任务(deadline 强制,P3-5) let timed_out = judge_repo::timeout_pending_tasks(&txn, round_id) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -372,6 +374,71 @@ async fn dispatch_judge_for_round( Ok(()) } +/// P3-5 deadline 强制:给指定 round 中超时(deadline 未回)的 judge 任务计分。 +/// +/// 语义:超时 = 防御方未响应 = 视为 down(-down_points)。 +/// score_event_type 枚举无 JudgeTimeout 变体(避免 enum 迁移), +/// 用 JudgeDown + reason="judge deadline timeout" 表达,审计可区分。 +/// 幂等键 `judge-timeout:{task_id}`(scheduler retry 不重复计分)。 +/// 返回计分的任务数。 +pub async fn score_judge_timeouts( + db: &sea_orm::DatabaseConnection, + round_id: Uuid, +) -> AwdResult { + let timed_out = awd_judge_tasks::Entity::find() + .filter(awd_judge_tasks::Column::RoundId.eq(round_id)) + .filter(awd_judge_tasks::Column::Status.eq(JudgeTaskStatus::JudgeTimeout)) + .all(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 收集涉及到的 template 分值 + use crate::entity::awd_gamebox_templates; + let mut template_points: std::collections::HashMap = std::collections::HashMap::new(); + for t in &timed_out { + if !template_points.contains_key(&t.template_id) { + if let Some(tpl) = awd_gamebox_templates::Entity::find_by_id(t.template_id) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + { + template_points.insert(t.template_id, tpl.down_points); + } + } + } + + let mut scored = 0u64; + for t in &timed_out { + let delta = -template_points.get(&t.template_id).copied().unwrap_or(0); + let key = format!("judge-timeout:{}", t.id); + match score_repo::create_score_event( + db, + t.event_id, + Some(round_id), + t.team_id, + ScoreEventType::JudgeDown, + delta, + &key, + None, + Some(t.gamebox_instance_id), + Some(t.template_id), + Some("judge deadline timeout"), + ) + .await + { + Ok(_) => scored += 1, + Err(e) => { + // 幂等重复(scheduler retry)不视为错误 + let msg = e.to_string().to_lowercase(); + if !(msg.contains("23505") || msg.contains("duplicate")) { + return Err(AwdError::Database(e.to_string())); + } + } + } + } + Ok(scored) +} + #[cfg(test)] mod tests { use super::*; From 8e4098dec2108f079b0271cccbc233416e65b9ec Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:04:09 +0800 Subject: [PATCH 008/486] =?UTF-8?q?feat(awd):=20precheck=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=8E=AF=E5=A2=83=E6=A3=80=E6=9F=A5=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=8E=A5=E5=85=A5=EF=BC=88P2-1..P2-8/P2-13?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_precheck 增加 network/firewall/containers/crypto 参数(P2-1), admin 端点与 AwdAutoPrecheckHandler/bootstrap 接线(参考 AwdEventStartHandler) - 新增 ExecutionContext 枚举(Competition / Precheck{run_id},§5.1), 预检路径只读隔离,不写正式 awd_flag_issues/score 表 - P2-2 Docker 存活:flagserver/judgeserver 容器 + GameBox 实例 inspect running - P2-3 SSH 可达:解密 ssh_password(AAD=event_id:ssh_password)+ ssh -o BatchMode=yes -o ConnectTimeout=3 ... root@ true; 无容器软跳过(note-only),FLOATCTF_PRECHECK_SSH=1 才真跑 - P2-4 WG:inspect 接口 up + DB Active peers 计数,接口未 up 判 fail - P2-5 Firewall 结构:table 存在 + chain awd_forward + hook forward + observed_revision==current_network_revision;Noop 空观测必 fail(双门禁) - P2-6 网络矩阵:FirewallRuntime::verify(desired);真实包流 probe 注释留给 Phase 5 - P2-13:discover_environment() 快照写入 network_check jsonb(非密钥) - ObservedFirewallState 增加 raw_output 字段(结构检查用) - 单测:evaluate_precheck / revision_matches / ssh_probe_enabled / Noop firewall 与 network_matrix 必 fail / report_json --- apps/api/src/bootstrap/mod.rs | 1 + apps/api/src/bootstrap/scheduler.rs | 16 +- .../src/modules/event/awd_team/api/admin.rs | 5 + .../modules/event/awd_team/scheduler/mod.rs | 8 + .../event/awd_team/service/event_service.rs | 3 +- .../awd_team/service/precheck_service.rs | 699 +++++++++++++++++- .../event/awd_team/service/round_service.rs | 78 +- apps/api/tests/awd_transition_guard.rs | 21 +- 8 files changed, 781 insertions(+), 50 deletions(-) diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 93218b54..5aaaeb8f 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -184,6 +184,7 @@ pub async fn run() -> Result<(), BootstrapError> { log_service.clone(), awd_network.clone(), awd_firewall.clone(), + awd_crypto.clone(), ) .await .expect("init startup handlers failed!"); diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index 231e1429..4918a44d 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -8,6 +8,7 @@ use crate::{ infrastructure::LogService, infrastructure::{WebDb, WebDocker, WebRustfs}, modules::event::awd_team::{ + crypto::AwdCrypto, infrastructure::firewall::FirewallRuntime, scheduler::{ AwdArchiveCleanupHandler, AwdAutoPrecheckHandler, AwdEventStartHandler, @@ -28,9 +29,14 @@ pub async fn build_task_scheduler( logger: LogService, network: Arc, firewall: Arc, + crypto: Arc, ) -> Result { let mut scheduler = TaskScheduler::new(db.clone(), docker.clone(), rustfs.clone(), logger); + // AWD 容器 runtime:自动 precheck 与 archive cleanup 共享同一实例。 + let awd_containers: Arc = + Arc::new(DockerRuntime::new(docker.get_ref().clone())); + let handlers: Vec> = vec![ Arc::new(CheckPracticeEventHandler { db: db.clone() }), Arc::new(CleanRunningInstancesHandler { @@ -41,7 +47,13 @@ pub async fn build_task_scheduler( db: db.clone(), rustfs, }), - Arc::new(AwdAutoPrecheckHandler { db: db.clone() }), + Arc::new(AwdAutoPrecheckHandler { + db: db.clone(), + network: network.clone(), + firewall: firewall.clone(), + containers: awd_containers.clone(), + crypto: crypto.clone(), + }), Arc::new(AwdEventStartHandler { db: db.clone(), network: network.clone(), @@ -59,7 +71,7 @@ pub async fn build_task_scheduler( db, docker: docker.clone(), network, - containers: Arc::new(DockerRuntime::new(docker.get_ref().clone())), + containers: awd_containers, }), ]; diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index aa66fd40..72babd2e 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -354,6 +354,7 @@ pub async fn get_event_scores( pub async fn run_precheck( _admin: SuperAdminJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path, ) -> UniResult { let event_id = path.into_inner(); @@ -361,6 +362,10 @@ pub async fn run_precheck( ctx.db.get_ref(), event_id, "manual", + awd.network.as_ref(), + awd.firewall.as_ref(), + awd.containers.as_ref(), + awd.crypto.as_ref(), ) .await .map_err(AppError::from)?; diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index 36113049..085fe2ba 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -151,6 +151,10 @@ fn event_id_from_task(task: &scheduled_tasks::Model) -> anyhow::Result { /// Handler: Run the automatic precheck scheduled before an AWD event. pub struct AwdAutoPrecheckHandler { pub db: WebDb, + pub network: Arc, + pub firewall: Arc, + pub containers: Arc, + pub crypto: Arc, } #[async_trait] @@ -170,6 +174,10 @@ impl TaskHandler for AwdAutoPrecheckHandler { self.db.get_ref(), event_id, "scheduled", + self.network.as_ref(), + self.firewall.as_ref(), + self.containers.as_ref(), + self.crypto.as_ref(), ) .await?; Ok(()) diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 8466ef76..58202cdd 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -58,7 +58,8 @@ pub async fn start_event( ) .await; return Err(AwdError::InvalidState( - "Cannot start event: configuration changed since verification (AWD_CONFIG_CHANGED)".into(), + "Cannot start event: configuration changed since verification (AWD_CONFIG_CHANGED)" + .into(), )); } diff --git a/apps/api/src/modules/event/awd_team/service/precheck_service.rs b/apps/api/src/modules/event/awd_team/service/precheck_service.rs index 73f1c15e..cc73b29a 100644 --- a/apps/api/src/modules/event/awd_team/service/precheck_service.rs +++ b/apps/api/src/modules/event/awd_team/service/precheck_service.rs @@ -10,33 +10,69 @@ //! 6. Flag: can issue and re-issue flags //! 7. Judge: scripts execute and callback works //! +//! # 隔离(Phase 2 P2-1 / 计划 §5.1) +//! +//! precheck 在 `ExecutionContext::Precheck { run_id }` 上下文执行:只读 / 隔离路径, +//! **不写正式 awd_flag_issues / score 表**;正式 issue / judge 调用链在 Phase 3 接入 +//! (P2-7/P2-8 本阶段为容器存活探测 + 上下文标注)。 +//! +//! # Noop 双门禁 +//! +//! Noop 网络 / firewall runtime 永远不允许 Verified: +//! - P2-5 firewall 结构检查:Noop inspect 返回空观测(table_exists=false)→ 必 fail; +//! - P2-6 网络矩阵验证:Noop verify 恒返回 verified=false → 必 fail。 +//! //! # Verified Revision //! //! Configuration changes while verified clear the verification. //! On event start, the revision must match. +use std::collections::HashMap; + +use fcmc::AwdContainerRuntime; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, - PaginatorTrait, QueryFilter, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, }; use tracing::info; use uuid::Uuid; use crate::entity::{ - awd_events, awd_precheck_runs, + awd_events, awd_gamebox_instances, awd_precheck_runs, awd_team_networks, sea_orm_active_enums::{AwdEventStatus, PrecheckStatus}, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, - domain::{AwdEventStatusExt, Ipv4Cidr}, - repo::event_repo, + crypto::{AwdCrypto, EncryptedBlob}, + domain::{AwdEventStatusExt, ExecutionContext, Ipv4Cidr, firewall_state::DesiredFirewallState}, + infrastructure::{ + firewall::{FirewallRuntime, HostFirewallEnvironment, ObservedFirewallState, env}, + network::{AwdNetworkRuntime, EventNetworkIdentity}, + }, + repo::{event_repo, gamebox_repo, wireguard_repo}, + service::firewall_service, + system::command::{CommandRunner, RealCommandRunner}, }; +/// P2-3 SSH 探测 env 开关:默认 note-only;`FLOATCTF_PRECHECK_SSH=1` 才真跑。 +/// (按本任务明确要求的环境敏感开关;其余配置仍走 TOML。) +const SSH_PROBE_ENV: &str = "FLOATCTF_PRECHECK_SSH"; + +/// 一次环境检查的输出:errors(判 fail)+ notes(说明,不判 fail)。 +#[derive(Debug, Default, Clone)] +struct CheckReport { + errors: Vec<(String, String)>, + notes: Vec<(String, String)>, +} + /// Run a manual precheck on an event. pub async fn run_precheck( db: &DatabaseConnection, event_id: Uuid, trigger: &str, + network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, + containers: &dyn AwdContainerRuntime, + crypto: &AwdCrypto, ) -> AwdResult { let awd_event = event_repo::find_by_event_id(db, event_id) .await @@ -99,12 +135,18 @@ pub async fn run_precheck( .await .map_err(|e| AwdError::Database(e.to_string()))?; - let mut errors = Vec::new(); + let run_id = run.id; + // 预检执行上下文(P2-1 §5.1):只读/隔离路径,不污染正式比赛状态。 + // 本阶段用于 run 记录标注;正式 issue/judge 调用链 Phase 3 接入。 + let context = ExecutionContext::Precheck { run_id }; + + let mut errors: Vec<(String, String)> = Vec::new(); + let mut notes: Vec<(String, String)> = Vec::new(); // ── Check 1: Config validation ── let config_result = validate_config(&awd_event); if let Err(e) = config_result { - errors.push(("config", e)); + errors.push(("config".to_string(), e)); } // ── Check 2: All teams have networks allocated ── @@ -117,7 +159,10 @@ pub async fn run_precheck( .map_err(|e| AwdError::Database(e.to_string()))?; if teams.is_empty() { - errors.push(("teams", "No teams registered for this event".into())); + errors.push(( + "teams".to_string(), + "No teams registered for this event".into(), + )); } // ── Check 3: GameBox instances exist for all templates × teams ── @@ -129,26 +174,104 @@ pub async fn run_precheck( .map_err(|e| AwdError::Database(e.to_string()))?; if templates.is_empty() { - errors.push(("templates", "No GameBox templates configured".into())); + errors.push(( + "templates".to_string(), + "No GameBox templates configured".into(), + )); } // ── Check 4: Docker network ID set ── if awd_event.docker_network_id.is_none() { - errors.push(("docker", "Docker network not yet created".into())); + errors.push(( + "docker".to_string(), + "Docker network not yet created".into(), + )); } // ── Check 5: FlagServer IP configured ── if awd_event.flagserver_ip.is_empty() { - errors.push(("flagserver", "FlagServer IP not configured".into())); + errors.push(( + "flagserver".to_string(), + "FlagServer IP not configured".into(), + )); } // ── Check 6: JudgeServer IP configured ── if awd_event.judgeserver_ip.is_empty() { - errors.push(("judgeserver", "JudgeServer IP not configured".into())); + errors.push(( + "judgeserver".to_string(), + "JudgeServer IP not configured".into(), + )); } - // Determine overall status - let overall_status = if errors.is_empty() { + // ── Check 7: Docker 存活(P2-2)── + // FlagServer / JudgeServer 容器(名称含 "flagserver"/"judgeserver")running; + // 所有 GameBox 实例 inspect_container running。 + let instances = match gamebox_repo::find_instances_by_event(db, event_id).await { + Ok(list) => list, + Err(e) => { + errors.push(( + "gamebox".to_string(), + format!("find_instances_by_event: {e}"), + )); + Vec::new() + } + }; + let container_report = check_containers(containers, event_id, &instances).await; + errors.extend(container_report.errors.clone()); + notes.extend(container_report.notes.clone()); + + // ── Check 8: SSH 可达(P2-3,环境敏感:默认 note-only)── + let ssh_report = check_ssh(db, containers, crypto, event_id, &instances).await; + errors.extend(ssh_report.errors.clone()); + notes.extend(ssh_report.notes.clone()); + + // ── Check 9: WireGuard(P2-4)── + let (wg_report, wg_observed, wg_active_peers) = + check_wireguard(network, db, event_id, &awd_event.gamebox_cidr).await; + errors.extend(wg_report.errors.clone()); + notes.extend(wg_report.notes.clone()); + + // ── Check 10: Firewall 结构检查(P2-5,nftables + Noop 双门禁)── + let desired_revision = firewall_service::current_network_revision(db).await; + let (fw_report, observed_state) = check_firewall_structure(firewall, desired_revision).await; + errors.extend(fw_report.errors.clone()); + notes.extend(fw_report.notes.clone()); + + // ── Check 11: 网络矩阵验证(P2-6)── + // 真实包流 probe(方案 A:挂起玩家 peers;方案 B:canary namespace/container)由 + // Phase 5 E2E 承接(§5.10:不给真实玩家提前攻击窗口)。本阶段做 desired-state + // 结构验证:revision 全量匹配 + event chains 存在(FirewallRuntime::verify)。 + let matrix_report = match firewall_service::build_desired_state(db, desired_revision).await { + Ok(desired) => check_network_matrix(firewall, &desired).await, + Err(e) => { + let mut r = CheckReport::default(); + r.errors.push(( + "network_matrix".to_string(), + format!("build_desired_state: {e}"), + )); + r + } + }; + errors.extend(matrix_report.errors.clone()); + notes.extend(matrix_report.notes.clone()); + + // ── Check 12/13: Flag / Judge 隔离探测(P2-7/P2-8)── + // 本阶段实现 = 容器存活探测(P2-2 已覆盖)+ precheck 上下文标注; + // 正式 issue / judge 调用链 Phase 3 接入;precheck 不写正式 awd_flag_issues / score 表。 + let flag_check = serde_json::json!({ + "context": "precheck", + "run_id": run_id, + "note": "本阶段 Flag 隔离探测 = FlagServer 容器存活(见 container_check);正式 flag issue 调用链 Phase 3 接入。precheck 不写正式 awd_flag_issues / score 表。", + }); + let judge_check = serde_json::json!({ + "context": "precheck", + "run_id": run_id, + "note": "本阶段 Judge 隔离探测 = JudgeServer 容器存活(见 container_check);正式 judge 调用链 Phase 3 接入。precheck 不产生正式 Judge Task / score。", + }); + + // Determine overall status(纯函数判定) + let overall_status = if evaluate_precheck(&errors) { PrecheckStatus::Passed } else { PrecheckStatus::Failed @@ -156,19 +279,38 @@ pub async fn run_precheck( let is_passed = overall_status == PrecheckStatus::Passed; - let error_details = if errors.is_empty() { + let error_details = if errors.is_empty() && notes.is_empty() { None } else { Some(serde_json::json!({ "errors": errors.iter().map(|(k, v)| { serde_json::json!({"component": k, "error": v}) - }).collect::>() + }).collect::>(), + "notes": notes.iter().map(|(k, v)| { + serde_json::json!({"component": k, "note": v}) + }).collect::>(), })) }; + // P2-13:Host Firewall Environment 快照(非密钥字段)→ network_check。 + let host_env = env::discover_environment().await; + let network_check = serde_json::json!({ + "host_environment": host_env_json(&host_env), + "firewall": { + "desired_revision": desired_revision, + "observed_revision": observed_state.as_ref().and_then(|s| s.observed_revision), + "table_exists": observed_state.as_ref().map(|s| s.table_exists).unwrap_or(false), + "matrix_verified": matrix_report.errors.is_empty(), + }, + "wireguard": { + "interface_up": wg_observed.as_ref().map(|o| o.wireguard_interface_up), + "active_peers": wg_active_peers, + }, + }); + // Update precheck run let mut run_active: awd_precheck_runs::ActiveModel = awd_precheck_runs::ActiveModel { - id: Set(run.id), + id: Set(run_id), status: Set(overall_status), completed_at: Set(Some(chrono::Utc::now().into())), ..Default::default() @@ -178,6 +320,11 @@ pub async fn run_precheck( run_active.error_msg = Set(Some(details.to_string())); } run_active.config_check = Set(error_details.clone()); + run_active.container_check = Set(Some(report_json("containers", &container_report))); + run_active.wireguard_check = Set(Some(report_json("wireguard", &wg_report))); + run_active.network_check = Set(Some(network_check)); + run_active.flag_check = Set(Some(flag_check)); + run_active.judge_check = Set(Some(judge_check)); run_active .update(db) @@ -197,7 +344,10 @@ pub async fn run_precheck( ) .await?; - info!("[Precheck] Event {} verified", event_id); + info!( + "[Precheck] Event {} verified (run {}, context {:?})", + event_id, run_id, context + ); } else { // 失败:Prechecking → VerificationFailed(Phase 0) // 记录失败状态的失败属 best-effort(显式告警,precheck run 记录已落库)。 @@ -223,7 +373,440 @@ pub async fn run_precheck( ); } - Ok(run.id) + Ok(run_id) +} + +// ── 检查实现(free functions,各返回 CheckReport)── + +/// P2-2 Docker 存活检查:FlagServer / JudgeServer 容器(名称含 kind)running; +/// 所有 GameBox 实例(DB 行)inspect_container running。 +async fn check_containers( + containers: &dyn AwdContainerRuntime, + event_id: Uuid, + instances: &[awd_gamebox_instances::Model], +) -> CheckReport { + let mut report = CheckReport::default(); + + let live = match containers.list_event_containers(event_id).await { + Ok(l) => l, + Err(e) => { + report + .errors + .push(("docker".to_string(), format!("list_event_containers: {e}"))); + return report; + } + }; + let live_by_name: HashMap<&str, &fcmc::ContainerState> = live + .iter() + .map(|c| (c.container_name.as_str(), c)) + .collect(); + + // ── FlagServer / JudgeServer(容器名含 "flagserver" / "judgeserver")── + for kind in ["flagserver", "judgeserver"] { + match live.iter().find(|c| c.container_name.contains(kind)) { + Some(c) if c.running => { + report.notes.push(( + format!("{kind}_container"), + format!("running: {}", c.container_id), + )); + } + Some(c) => report.errors.push(( + format!("{kind}_container"), + format!( + "container {} not running (status: {})", + c.container_name, c.status + ), + )), + None => report.errors.push(( + format!("{kind}_container"), + format!("no container matching '{kind}'"), + )), + } + } + + // ── GameBox 实例:DB 行 → inspect_container → running ── + if instances.is_empty() { + report + .notes + .push(("gamebox".to_string(), "no GameBox instances".to_string())); + } + for inst in instances { + let Some(container_id) = inst.container_id.as_deref() else { + report.errors.push(( + "gamebox".to_string(), + format!("{}: no container_id in DB", inst.container_name), + )); + continue; + }; + // 优先按 container_id inspect(deploy 时写入;DB 名 = 容器名,也可按名查)。 + let by_id = containers.inspect_container(container_id).await; + match by_id { + Ok(state) if state.running => { + report.notes.push(( + format!("gamebox:{}", inst.container_name), + format!("running: {}", state.container_id), + )); + } + Ok(state) => report.errors.push(( + "gamebox".to_string(), + format!( + "{}: container not running (status: {})", + inst.container_name, state.status + ), + )), + Err(e) => { + // inspect 失败:回退按容器名在列表里找一次(container_id 可能因重置变化)。 + match live_by_name.get(inst.container_name.as_str()) { + Some(state) if state.running => { + report.notes.push(( + format!("gamebox:{}", inst.container_name), + format!("running (by name): {}", state.container_id), + )); + } + Some(state) => report.errors.push(( + "gamebox".to_string(), + format!( + "{}: container not running (status: {})", + inst.container_name, state.status + ), + )), + None => report.errors.push(( + "gamebox".to_string(), + format!( + "{}: inspect_container({container_id}) failed: {e}", + inst.container_name + ), + )), + } + } + } + } + + report +} + +/// P2-3 SSH 可达检查(环境敏感:默认 note-only;`FLOATCTF_PRECHECK_SSH=1` 才真跑)。 +/// +/// 对每个 GameBox 实例:解密 ssh_password(AAD=`event_id:ssh_password`,凭据完整性, +/// 密码不落日志),然后结构化 argv 跑 +/// `ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no root@ true` +/// 探测连通性。无容器时软跳过(记 note,不判失败)。 +async fn check_ssh( + db: &DatabaseConnection, + containers: &dyn AwdContainerRuntime, + crypto: &AwdCrypto, + event_id: Uuid, + instances: &[awd_gamebox_instances::Model], +) -> CheckReport { + let mut report = CheckReport::default(); + + if !ssh_probe_enabled(std::env::var(SSH_PROBE_ENV).ok().as_deref()) { + report.notes.push(( + "ssh".to_string(), + format!("SSH 探测未启用(设置 {SSH_PROBE_ENV}=1 开启;默认 note-only)"), + )); + return report; + } + + // 无容器软跳过:Docker 不可达或没有任何容器 → 记 note,不判失败。 + match containers.list_event_containers(event_id).await { + Ok(live) if live.is_empty() || instances.is_empty() => { + report + .notes + .push(("ssh".to_string(), "无容器,跳过 SSH 探测".to_string())); + return report; + } + Ok(_) => {} + Err(e) => { + report.notes.push(( + "ssh".to_string(), + format!("Docker 不可达,跳过 SSH 探测:{e}"), + )); + return report; + } + } + + let runner = RealCommandRunner; + for inst in instances { + // 解密 ssh_password(凭据完整性检查;解密结果仅用于校验,不参与 ssh 参数、不落日志) + let network = match awd_team_networks::Entity::find() + .filter(awd_team_networks::Column::EventId.eq(event_id)) + .filter(awd_team_networks::Column::TeamId.eq(inst.team_id)) + .one(db) + .await + { + Ok(Some(n)) => n, + Ok(None) => { + report.errors.push(( + "ssh".to_string(), + format!( + "{}: no awd_team_networks row for team {}", + inst.container_name, inst.team_id + ), + )); + continue; + } + Err(e) => { + report + .errors + .push(("ssh".to_string(), format!("load team network: {e}"))); + continue; + } + }; + let blob = EncryptedBlob { + ciphertext: network.ssh_password_ciphertext, + nonce: network.ssh_password_nonce, + key_version: network.key_version, + }; + let aad = AwdCrypto::build_aad(event_id, "ssh_password"); + if let Err(e) = crypto.decrypt(&blob, &aad) { + report.errors.push(( + "ssh".to_string(), + format!("{}: ssh_password 解密失败: {e}", inst.container_name), + )); + continue; + } + + // 结构化 argv(禁 shell 拼接);BatchMode=yes 禁用密码交互,探测纯连通性。 + let args = vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + "ConnectTimeout=3".to_string(), + "-o".to_string(), + "StrictHostKeyChecking=no".to_string(), + format!("root@{}", inst.gamebox_ip), + "true".to_string(), + ]; + let out = match runner.run("ssh", &args).await { + Ok(o) => o, + Err(e) => { + report.errors.push(( + "ssh".to_string(), + format!( + "{} ({}): 无法执行 ssh: {e}", + inst.container_name, inst.gamebox_ip + ), + )); + continue; + } + }; + if out.exit_code == 0 { + report.notes.push(( + format!("ssh:{}", inst.container_name), + format!("{}: ssh 可达", inst.gamebox_ip), + )); + } else { + report.errors.push(( + "ssh".to_string(), + format!( + "{} ({}): ssh 探测失败 (exit {}): {}", + inst.container_name, + inst.gamebox_ip, + out.exit_code, + out.stderr.trim() + ), + )); + } + } + + report +} + +/// P2-4 WireGuard 检查:接口 up(host inspect)+ DB Active peers 计数。 +async fn check_wireguard( + network: &dyn AwdNetworkRuntime, + db: &DatabaseConnection, + event_id: Uuid, + gamebox_cidr: &str, +) -> ( + CheckReport, + Option, + usize, +) { + let mut report = CheckReport::default(); + + let observed = match network + .inspect(EventNetworkIdentity { + event_id, + gamebox_cidr: gamebox_cidr.to_string(), + }) + .await + { + Ok(o) => o, + Err(e) => { + report + .errors + .push(("wireguard".to_string(), format!("inspect failed: {e}"))); + return (report, None, 0); + } + }; + + for note in &observed.notes { + report.notes.push(("wireguard".to_string(), note.clone())); + } + + let peers = match wireguard_repo::find_active_peers_by_event(db, event_id).await { + Ok(p) => p.len(), + Err(e) => { + report.errors.push(( + "wireguard".to_string(), + format!("find_active_peers_by_event: {e}"), + )); + return (report, Some(observed), 0); + } + }; + report + .notes + .push(("wireguard".to_string(), format!("active_peers={peers}"))); + + if !observed.wireguard_interface_up { + report.errors.push(( + "wireguard".to_string(), + "WireGuard 接口未 up(host inspect 未观测到接口 / 网络不可用)".to_string(), + )); + } + + (report, Some(observed), peers) +} + +/// P2-5 Firewall 结构检查(nftables,Noop 双门禁): +/// `nft list table inet floatctf_awd` → table 存在 + base chain `awd_forward` + +/// `hook forward` 声明 + `observed_revision == desired_revision`。 +/// +/// Noop runtime(inspect 返回空观测 / table_exists=false)→ **必 fail** +/// (Noop 永远不允许 Verified,双门禁)。 +async fn check_firewall_structure( + firewall: &dyn FirewallRuntime, + desired_revision: u64, +) -> (CheckReport, Option) { + let mut report = CheckReport::default(); + + let observed = match firewall.inspect().await { + Ok(o) => o, + Err(e) => { + report + .errors + .push(("firewall".to_string(), format!("inspect failed: {e}"))); + return (report, None); + } + }; + + // Noop runtime 或从未 reconcile → table 不存在 → fail(双门禁)。 + if !observed.table_exists { + report.errors.push(( + "firewall".to_string(), + "table inet floatctf_awd 不存在(Noop runtime 或从未 reconcile)".to_string(), + )); + return (report, Some(observed)); + } + + // 结构字符串检查:数据面必须包含 FloatCTF 渲染的 base chain 与 forward hook。 + if !observed.raw_output.contains("chain awd_forward") { + report.errors.push(( + "firewall".to_string(), + "base chain 'awd_forward' 缺失".to_string(), + )); + } + if !observed.raw_output.contains("hook forward") { + report + .errors + .push(("firewall".to_string(), "forward hook 声明缺失".to_string())); + } + + // revision 比对(纯函数)。 + if !revision_matches(observed.observed_revision, desired_revision) { + report.errors.push(( + "firewall".to_string(), + format!( + "revision 不匹配: observed={:?} desired={desired_revision}", + observed.observed_revision + ), + )); + } + + (report, Some(observed)) +} + +/// P2-6 网络矩阵验证(本阶段:desired-state 结构验证)。 +/// +/// 真实包流 probe(计划 §5.10): +/// - 方案 A:precheck 时挂起玩家 WG peers(host 移除,DB 保持 Active)→ Hardening probe +/// → 临时 Attack matrix probe → 恢复 Hardening → 恢复 peers; +/// - 方案 B:专用 canary namespace/container(同构镜像),probe 全部在 canary 内完成。 +/// +/// 两方案均**不得**给真实玩家提前攻击窗口。本阶段实现为 `FirewallRuntime::verify` +/// (revision 全量匹配 + event chains 存在),真实包流 probe 留给 Phase 5 E2E。 +async fn check_network_matrix( + firewall: &dyn FirewallRuntime, + desired: &DesiredFirewallState, +) -> CheckReport { + let mut report = CheckReport::default(); + + match firewall.verify(desired).await { + Ok(v) if v.verified => { + report.notes.push(( + "network_matrix".to_string(), + format!("desired-state verify 通过(revision {})", desired.revision), + )); + } + Ok(v) => { + report.errors.push(( + "network_matrix".to_string(), + format!("verify 失败: {}", v.notes.join("; ")), + )); + } + Err(e) => { + report + .errors + .push(("network_matrix".to_string(), format!("verify error: {e}"))); + } + } + + report +} + +// ── 纯函数(可单测)── + +/// 判定:errors 为空 → Passed。纯函数。 +fn evaluate_precheck(errors: &[(String, String)]) -> bool { + errors.is_empty() +} + +/// revision 比对纯函数(P2-5):observed == desired 才一致;None 视为不匹配。 +fn revision_matches(observed: Option, expected: u64) -> bool { + observed == Some(expected) +} + +/// SSH 探测是否启用(P2-3):env 值为 `1`。纯函数。 +fn ssh_probe_enabled(env: Option<&str>) -> bool { + env == Some("1") +} + +/// CheckReport → jsonb(component / passed / errors / notes)。 +fn report_json(component: &str, report: &CheckReport) -> serde_json::Value { + serde_json::json!({ + "component": component, + "passed": report.errors.is_empty(), + "errors": report.errors.iter().map(|(k, v)| { + serde_json::json!({"component": k, "error": v}) + }).collect::>(), + "notes": report.notes.iter().map(|(k, v)| { + serde_json::json!({"component": k, "note": v}) + }).collect::>(), + }) +} + +/// P2-13 Host Firewall Environment 快照序列化(非密钥字段)。 +fn host_env_json(env: &HostFirewallEnvironment) -> serde_json::Value { + serde_json::json!({ + "nft_version": env.nft_version, + "kernel_version": env.kernel_version, + "docker_firewall_backend": env.docker_firewall_backend, + "firewalld_active": env.firewalld_active, + "iptables_frontend": env.iptables_frontend, + "notes": env.notes, + }) } /// Validate basic config: CIDR format, IPs are within ranges, interface name valid. @@ -314,3 +897,81 @@ fn compute_revision(event: &awd_events::Model) -> String { hasher.update(event.judgeserver_ip.as_bytes()); hex::encode(hasher.finalize()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::event::awd_team::infrastructure::firewall::NoopFirewallRuntime; + + #[test] + fn evaluate_precheck_passes_on_empty_errors() { + let empty: Vec<(String, String)> = Vec::new(); + assert!(evaluate_precheck(&empty)); + } + + #[test] + fn evaluate_precheck_fails_on_any_error() { + let errors = vec![("docker".to_string(), "list failed".to_string())]; + assert!(!evaluate_precheck(&errors)); + } + + #[test] + fn revision_matches_only_on_equal_revision() { + assert!(revision_matches(Some(7), 7)); + assert!(!revision_matches(None, 7)); + assert!(!revision_matches(Some(6), 7)); + assert!(!revision_matches(Some(8), 7)); + } + + #[test] + fn ssh_probe_only_when_env_is_one() { + assert!(ssh_probe_enabled(Some("1"))); + assert!(!ssh_probe_enabled(Some("0"))); + assert!(!ssh_probe_enabled(None)); + assert!(!ssh_probe_enabled(Some("yes"))); + } + + #[tokio::test] + async fn noop_firewall_structure_never_passes() { + // Noop 双门禁:inspect 返回空观测(table_exists=false)→ 必 fail。 + let fw = NoopFirewallRuntime; + let (report, observed) = check_firewall_structure(&fw, 0).await; + assert!(!report.errors.is_empty()); + assert!( + report.errors.iter().any(|(k, _)| k == "firewall"), + "errors: {:?}", + report.errors + ); + assert!(observed.as_ref().is_some_and(|o| !o.table_exists)); + } + + #[tokio::test] + async fn noop_network_matrix_never_verifies() { + // Noop verify 恒返回 verified=false → network_matrix 必 fail(双门禁第二重)。 + let fw = NoopFirewallRuntime; + let desired = DesiredFirewallState { + revision: 1, + events: Vec::new(), + }; + let report = check_network_matrix(&fw, &desired).await; + assert!(!report.errors.is_empty()); + assert!( + report.errors.iter().any(|(k, _)| k == "network_matrix"), + "errors: {:?}", + report.errors + ); + } + + #[test] + fn report_json_marks_failed_when_errors_present() { + let report = CheckReport { + errors: vec![("ssh".to_string(), "down".to_string())], + notes: vec![("ssh".to_string(), "probe skipped".to_string())], + }; + let json = report_json("containers", &report); + assert_eq!(json["component"], "containers"); + assert_eq!(json["passed"], false); + assert_eq!(json["errors"][0]["component"], "ssh"); + assert_eq!(json["notes"][0]["note"], "probe skipped"); + } +} diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index b237bbc3..92cc99ad 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -27,7 +27,9 @@ use uuid::Uuid; use crate::entity::{ awd_events, awd_judge_tasks, awd_rounds, scheduled_tasks, - sea_orm_active_enums::{AwdEventStatus, AwdPhase, JudgeTaskStatus, RoundStatus, ScoreEventType}, + sea_orm_active_enums::{ + AwdEventStatus, AwdPhase, JudgeTaskStatus, RoundStatus, ScoreEventType, + }, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, @@ -98,7 +100,10 @@ pub async fn start_round( event_id: Uuid, ) -> AwdResult { // ── 事务内:DB 状态变更 ── - let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; let awd_event = event_repo::find_by_event_id(&txn, event_id) .await @@ -110,7 +115,9 @@ pub async fn start_round( "[Round] Event {} is not Running ({:?}) — skip round start", event_id, awd_event.status ); - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Err(AwdError::InvalidState(format!( "Cannot start round in {:?} status", awd_event.status @@ -122,7 +129,10 @@ pub async fn start_round( .await .map_err(|e| AwdError::Database(e.to_string()))? { - info!("[Round] Completing leftover active round {} for event {}", prev.round_number, event_id); + info!( + "[Round] Completing leftover active round {} for event {}", + prev.round_number, event_id + ); let timed_out = judge_repo::timeout_pending_tasks(&txn, prev.id) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -150,7 +160,9 @@ pub async fn start_round( .await .map_err(|e| AwdError::Database(e.to_string()))?; if let Some(round) = existing { - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Ok(RoundStarted { round_id: round.id, round_number: round.round_number, @@ -188,7 +200,9 @@ pub async fn start_round( .await .map_err(|e| AwdError::Database(e.to_string()))?; - txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; info!( "[Round] Round {} (phase {:?}) started for event {}", @@ -224,7 +238,10 @@ pub async fn end_round( event_id: Uuid, round_id: Uuid, ) -> AwdResult<()> { - let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; let round = awd_rounds::Entity::find_by_id(round_id) .one(&txn) @@ -232,13 +249,20 @@ pub async fn end_round( .map_err(|e| AwdError::Database(e.to_string()))? .ok_or_else(|| AwdError::NotFound("Round not found".into()))?; if round.event_id != event_id { - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Err(AwdError::Forbidden("Round belongs to another event".into())); } if round.status != RoundStatus::Active { - warn!("[Round] Round {} already {:?} — skip grace", round_id, round.status); - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + warn!( + "[Round] Round {} already {:?} — skip grace", + round_id, round.status + ); + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Ok(()); } @@ -264,7 +288,10 @@ pub async fn end_round( grace_ends_at: Set(Some(grace_ends_at.into())), ..Default::default() }; - active.update(&txn).await.map_err(|e| AwdError::Database(e.to_string()))?; + active + .update(&txn) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; // 插入 GraceEnd 任务 schedule_round_task( @@ -277,7 +304,9 @@ pub async fn end_round( .await .map_err(|e| AwdError::Database(e.to_string()))?; - txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; info!("[Round] Round {round_id} entered grace until {grace_ends_at}"); Ok(()) @@ -291,7 +320,10 @@ pub async fn grace_end_round( event_id: Uuid, round_id: Uuid, ) -> AwdResult<()> { - let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; let round = awd_rounds::Entity::find_by_id(round_id) .one(&txn) @@ -299,13 +331,20 @@ pub async fn grace_end_round( .map_err(|e| AwdError::Database(e.to_string()))? .ok_or_else(|| AwdError::NotFound("Round not found".into()))?; if round.event_id != event_id { - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Err(AwdError::Forbidden("Round belongs to another event".into())); } if round.status != RoundStatus::Grace { - warn!("[Round] Round {round_id} not in Grace ({:?}) — skip", round.status); - txn.rollback().await.map_err(|e| AwdError::Database(e.to_string()))?; + warn!( + "[Round] Round {round_id} not in Grace ({:?}) — skip", + round.status + ); + txn.rollback() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; return Ok(()); } @@ -330,7 +369,9 @@ pub async fn grace_end_round( .map_err(|e| AwdError::Database(e.to_string()))?; } - txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; info!("[Round] Round {round_id} completed for event {event_id}"); Ok(()) @@ -394,7 +435,8 @@ pub async fn score_judge_timeouts( // 收集涉及到的 template 分值 use crate::entity::awd_gamebox_templates; - let mut template_points: std::collections::HashMap = std::collections::HashMap::new(); + let mut template_points: std::collections::HashMap = + std::collections::HashMap::new(); for t in &timed_out { if !template_points.contains_key(&t.template_id) { if let Some(tpl) = awd_gamebox_templates::Entity::find_by_id(t.template_id) diff --git a/apps/api/tests/awd_transition_guard.rs b/apps/api/tests/awd_transition_guard.rs index bb11db30..42ee5ae7 100644 --- a/apps/api/tests/awd_transition_guard.rs +++ b/apps/api/tests/awd_transition_guard.rs @@ -293,19 +293,16 @@ async fn legal_transitions_smoke_across_table() { #[tokio::test] async fn configuration_generation_gates_start() { use floatctf::modules::event::awd_team::{ - infrastructure::{ - firewall::NoopFirewallRuntime, network::NoopNetworkRuntime, - }, + infrastructure::{firewall::NoopFirewallRuntime, network::NoopNetworkRuntime}, service::event_service, }; - let Some(db) = connect_or_skip().await else { return }; + let Some(db) = connect_or_skip().await else { + return; + }; // 推进到 Verified 的辅助:直接在主连接上操作(start_event 需要 &DatabaseConnection) - async fn to_verified( - db: &sea_orm::DatabaseConnection, - awd_id: Uuid, - ) { + async fn to_verified(db: &sea_orm::DatabaseConnection, awd_id: Uuid) { for (from, to) in [ (AwdEventStatus::Draft, AwdEventStatus::Configuring), (AwdEventStatus::Configuring, AwdEventStatus::Prechecking), @@ -347,12 +344,16 @@ async fn configuration_generation_gates_start() { err.to_string().contains("AWD_CONFIG_CHANGED"), "expected AWD_CONFIG_CHANGED, got {err}" ); - let row = awd_events::Entity::find_by_id(awd2.id).one(&db).await.unwrap().unwrap(); + let row = awd_events::Entity::find_by_id(awd2.id) + .one(&db) + .await + .unwrap() + .unwrap(); assert_eq!(row.status, AwdEventStatus::StartBlocked); // 清理:删除轮次/事件/父行(settings revision 全局保留) - use sea_orm::QueryFilter; use floatctf::entity::awd_rounds; + use sea_orm::QueryFilter; let _ = awd_rounds::Entity::delete_many() .filter(awd_rounds::Column::EventId.is_in([_event_id, _eid2])) .exec(&db) From e48255ab0d642b3a87e3caa954081ad0aac7919b Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:08:24 +0800 Subject: [PATCH 009/486] =?UTF-8?q?feat(awd):=20round=20handlers=20?= =?UTF-8?q?=E7=98=A6=E8=BA=AB=E4=B8=BA=20round=5Fservice=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=EF=BC=88P3-2=EF=BC=89+=20start=5Fevent=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20start=5Fround=EF=BC=88P3-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AwdRoundStart/End/GraceEnd handler 全部委托 round_service(事务边界/幂等/service 化) - start_event 创建第一轮改走 start_round(find-or-create + RoundEnd(1) 任务 + reconcile) --- apps/api/src/bootstrap/scheduler.rs | 1 - .../modules/event/awd_team/scheduler/mod.rs | 195 +++++------------- .../event/awd_team/service/event_service.rs | 27 +-- 3 files changed, 53 insertions(+), 170 deletions(-) diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index 4918a44d..6bdfc073 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -61,7 +61,6 @@ pub async fn build_task_scheduler( }), Arc::new(AwdRoundStartHandler { db: db.clone(), - docker: docker.clone(), network: network.clone(), firewall: firewall.clone(), }), diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index 085fe2ba..8ee312c2 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -25,8 +25,8 @@ use crate::modules::event::awd_team::{ crypto::{AwdCrypto, EncryptedBlob}, domain::AwdEventStatusExt, infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, - repo::{event_repo, judge_repo, round_repo}, - service::{event_service, firewall_service, judge_service}, + repo::{event_repo, round_repo}, + service::{event_service, firewall_service, round_service}, }; use fcmc::AwdContainerRuntime; use std::sync::Arc; @@ -217,10 +217,9 @@ impl TaskHandler for AwdEventStartHandler { } } -/// Handler: Start a new AWD round. +/// Handler: Start a new AWD round(thin 代理 → round_service::start_round,P3-2)。 pub struct AwdRoundStartHandler { pub db: WebDb, - pub docker: WebDocker, pub network: Arc, pub firewall: Arc, } @@ -236,123 +235,36 @@ impl TaskHandler for AwdRoundStartHandler { } async fn run(&self, task: scheduled_tasks::Model) -> anyhow::Result<()> { - let event_id = event_id_from_task(&task)?; + let payload: round_service::RoundTaskPayload = + serde_json::from_value(task.payload.clone().unwrap_or_default())?; + let event_id = payload.event_id; info!("[AWD] Starting new round for event {}", event_id); - // Verify event is running - let awd_event = event_repo::find_by_event_id(self.db.get_ref(), event_id) - .await? - .ok_or_else(|| anyhow::anyhow!("AWD event not found"))?; - - if !awd_event.status.is_active() { - warn!( - "[AWD] Event {} is not active, skipping round start", - event_id - ); - return Ok(()); - } - - // Complete the previous round if any - if let Some(prev_round) = round_repo::find_active_round(self.db.get_ref(), event_id).await? - { - // Timeout pending judge tasks from previous round - let timed_out = - judge_repo::timeout_pending_tasks(self.db.get_ref(), prev_round.id).await?; - info!( - "[AWD] Round {} ended. {} tasks timed out.", - prev_round.round_number, timed_out - ); - - round_repo::update_round_status( - self.db.get_ref(), - prev_round.id, - RoundStatus::Completed, - ) - .await?; + // 非 Running 赛事:跳过(scheduler 任务不失败) + if let Some(ev) = event_repo::find_by_event_id(self.db.get_ref(), event_id).await? { + if !ev.status.is_active() { + warn!( + "[AWD] Event {} is not active ({:?}), skipping round start", + event_id, ev.status + ); + return Ok(()); + } } - // Get the latest round number - let latest = round_repo::find_latest_round(self.db.get_ref(), event_id).await?; - let next_round_number = latest.map(|r| r.round_number + 1).unwrap_or(1); - - // Determine phase (round 1 starts with hardening, subsequent rounds are attack) - let phase = if next_round_number == 1 { - AwdPhase::Hardening - } else { - AwdPhase::Attack - }; - let phase_debug = format!("{:?}", phase); - - // Update event phase - crate::modules::event::awd_team::repo::event_repo::update_phase( - self.db.get_ref(), - awd_event.id, - phase.clone(), - ) - .await?; - - let now = chrono::Utc::now(); - let round_end = now + chrono::Duration::seconds(awd_event.round_duration_secs as i64); - - let new_round = round_repo::create_round( - self.db.get_ref(), - event_id, - next_round_number, - phase.clone(), - round_end, - ) - .await?; - - info!( - "[AWD] Round {} started for event {} (phase: {})", - new_round.round_number, event_id, phase_debug - ); - - // phase 切换 = DB desired phase → 全局 DesiredFirewallState → nftables reconcile(Phase 1 P1-10) - firewall_service::reconcile_global( + round_service::start_round( self.db.get_ref(), - self.firewall.as_ref(), - firewall_service::next_network_revision(self.db.get_ref()).await?, - ) - .await?; - firewall_service::flush_event_connections( self.network.as_ref(), + self.firewall.as_ref(), event_id, - &awd_event.gamebox_cidr, ) - .await; - - let token_ciphertext = awd_event - .judgeserver_token_ciphertext - .clone() - .ok_or_else(|| anyhow::anyhow!("JudgeServer token is not configured"))?; - let token_nonce = awd_event - .judgeserver_token_nonce - .clone() - .ok_or_else(|| anyhow::anyhow!("JudgeServer token nonce is not configured"))?; - let crypto = AwdCrypto::from_config_secret()?; - let token = crypto.decrypt( - &EncryptedBlob { - ciphertext: token_ciphertext, - nonce: token_nonce, - key_version: awd_event.key_version, - }, - &AwdCrypto::build_aad(event_id, "internal_token"), - )?; - let token = String::from_utf8(token) - .map_err(|_| anyhow::anyhow!("JudgeServer token is not valid UTF-8"))?; - let batch_id = - judge_service::create_batch(self.db.get_ref(), event_id, new_round.id).await?; - let judgeserver_url = format!("http://{}:8082", awd_event.judgeserver_ip); - judge_service::dispatch_batch(self.db.get_ref(), batch_id, &judgeserver_url, &token) - .await?; + .await?; Ok(()) } } -/// Handler: End the current AWD round (grace period). +/// Handler: End the current AWD round (grace period)——thin 代理 → round_service::end_round。 pub struct AwdRoundEndHandler { pub db: WebDb, } @@ -368,32 +280,28 @@ impl TaskHandler for AwdRoundEndHandler { } async fn run(&self, task: scheduled_tasks::Model) -> anyhow::Result<()> { - let event_id = event_id_from_task(&task)?; - - info!("[AWD] Ending round for event {}", event_id); - - let active_round = match round_repo::find_active_round(self.db.get_ref(), event_id).await? { - Some(r) => r, - None => { - warn!("[AWD] No active round for event {}", event_id); - return Ok(()); - } + let payload: round_service::RoundTaskPayload = + serde_json::from_value(task.payload.clone().unwrap_or_default())?; + let event_id = payload.event_id; + // end 任务携带 round_id;缺失时按 active round 兜底 + let round_id = match payload.round_id { + Some(id) => id, + None => match round_repo::find_active_round(self.db.get_ref(), event_id).await? { + Some(r) => r.id, + None => { + warn!("[AWD] No active round for event {}", event_id); + return Ok(()); + } + }, }; - // Start grace period - round_repo::update_round_status(self.db.get_ref(), active_round.id, RoundStatus::Grace) - .await?; - - info!( - "[AWD] Round {} in grace period for event {}", - active_round.round_number, event_id - ); - + info!("[AWD] Ending round {round_id} for event {event_id}"); + round_service::end_round(self.db.get_ref(), event_id, round_id).await?; Ok(()) } } -/// Handler: Grace period ends — complete the round. +/// Handler: Grace period ends — complete the round(thin 代理 → round_service::grace_end_round)。 pub struct AwdRoundGraceEndHandler { pub db: WebDb, } @@ -409,27 +317,22 @@ impl TaskHandler for AwdRoundGraceEndHandler { } async fn run(&self, task: scheduled_tasks::Model) -> anyhow::Result<()> { - let event_id = event_id_from_task(&task)?; - - info!("[AWD] Grace period ending for event {}", event_id); - - // Timeout any remaining pending tasks - let active_round = match round_repo::find_active_round(self.db.get_ref(), event_id).await? { - Some(r) => r, - None => return Ok(()), + let payload: round_service::RoundTaskPayload = + serde_json::from_value(task.payload.clone().unwrap_or_default())?; + let event_id = payload.event_id; + let round_id = match payload.round_id { + Some(id) => id, + None => match round_repo::find_active_round(self.db.get_ref(), event_id).await? { + Some(r) => r.id, + None => { + warn!("[AWD] No active round for event {}", event_id); + return Ok(()); + } + }, }; - let timed_out = - judge_repo::timeout_pending_tasks(self.db.get_ref(), active_round.id).await?; - info!( - "[AWD] Grace ended. {} tasks timed out for round {}.", - timed_out, active_round.round_number - ); - - // Complete the round - round_repo::update_round_status(self.db.get_ref(), active_round.id, RoundStatus::Completed) - .await?; - + info!("[AWD] Grace period ending for round {round_id} event {event_id}"); + round_service::grace_end_round(self.db.get_ref(), event_id, round_id).await?; Ok(()) } } diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 58202cdd..dacfa6b1 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -12,7 +12,7 @@ use crate::modules::event::awd_team::{ network::{AwdNetworkRuntime, EventNetworkIdentity}, }, repo::{event_repo, round_repo}, - service::firewall_service, + service::{firewall_service, round_service}, }; /// Start an AWD event: validate status, create first round, set hardening phase + policy. @@ -77,28 +77,9 @@ pub async fn start_event( ) .await?; - // Create the first round - let now = chrono::Utc::now(); - let round_end = now + chrono::Duration::seconds(awd_event.round_duration_secs as i64); - - round_repo::create_round( - db, - event_id, - 1, // round 1 - AwdPhase::Hardening, - round_end, - ) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - - // 全局 desired-state reconcile(nftables)+ conntrack 清理(Phase 1 P1-10) - firewall_service::reconcile_global( - db, - firewall, - firewall_service::next_network_revision(db).await?, - ) - .await?; - firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; + // P3-1:第一轮经 round_service 创建(幂等 find-or-create + 插入 RoundEnd(1) 任务 + // + COMMIT 后 reconcile + conntrack + judge dispatch)。 + round_service::start_round(db, network, firewall, event_id).await?; Ok(()) } From 6d493772d898602849bcfa22ebe36312329f95ab Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:09:32 +0800 Subject: [PATCH 010/486] =?UTF-8?q?fix(awd):=20judge=20dispatch=20?= =?UTF-8?q?=E9=9D=9E=E8=87=B4=E5=91=BD=20+=20=E6=B5=8B=E8=AF=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=88P3-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start_round 中 judge dispatch 失败只记录不回滚(可重试,round 已提交) - awd_transition_guard 测试:唯一 interface 名 + 清理残留 --- .../src/modules/event/awd_team/service/round_service.rs | 7 +++++-- apps/api/tests/awd_transition_guard.rs | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 92cc99ad..61deafed 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -219,8 +219,11 @@ pub async fn start_round( .await?; firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; - // judge batch 分发 - dispatch_judge_for_round(db, &awd_event, round.id, event_id).await?; + // judge batch 分发(P3-2:非致命——round 已提交,judge 分发可重试, + // 失败只记录,不让轮次启动因网络抖动回滚) + if let Err(e) = dispatch_judge_for_round(db, &awd_event, round.id, event_id).await { + warn!("[Round] judge dispatch failed for round {} event {}: {}", round.id, event_id, e); + } Ok(RoundStarted { round_id: round.id, diff --git a/apps/api/tests/awd_transition_guard.rs b/apps/api/tests/awd_transition_guard.rs index 42ee5ae7..7979754d 100644 --- a/apps/api/tests/awd_transition_guard.rs +++ b/apps/api/tests/awd_transition_guard.rs @@ -37,7 +37,11 @@ fn base_awd_event(event_id: Uuid, tag: &str) -> awd_events::ActiveModel { event_id: Set(event_id), gamebox_cidr: Set("10.42.0.0/16".into()), wireguard_cidr: Set("172.31.0.0/16".into()), - wireguard_interface_name: Set(format!("wg-test-{}", tag)), + // 随机后缀保证唯一(历史失败残留行会撞 unique constraint);≤15 字符 + wireguard_interface_name: Set(format!( + "wg-{}", + &Uuid::new_v4().to_string().replace('-', "")[..8] + )), wireguard_listen_port: Set(5_0000 + Uuid::new_v4().as_u128() as i32 % 1000), flagserver_ip: Set("10.42.0.10".into()), judgeserver_ip: Set("10.42.0.11".into()), From 351c94381a8cbc76267719ba130b9aedba4ce835 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:14:24 +0800 Subject: [PATCH 011/486] =?UTF-8?q?feat(awd):=20realtime=20=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=EF=BC=88P3-7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - round.started / round.completed / network.policy.applied / network.policy.failed 事件 - round_service:DB commit 后发布;reconcile 失败发布 failed 并 Fail Closed - submission_service:score.changed 在事务提交后发布(best-effort) - EventPublisher 线程化:event_service/scheduler handlers/bootstrap/admin/player --- apps/api/src/bootstrap/mod.rs | 1 + apps/api/src/bootstrap/scheduler.rs | 9 +- .../src/modules/event/awd_team/api/admin.rs | 1 + .../src/modules/event/awd_team/api/player.rs | 2 + .../modules/event/awd_team/scheduler/mod.rs | 13 +- .../event/awd_team/service/event_service.rs | 3 +- .../event/awd_team/service/round_service.rs | 53 +++- .../awd_team/service/submission_service.rs | 268 ++++++++++-------- .../src/modules/event/awd_team/websocket.rs | 47 +++ apps/api/tests/awd_transition_guard.rs | 5 +- 10 files changed, 263 insertions(+), 139 deletions(-) diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 5aaaeb8f..43c4f3a8 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -185,6 +185,7 @@ pub async fn run() -> Result<(), BootstrapError> { awd_network.clone(), awd_firewall.clone(), awd_crypto.clone(), + publisher.clone(), ) .await .expect("init startup handlers failed!"); diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index 6bdfc073..3459a923 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -6,6 +6,7 @@ use tracing::info; use crate::{ infrastructure::LogService, + infrastructure::realtime::EventPublisher, infrastructure::{WebDb, WebDocker, WebRustfs}, modules::event::awd_team::{ crypto::AwdCrypto, @@ -30,6 +31,7 @@ pub async fn build_task_scheduler( network: Arc, firewall: Arc, crypto: Arc, + publisher: Arc, ) -> Result { let mut scheduler = TaskScheduler::new(db.clone(), docker.clone(), rustfs.clone(), logger); @@ -58,14 +60,19 @@ pub async fn build_task_scheduler( db: db.clone(), network: network.clone(), firewall: firewall.clone(), + publisher: publisher.clone(), }), Arc::new(AwdRoundStartHandler { db: db.clone(), network: network.clone(), firewall: firewall.clone(), + publisher: publisher.clone(), }), Arc::new(AwdRoundEndHandler { db: db.clone() }), - Arc::new(AwdRoundGraceEndHandler { db: db.clone() }), + Arc::new(AwdRoundGraceEndHandler { + db: db.clone(), + publisher: publisher.clone(), + }), Arc::new(AwdArchiveCleanupHandler { db, docker: docker.clone(), diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 72babd2e..9c1b9005 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -137,6 +137,7 @@ pub async fn start_awd_event( ctx.db.get_ref(), awd.network.as_ref(), awd.firewall.as_ref(), + awd.publisher.as_ref(), event_id, ) .await diff --git a/apps/api/src/modules/event/awd_team/api/player.rs b/apps/api/src/modules/event/awd_team/api/player.rs index da83ab3f..67f29560 100644 --- a/apps/api/src/modules/event/awd_team/api/player.rs +++ b/apps/api/src/modules/event/awd_team/api/player.rs @@ -89,6 +89,7 @@ pub async fn reset_my_gamebox( pub async fn submit_flag( user: UserJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path, body: web::Json, ) -> UniResult { @@ -148,6 +149,7 @@ pub async fn submit_flag( template.loss_points, template.first_bonus, template.id, + awd.publisher.as_ref(), ) .await .map_err(AppError::from)?; diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index 8ee312c2..8f636a67 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -191,6 +191,7 @@ pub struct AwdEventStartHandler { dyn crate::modules::event::awd_team::infrastructure::network::AwdNetworkRuntime, >, pub firewall: Arc, + pub publisher: Arc, } #[async_trait] @@ -210,6 +211,7 @@ impl TaskHandler for AwdEventStartHandler { self.db.get_ref(), self.network.as_ref(), self.firewall.as_ref(), + self.publisher.as_ref(), event_id, ) .await?; @@ -222,6 +224,7 @@ pub struct AwdRoundStartHandler { pub db: WebDb, pub network: Arc, pub firewall: Arc, + pub publisher: Arc, } #[async_trait] @@ -256,6 +259,7 @@ impl TaskHandler for AwdRoundStartHandler { self.db.get_ref(), self.network.as_ref(), self.firewall.as_ref(), + self.publisher.as_ref(), event_id, ) .await?; @@ -304,6 +308,7 @@ impl TaskHandler for AwdRoundEndHandler { /// Handler: Grace period ends — complete the round(thin 代理 → round_service::grace_end_round)。 pub struct AwdRoundGraceEndHandler { pub db: WebDb, + pub publisher: Arc, } #[async_trait] @@ -332,7 +337,13 @@ impl TaskHandler for AwdRoundGraceEndHandler { }; info!("[AWD] Grace period ending for round {round_id} event {event_id}"); - round_service::grace_end_round(self.db.get_ref(), event_id, round_id).await?; + round_service::grace_end_round( + self.db.get_ref(), + event_id, + round_id, + self.publisher.as_ref(), + ) + .await?; Ok(()) } } diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index dacfa6b1..b7d6da75 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -20,6 +20,7 @@ pub async fn start_event( db: &DatabaseConnection, network: &dyn AwdNetworkRuntime, firewall: &dyn FirewallRuntime, + publisher: &dyn crate::infrastructure::realtime::EventPublisher, event_id: Uuid, ) -> AwdResult<()> { let awd_event = event_repo::find_by_event_id(db, event_id) @@ -79,7 +80,7 @@ pub async fn start_event( // P3-1:第一轮经 round_service 创建(幂等 find-or-create + 插入 RoundEnd(1) 任务 // + COMMIT 后 reconcile + conntrack + judge dispatch)。 - round_service::start_round(db, network, firewall, event_id).await?; + round_service::start_round(db, network, firewall, publisher, event_id).await?; Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 61deafed..1c45d973 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -31,12 +31,14 @@ use crate::entity::{ AwdEventStatus, AwdPhase, JudgeTaskStatus, RoundStatus, ScoreEventType, }, }; +use crate::infrastructure::realtime::EventPublisher; use crate::modules::event::awd_team::{ AwdError, AwdResult, domain::AwdEventStatusExt, infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, repo::{event_repo, judge_repo, round_repo, score_repo}, service::{firewall_service, judge_service}, + websocket, }; use crate::scheduler::TaskKey; @@ -97,6 +99,7 @@ pub async fn start_round( db: &sea_orm::DatabaseConnection, network: &dyn AwdNetworkRuntime, firewall: &dyn FirewallRuntime, + publisher: &dyn EventPublisher, event_id: Uuid, ) -> AwdResult { // ── 事务内:DB 状态变更 ── @@ -211,18 +214,46 @@ pub async fn start_round( // ── COMMIT 后:网络副作用(横切规则 3)── // phase 切换 = DB desired → 全局 DesiredFirewallState → nftables reconcile(P3-6) - firewall_service::reconcile_global( - db, - firewall, - firewall_service::next_network_revision(db).await?, - ) - .await?; - firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; + let revision = firewall_service::next_network_revision(db).await?; + match firewall_service::reconcile_global(db, firewall, revision).await { + Ok(_) => { + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr) + .await; + // P3-7:DB commit 后发布;publish 失败不回滚业务(best-effort) + let phase_str = format!("{:?}", phase).to_lowercase(); + let _ = publisher + .publish( + websocket::network_policy_applied(event_id, revision, revision, &phase_str) + .into_realtime(), + ) + .await; + let _ = publisher + .publish( + websocket::round_started(event_id, round.round_number, &phase_str) + .into_realtime(), + ) + .await; + } + Err(e) => { + // P3-6 Fail Closed:reconcile 失败 → network.policy.failed + 返回错误(调用方置 NetworkError) + let phase_str = format!("{:?}", phase).to_lowercase(); + let _ = publisher + .publish( + websocket::network_policy_failed(event_id, revision, None, &phase_str) + .into_realtime(), + ) + .await; + return Err(e); + } + } // judge batch 分发(P3-2:非致命——round 已提交,judge 分发可重试, // 失败只记录,不让轮次启动因网络抖动回滚) if let Err(e) = dispatch_judge_for_round(db, &awd_event, round.id, event_id).await { - warn!("[Round] judge dispatch failed for round {} event {}: {}", round.id, event_id, e); + warn!( + "[Round] judge dispatch failed for round {} event {}: {}", + round.id, event_id, e + ); } Ok(RoundStarted { @@ -322,6 +353,7 @@ pub async fn grace_end_round( db: &sea_orm::DatabaseConnection, event_id: Uuid, round_id: Uuid, + publisher: &dyn EventPublisher, ) -> AwdResult<()> { let txn = db .begin() @@ -376,7 +408,12 @@ pub async fn grace_end_round( .await .map_err(|e| AwdError::Database(e.to_string()))?; + let completed_round_number = round.round_number; info!("[Round] Round {round_id} completed for event {event_id}"); + // P3-7:DB commit 后发布(best-effort) + let _ = publisher + .publish(websocket::round_completed(event_id, completed_round_number).into_realtime()) + .await; Ok(()) } diff --git a/apps/api/src/modules/event/awd_team/service/submission_service.rs b/apps/api/src/modules/event/awd_team/service/submission_service.rs index e3f140af..8caaa810 100644 --- a/apps/api/src/modules/event/awd_team/service/submission_service.rs +++ b/apps/api/src/modules/event/awd_team/service/submission_service.rs @@ -43,142 +43,158 @@ pub async fn process_submission( loss_points: i64, first_bonus: i64, template_id: Uuid, + publisher: &dyn crate::infrastructure::realtime::EventPublisher, ) -> AwdResult { - db.transaction(|tx| { - Box::pin(async move { - // 1. Check if this team already submitted for this instance this round - let already_submitted = flag_repo::has_submission( - tx, - event_id, - round_id, - attacker_team_id, - gamebox_instance_id, - ) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - - if already_submitted { - return Err(AwdError::Conflict( - "Already submitted this flag for this target this round".into(), - )); - } - - // 2. Insert submission (unique constraint protects against races) - flag_repo::create_submission( - tx, - event_id, - round_id, - flag_issue_id, - attacker_team_id, - victim_team_id, - gamebox_instance_id, - submitted_by_user_id, - ) - .await - .map_err(|e| { - let msg = e.to_string(); - if msg.contains("duplicate") || msg.contains("unique") { - AwdError::Conflict("Already submitted (concurrent request)".into()) - } else { - AwdError::Database(msg) - } - })?; - - // 3. Insert attack score event - let attack_key = IdempotencyKey::attack( - &event_id.to_string(), - &round_id.to_string(), - &attacker_team_id.to_string(), - &gamebox_instance_id.to_string(), - ); - - score_repo::create_score_event( - tx, - event_id, - Some(round_id), - attacker_team_id, - ScoreEventType::Attack, - break_points, - &attack_key, - Some(victim_team_id), - Some(gamebox_instance_id), - None, - Some("flag capture"), - ) - .await - .map_err(|e| { - let msg = e.to_string(); - if msg.contains("duplicate") || msg.contains("unique") { - AwdError::Conflict("Attack already scored".into()) - } else { - AwdError::Database(msg) + let result = db + .transaction(|tx| { + Box::pin(async move { + // 1. Check if this team already submitted for this instance this round + let already_submitted = flag_repo::has_submission( + tx, + event_id, + round_id, + attacker_team_id, + gamebox_instance_id, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + if already_submitted { + return Err(AwdError::Conflict( + "Already submitted this flag for this target this round".into(), + )); } - })?; - - // 4. Insert victim loss score event - let loss_key = IdempotencyKey::victim_loss( - &event_id.to_string(), - &round_id.to_string(), - &attacker_team_id.to_string(), - &gamebox_instance_id.to_string(), - ); - - let _loss_event = score_repo::create_score_event( - tx, - event_id, - Some(round_id), - victim_team_id, - ScoreEventType::VictimLoss, - -loss_points, - &loss_key, - Some(attacker_team_id), - Some(gamebox_instance_id), - None, - Some("flag stolen"), - ) - .await - .map_err(|e| AwdError::Database(format!("Victim loss scoring failed: {}", e)))?; - // 5. Try first-blood bonus (once per template per event) - let bonus_key = - IdempotencyKey::first_bonus(&event_id.to_string(), &template_id.to_string()); + // 2. Insert submission (unique constraint protects against races) + flag_repo::create_submission( + tx, + event_id, + round_id, + flag_issue_id, + attacker_team_id, + victim_team_id, + gamebox_instance_id, + submitted_by_user_id, + ) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("duplicate") || msg.contains("unique") { + AwdError::Conflict("Already submitted (concurrent request)".into()) + } else { + AwdError::Database(msg) + } + })?; - let bonus_result = score_repo::create_score_event( - tx, - event_id, - Some(round_id), - attacker_team_id, - ScoreEventType::FirstBonus, - first_bonus, - &bonus_key, - None, - None, - Some(template_id), - Some("first blood"), - ) - .await; + // 3. Insert attack score event + let attack_key = IdempotencyKey::attack( + &event_id.to_string(), + &round_id.to_string(), + &attacker_team_id.to_string(), + &gamebox_instance_id.to_string(), + ); - let was_first_blood = match &bonus_result { - Ok(_) => true, - Err(e) => { + score_repo::create_score_event( + tx, + event_id, + Some(round_id), + attacker_team_id, + ScoreEventType::Attack, + break_points, + &attack_key, + Some(victim_team_id), + Some(gamebox_instance_id), + None, + Some("flag capture"), + ) + .await + .map_err(|e| { let msg = e.to_string(); if msg.contains("duplicate") || msg.contains("unique") { - false // already claimed by another team + AwdError::Conflict("Attack already scored".into()) } else { - return Err(AwdError::Database(msg)); + AwdError::Database(msg) } - } - }; + })?; + + // 4. Insert victim loss score event + let loss_key = IdempotencyKey::victim_loss( + &event_id.to_string(), + &round_id.to_string(), + &attacker_team_id.to_string(), + &gamebox_instance_id.to_string(), + ); - Ok(SubmissionResult { - attack_score_delta: break_points, - victim_loss_delta: loss_points, - first_bonus_delta: if was_first_blood { first_bonus } else { 0 }, - was_first_blood, + let _loss_event = score_repo::create_score_event( + tx, + event_id, + Some(round_id), + victim_team_id, + ScoreEventType::VictimLoss, + -loss_points, + &loss_key, + Some(attacker_team_id), + Some(gamebox_instance_id), + None, + Some("flag stolen"), + ) + .await + .map_err(|e| AwdError::Database(format!("Victim loss scoring failed: {}", e)))?; + + // 5. Try first-blood bonus (once per template per event) + let bonus_key = + IdempotencyKey::first_bonus(&event_id.to_string(), &template_id.to_string()); + + let bonus_result = score_repo::create_score_event( + tx, + event_id, + Some(round_id), + attacker_team_id, + ScoreEventType::FirstBonus, + first_bonus, + &bonus_key, + None, + None, + Some(template_id), + Some("first blood"), + ) + .await; + + let was_first_blood = match &bonus_result { + Ok(_) => true, + Err(e) => { + let msg = e.to_string(); + if msg.contains("duplicate") || msg.contains("unique") { + false // already claimed by another team + } else { + return Err(AwdError::Database(msg)); + } + } + }; + + Ok(SubmissionResult { + attack_score_delta: break_points, + victim_loss_delta: loss_points, + first_bonus_delta: if was_first_blood { first_bonus } else { 0 }, + was_first_blood, + }) }) }) - }) - .await - .map_err(|e| AwdError::Database(format!("Transaction failed: {}", e))) - .and_then(|r| Ok(r)) + .await + .map_err(|e| AwdError::Database(format!("Transaction failed: {}", e)))?; + + // P3-7:DB commit 后发布 score.changed(best-effort,不回滚业务) + let _ = publisher + .publish( + crate::modules::event::awd_team::websocket::score_changed( + event_id, + attacker_team_id, + break_points, + break_points, + ) + .into_realtime(), + ) + .await; + + Ok(result) } diff --git a/apps/api/src/modules/event/awd_team/websocket.rs b/apps/api/src/modules/event/awd_team/websocket.rs index 36d26924..cbeb6568 100644 --- a/apps/api/src/modules/event/awd_team/websocket.rs +++ b/apps/api/src/modules/event/awd_team/websocket.rs @@ -136,6 +136,53 @@ pub fn round_ended(event_id: Uuid, round_number: i32) -> AwdEvent { ) } +/// Publish a network policy applied event(P3-7:payload 只含 revision/phase,不含完整 ruleset)。 +pub fn network_policy_applied( + event_id: Uuid, + desired_revision: u64, + observed_revision: u64, + phase: &str, +) -> AwdEvent { + AwdEvent::new( + "network.policy.applied", + event_id, + serde_json::json!({ + "desired_revision": desired_revision, + "observed_revision": observed_revision, + "phase": phase, + }), + ) +} + +/// Publish a network policy failure event(P3-7)。 +pub fn network_policy_failed( + event_id: Uuid, + desired_revision: u64, + observed_revision: Option, + phase: &str, +) -> AwdEvent { + AwdEvent::new( + "network.policy.failed", + event_id, + serde_json::json!({ + "desired_revision": desired_revision, + "observed_revision": observed_revision, + "phase": phase, + }), + ) +} + +/// Publish a round completed event(P3-7)。 +pub fn round_completed(event_id: Uuid, round_number: i32) -> AwdEvent { + AwdEvent::new( + "round.completed", + event_id, + serde_json::json!({ + "round_number": round_number, + }), + ) +} + /// Publish a GameBox reset event. pub fn gamebox_reset(event_id: Uuid, instance_id: Uuid, team_id: Uuid, status: &str) -> AwdEvent { AwdEvent::new( diff --git a/apps/api/tests/awd_transition_guard.rs b/apps/api/tests/awd_transition_guard.rs index 7979754d..5e5ab7cc 100644 --- a/apps/api/tests/awd_transition_guard.rs +++ b/apps/api/tests/awd_transition_guard.rs @@ -331,7 +331,8 @@ async fn configuration_generation_gates_start() { to_verified(&db, awd.id).await; let network = NoopNetworkRuntime; let firewall = NoopFirewallRuntime; - event_service::start_event(&db, &network, &firewall, _event_id) + let publisher = floatctf::infrastructure::realtime::NoopEventPublisher; + event_service::start_event(&db, &network, &firewall, &publisher, _event_id) .await .expect("start must pass when verified_generation == configuration_generation"); @@ -341,7 +342,7 @@ async fn configuration_generation_gates_start() { event_repo::touch_configuration(&db, awd2.id) .await .expect("touch_configuration"); - let err = event_service::start_event(&db, &network, &firewall, _eid2) + let err = event_service::start_event(&db, &network, &firewall, &publisher, _eid2) .await .expect_err("start must be blocked after config change"); assert!( From d63d5d676e7a4477c0a8304e66075b100fddb6b4 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:17:43 +0800 Subject: [PATCH 012/486] =?UTF-8?q?feat(awd):=20round=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=EF=BC=88P3-13=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore_round_scheduling:Active/Grace round 缺 end/grace_end 任务按时间重建(幂等) - recovery_service 接线(第 4 步) --- .../awd_team/service/recovery_service.rs | 12 ++- .../event/awd_team/service/round_service.rs | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/api/src/modules/event/awd_team/service/recovery_service.rs b/apps/api/src/modules/event/awd_team/service/recovery_service.rs index d5cbaf94..a0850dcf 100644 --- a/apps/api/src/modules/event/awd_team/service/recovery_service.rs +++ b/apps/api/src/modules/event/awd_team/service/recovery_service.rs @@ -19,7 +19,7 @@ use crate::modules::event::awd_team::{ network::{AwdNetworkRuntime, WireGuardDesiredState}, }, repo::event_repo, - service::firewall_service, + service::{firewall_service, round_service}, }; use fcmc::AwdContainerRuntime; @@ -189,6 +189,16 @@ async fn recover_event( firewall_service::flush_event_connections(network, event.event_id, &event.gamebox_cidr).await; recovered += 1; + // ── 4. Round 调度恢复(P3-13):Active/Grace round 缺任务则按时间重建 ── + let restored_tasks = round_service::restore_round_scheduling(db, event.event_id).await?; + if restored_tasks > 0 { + info!( + "[Recovery] Event {} restored {} round scheduling task(s)", + event.event_id, restored_tasks + ); + } + recovered += restored_tasks as u32; + Ok(recovered) } diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 1c45d973..18007150 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -462,6 +462,90 @@ async fn dispatch_judge_for_round( /// 用 JudgeDown + reason="judge deadline timeout" 表达,审计可区分。 /// 幂等键 `judge-timeout:{task_id}`(scheduler retry 不重复计分)。 /// 返回计分的任务数。 +/// P3-13:重启后恢复当前 round 的调度任务(幂等)。 +/// +/// 场景:历史版本创建的 round 没有 RoundEnd 任务,或崩溃发生在任务窗口内。 +/// 恢复规则: +/// - Active round:缺 pending `awd.round.end` 任务 → 按 scheduled_end_at 重建; +/// - Grace round:缺 pending `awd.round.grace_end` 任务 → 按 grace_ends_at 重建; +/// - Paused round:暂停中,不恢复 end/grace 任务(resume 时重建)。 +pub async fn restore_round_scheduling( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, +) -> AwdResult { + use crate::entity::sea_orm_active_enums::RoundStatus as RS; + + let Some(round) = awd_rounds::Entity::find() + .filter(awd_rounds::Column::EventId.eq(event_id)) + .filter(awd_rounds::Column::Status.is_in([ + RS::Active, + RS::Grace, + RS::Paused, + ])) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + else { + return Ok(0); + }; + + let mut restored = 0usize; + let task_exists = |key: TaskKey| -> std::pin::Pin> + Send + '_>> { + Box::pin(async move { + let found = scheduled_tasks::Entity::find() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq(key.to_string())) + .filter(scheduled_tasks::Column::Status.eq("pending")) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + Ok(found.is_some()) + }) + }; + + match round.status { + RS::Active => { + if !task_exists(TaskKey::AwdRoundEnd).await? { + let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + schedule_round_task( + &txn, + event_id, + TaskKey::AwdRoundEnd, + round.scheduled_end_at.fixed_offset(), + Some(round.id), + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + restored += 1; + } + } + RS::Grace => { + if let Some(grace_end) = round.grace_ends_at { + if !task_exists(TaskKey::AwdRoundGraceEnd).await? { + let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + schedule_round_task( + &txn, + event_id, + TaskKey::AwdRoundGraceEnd, + grace_end.fixed_offset(), + Some(round.id), + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + restored += 1; + } + } + } + RS::Paused => { + // 暂停中:任务挂起由 resume 重建 + } + _ => {} + } + Ok(restored) +} + pub async fn score_judge_timeouts( db: &sea_orm::DatabaseConnection, round_id: Uuid, From 8706be09582296e3021b4fdddfd09d94aeafe08a Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:19:02 +0800 Subject: [PATCH 013/486] =?UTF-8?q?fix(awd):=20judge=20callback=20?= =?UTF-8?q?=E5=90=8C=E8=BA=AB=E4=BB=BD=E6=8C=87=E6=95=B0=E9=80=80=E9=81=BF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=20+=20=E8=84=9A=E6=9C=AC=20env=20=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=88P3-9/P3-12=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/awd-judgeserver/src/main.rs | 163 +++++++++++++++++++++++++---- 1 file changed, 140 insertions(+), 23 deletions(-) diff --git a/crates/awd-judgeserver/src/main.rs b/crates/awd-judgeserver/src/main.rs index 85065f4d..e321a919 100644 --- a/crates/awd-judgeserver/src/main.rs +++ b/crates/awd-judgeserver/src/main.rs @@ -7,6 +7,8 @@ //! 3. Script receives target IP via command-line args //! 4. Script checks the target GameBox's services over the network //! 5. Each task result is immediately POSTed back to the platform +//! (失败按指数退避重试,且复用同一 callback_id,见 P3-9) +//! 6. Judge 脚本执行时使用最小 env 白名单(PATH/HOME/LANG + JUDGE_*,见 P3-12) //! //! # Configuration (env vars) //! @@ -129,7 +131,7 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { // Write script to temp file if let Err(e) = tokio::fs::write(&script_path, &task.script_content).await { tracing::error!("Failed to write script {}: {}", script_path, e); - send_result( + let _ = send_result( state, &task, TaskResult { @@ -172,7 +174,13 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { let result = tokio::time::timeout( Duration::from_secs(task.timeout_secs), - Command::new(&script_path).args(&args).output(), + // env 白名单(P3-12):只透传 PATH/HOME/LANG 与 JUDGE_* 前缀变量, + // 其余宿主环境(含 INTERNAL_TOKEN 等敏感项)对脚本一律不可见。 + Command::new(&script_path) + .args(&args) + .env_clear() + .envs(build_script_env(env::vars())) + .output(), ) .await; @@ -194,7 +202,7 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { _ => "judge_error", }; - send_result( + let _ = send_result( state, &task, TaskResult { @@ -217,7 +225,7 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { Ok(Err(e)) => { tracing::error!("Task {} failed: {}", task.id, e); if attempts >= max_attempts { - send_result( + let _ = send_result( state, &task, TaskResult { @@ -239,7 +247,7 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { Err(_timeout) => { tracing::warn!("Task {} timed out", task.id); if attempts >= max_attempts { - send_result( + let _ = send_result( state, &task, TaskResult { @@ -262,38 +270,108 @@ async fn execute_single_task(state: &AppState, task: JudgeTask) { } } +// ── Callback 重试(P3-9)── + +/// 回调最大尝试次数:初始 1 次 + 3 次重试。 +const CALLBACK_MAX_ATTEMPTS: usize = 4; +/// 指数退避间隔(秒):第 2 / 3 / 4 次尝试前分别等待 1s / 2s / 4s。 +const CALLBACK_RETRY_DELAYS_SECS: [u64; 3] = [1, 2, 4]; + +/// 第 `attempt` 次尝试前的退避等待时长(attempt 从 1 开始;首次尝试不等待)。 +/// 调用方需保证 `attempt >= 2`。 +fn callback_retry_delay(attempt: usize) -> Duration { + Duration::from_secs(CALLBACK_RETRY_DELAYS_SECS[attempt - 2]) +} + +/// Judge 脚本执行的最小 env 白名单(P3-12,安全边界)。 +/// +/// 规则:禁止透传宿主全部环境变量;只保留 +/// - `PATH` / `HOME` / `LANG`:脚本正常运行所需的基础变量 +/// - 宿主中以 `JUDGE_` 开头的变量:运维可按需给脚本注入自定义配置 +/// +/// 其余环境变量一律清除——特别是 `INTERNAL_TOKEN` 等敏感项 +/// 绝不能对任意脚本内容可见。 +fn build_script_env(host_env: impl Iterator) -> Vec<(String, String)> { + host_env + .filter(|(key, _)| { + key == "PATH" || key == "HOME" || key == "LANG" || key.starts_with("JUDGE_") + }) + .collect() +} + /// POST the task result back to the platform. -async fn send_result(state: &AppState, task: &JudgeTask, result: TaskResult) { +/// +/// 失败时按指数退避自动重试(最多 `CALLBACK_MAX_ATTEMPTS` 次)。 +/// **每次重试复用同一个 `result`(同一 callback_id / 同一 callback identity)**: +/// 平台侧 judge_callback 已按 callback_id/task_id 幂等(P3-8), +/// 重复投递不会重复计分;绝不能为每次重试生成新 ID(计划 §5.7)。 +/// 全部尝试仍失败 → 记录 error 并返回 Err(保持原有"记录后继续"语义)。 +async fn send_result(state: &AppState, task: &JudgeTask, result: TaskResult) -> Result<(), String> { let url = format!( "{}/internal/awd/events/{}/judge/callback", state.platform_url, state.event_id ); - let res = state - .client - .post(&url) - .header("Authorization", format!("Bearer {}", state.internal_token)) - .json(&result) - .timeout(Duration::from_secs(10)) - .send() - .await; + let mut last_error = String::new(); + + for attempt in 1..=CALLBACK_MAX_ATTEMPTS { + if attempt > 1 { + let delay = callback_retry_delay(attempt); + tracing::warn!( + "Callback for task {} failed, retrying in {:?} (attempt {}/{})", + task.id, + delay, + attempt, + CALLBACK_MAX_ATTEMPTS + ); + tokio::time::sleep(delay).await; + } - match res { - Ok(resp) => { - if resp.status().is_success() { + // 每次尝试复用同一个 result(含同一 callback_id),保持同 identity。 + let res = state + .client + .post(&url) + .header("Authorization", format!("Bearer {}", state.internal_token)) + .json(&result) + .timeout(Duration::from_secs(10)) + .send() + .await; + + match res { + Ok(resp) if resp.status().is_success() => { tracing::info!("Callback OK for task {}", task.id); - } else { + return Ok(()); + } + Ok(resp) => { + last_error = format!("HTTP {}", resp.status()); tracing::warn!( - "Callback failed for task {}: HTTP {}", + "Callback failed for task {}: HTTP {} (attempt {}/{})", task.id, - resp.status() + resp.status(), + attempt, + CALLBACK_MAX_ATTEMPTS + ); + } + Err(e) => { + last_error = format!("network error: {}", e); + tracing::warn!( + "Callback network error for task {}: {} (attempt {}/{})", + task.id, + e, + attempt, + CALLBACK_MAX_ATTEMPTS ); } - } - Err(e) => { - tracing::error!("Callback network error for task {}: {}", task.id, e); } } + + tracing::error!( + "Callback permanently failed for task {} after {} attempts: {}", + task.id, + CALLBACK_MAX_ATTEMPTS, + last_error + ); + Err(last_error) } fn truncate_str(s: &str, max_len: usize) -> String { @@ -323,6 +401,45 @@ mod tests { assert!(!has_valid_bearer_token(&missing, "event-token")); assert!(!has_valid_bearer_token(&wrong, "event-token")); } + + #[test] + fn callback_retry_delay_follows_exponential_backoff() { + // 最多 4 次尝试(初始 1 次 + 3 次重试),退避间隔 1s / 2s / 4s。 + assert_eq!(CALLBACK_MAX_ATTEMPTS, 4); + assert_eq!(CALLBACK_RETRY_DELAYS_SECS, [1, 2, 4]); + assert_eq!(callback_retry_delay(2), Duration::from_secs(1)); + assert_eq!(callback_retry_delay(3), Duration::from_secs(2)); + assert_eq!(callback_retry_delay(4), Duration::from_secs(4)); + } + + #[test] + fn script_env_allowlist_only_keeps_whitelisted_vars() { + let host_env = vec![ + ("PATH".to_string(), "/usr/bin:/bin".to_string()), + ("HOME".to_string(), "/root".to_string()), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("JUDGE_TIMEOUT_FACTOR".to_string(), "3".to_string()), + // 敏感项与无关变量必须被过滤掉。 + ("INTERNAL_TOKEN".to_string(), "should-not-leak".to_string()), + ( + "PLATFORM_INTERNAL_URL".to_string(), + "http://127.0.0.1".to_string(), + ), + ("RUST_LOG".to_string(), "debug".to_string()), + ]; + + let allowlist = build_script_env(host_env.into_iter()); + let keys: Vec<&str> = allowlist.iter().map(|(k, _)| k.as_str()).collect(); + + assert!(keys.contains(&"PATH")); + assert!(keys.contains(&"HOME")); + assert!(keys.contains(&"LANG")); + assert!(keys.contains(&"JUDGE_TIMEOUT_FACTOR")); + assert!(!keys.contains(&"INTERNAL_TOKEN")); + assert!(!keys.contains(&"PLATFORM_INTERNAL_URL")); + assert!(!keys.contains(&"RUST_LOG")); + assert_eq!(allowlist.len(), 4); + } } /// GET /health — liveness check. From 72bd63aa859292408a9343d68e00b0ac3a486b9c Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:21:57 +0800 Subject: [PATCH 014/486] =?UTF-8?q?feat(awd):=20token=20rotation=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=BC=96=E6=8E=92=E4=B8=8E=20key=5Fversion?= =?UTF-8?q?=20=E9=AA=8C=E8=AF=81=EF=BC=88P3-10/P3-11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rotate_tokens:修复外键当主键静默 0 行 bug;key_version+1 与新版本加密 - DB 更新 + rotation audit 同事务(rotated_by=admin) - 容器 rollout:recreate flagserver/judgeserver(同 IP/网络/新 token),失败返回可重跑 - is_valid_token 增加 key_version 参数(auth.rs 传入 awd_event.key_version) - 新增 key_version 语义单测 --- .../src/modules/event/awd_team/api/admin.rs | 149 +++++++++++++++--- .../src/modules/event/awd_team/api/auth.rs | 16 +- apps/api/src/modules/event/awd_team/crypto.rs | 37 ++++- .../event/awd_team/service/round_service.rs | 44 +++--- 4 files changed, 204 insertions(+), 42 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 9c1b9005..6296c3ed 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -487,61 +487,174 @@ pub async fn archive_event( } /// POST /api/admin/events/{event_id}/awd/tokens/rotate +/// +/// P3-10 完整编排(计划 §5.6): +/// 1. key_version + 1,新 token 用新版本加密(修复原实现硬编码 1 + 用外键当主键 +/// 静默 0 行的历史 bug); +/// 2. DB 原子更新:token ciphertext + key_version + rotation audit(同一事务); +/// 3. 容器 rollout:recreate flagserver/judgeserver 容器(同固定 IP/网络,新 INTERNAL_TOKEN)。 +/// +/// 失败模型:DB 更新是原子 desired state;rollout 失败返回错误可重跑, +/// 绝不允许"DB 已只认新 token 但运行中容器仍拿旧 token"的静默态。 #[post("/events/{event_id}/awd/tokens/rotate")] pub async fn rotate_tokens( - _admin: SuperAdminJwtGuard, + admin: SuperAdminJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path, ) -> UniResult<()> { use crate::entity::awd_events; use crate::modules::event::awd_team::crypto::AwdCrypto; let event_id = path.into_inner(); + let admin_id = admin.into_inner().id; + + // 1. 解析真实 awd_event(真实主键 + 当前 key_version + infra 信息) + let awd_event = event_repo::find_by_event_id(ctx.db.get_ref(), event_id) + .await + .map_err(|e| AppError::Database(e.to_string()))? + .ok_or_else(|| AppError::NotFound("AWD event not found".into()))?; + let new_key_version = awd_event.key_version + 1; - // Initialize crypto let crypto = AwdCrypto::from_config_secret().map_err(|e| AppError::Internal(e.to_string()))?; - // Generate new tokens + // 2. 生成并加密新 token(新 key_version) let fs_token = AwdCrypto::generate_token(); let js_token = AwdCrypto::generate_token(); - - // Encrypt tokens let fs_aad = AwdCrypto::build_aad(event_id, "internal_token"); let fs_blob = crypto - .encrypt(&fs_token, &fs_aad, 1) + .encrypt(&fs_token, &fs_aad, new_key_version) .map_err(|e| AppError::Internal(e.to_string()))?; - let js_aad = AwdCrypto::build_aad(event_id, "internal_token"); let js_blob = crypto - .encrypt(&js_token, &js_aad, 1) + .encrypt(&js_token, &js_aad, new_key_version) .map_err(|e| AppError::Internal(e.to_string()))?; - // Update the event with new encrypted tokens + // 3. DB 原子更新(真实主键)+ audit 同一事务 + let txn = ctx.db.begin().await?; let mut active: awd_events::ActiveModel = awd_events::ActiveModel { - id: Set(event_id), + id: Set(awd_event.id), + key_version: Set(new_key_version), + flagserver_token_ciphertext: Set(Some(fs_blob.ciphertext)), + flagserver_token_nonce: Set(Some(fs_blob.nonce)), + judgeserver_token_ciphertext: Set(Some(js_blob.ciphertext)), + judgeserver_token_nonce: Set(Some(js_blob.nonce)), ..Default::default() }; - active.flagserver_token_ciphertext = Set(Some(fs_blob.ciphertext)); - active.flagserver_token_nonce = Set(Some(fs_blob.nonce)); - active.judgeserver_token_ciphertext = Set(Some(js_blob.ciphertext)); - active.judgeserver_token_nonce = Set(Some(js_blob.nonce)); - active - .update(ctx.db.get_ref()) + .update(&txn) .await .map_err(|e| AppError::Database(e.to_string()))?; - // Create rotation audit record let rotation = crate::entity::awd_internal_token_rotations::ActiveModel { id: Set(Uuid::new_v4()), event_id: Set(event_id), token_type: Set("all".to_string()), + rotated_by: Set(Some(admin_id)), ..Default::default() }; rotation - .insert(ctx.db.get_ref()) + .insert(&txn) .await .map_err(|e| AppError::Database(e.to_string()))?; + txn.commit().await?; + + // 4. 容器 rollout:recreate flagserver/judgeserver(同 IP/网络,新 token) + let network_name = awd_event + .docker_network_name + .clone() + .ok_or_else(|| AppError::Internal("Docker network not configured".into()))?; + let fs_token_str = + String::from_utf8(fs_token).map_err(|_| AppError::Internal("token not utf8".into()))?; + let js_token_str = + String::from_utf8(js_token).map_err(|_| AppError::Internal("token not utf8".into()))?; + rollout_infra_container( + ctx.db.get_ref(), + awd.containers.as_ref(), + &awd_event, + event_id, + "flagserver", + &awd_event.flagserver_ip, + &network_name, + ctx.config.awd.flagserver_image.clone(), + fs_token_str, + ) + .await?; + rollout_infra_container( + ctx.db.get_ref(), + awd.containers.as_ref(), + &awd_event, + event_id, + "judgeserver", + &awd_event.judgeserver_ip, + &network_name, + ctx.config.awd.judgeserver_image.clone(), + js_token_str, + ) + .await?; + + UniResponse::ok_none().into() +} + +/// 重建一个 infra 容器(stop → create,同 fixed_ip/网络,env 带新 token)。 +async fn rollout_infra_container( + db: &sea_orm::DatabaseConnection, + containers: &dyn fcmc::AwdContainerRuntime, + awd_event: &crate::entity::awd_events::Model, + event_id: Uuid, + kind: &str, + fixed_ip: &str, + network_name: &str, + image_ref: String, + token: String, +) -> UniResult<()> { + let container_name = format!("fctf-{}-{}", kind, &event_id.to_string()[..8]); + + if let Err(e) = containers.stop_container(&container_name).await { + // 容器不存在也继续 create(幂等 rollout) + tracing::info!("[Rotate] stop {}: {}", container_name, e); + } + + containers + .create_infrastructure_container(fcmc::InfrastructureContainerSpec { + event_id, + container_name: container_name.clone(), + image_ref, + network_name: network_name.to_string(), + fixed_ip: fixed_ip.to_string(), + env: vec![ + format!("EVENT_ID={event_id}"), + format!("INTERNAL_TOKEN={token}"), + format!("LISTEN_ADDR=0.0.0.0:8080"), + ], + cpu_millis: Some(500), + memory_bytes: Some(256 * 1024 * 1024), + }) + .await + .map_err(|e| { + AppError::Internal(format!( + "token rotation DB committed but {kind} rollout failed: {e}(可重跑本端点)" + )) + })?; + + // 更新 runtime resource 记录的 container_id(rollout 后容器 id 变化) + use crate::entity::awd_runtime_resources; + let updated = awd_runtime_resources::Entity::update_many() + .col_expr( + awd_runtime_resources::Column::ResourceId, + sea_orm::sea_query::Expr::value(container_name.clone()), + ) + .filter(awd_runtime_resources::Column::EventId.eq(event_id)) + .filter(awd_runtime_resources::Column::ResourceType.eq(kind)) + .exec(db) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + if updated.rows_affected == 0 { + tracing::warn!("[Rotate] no awd_runtime_resources row for {kind} — recorded manually"); + } + + let _ = awd_event; + tracing::info!("[Rotate] {kind} container rolled out as {}", container_name); UniResponse::ok_none().into() } diff --git a/apps/api/src/modules/event/awd_team/api/auth.rs b/apps/api/src/modules/event/awd_team/api/auth.rs index 9073c9b1..74aac09d 100644 --- a/apps/api/src/modules/event/awd_team/api/auth.rs +++ b/apps/api/src/modules/event/awd_team/api/auth.rs @@ -118,7 +118,13 @@ impl FromRequest for AwdInternalAuth { ) { if !ciphertext.is_empty() && crypto - .is_valid_token(&token_bytes, ciphertext, nonce, path_event_id) + .is_valid_token( + &token_bytes, + ciphertext, + nonce, + path_event_id, + awd_event.key_version, + ) .map_err(|e| AuthError::CryptoError(e.to_string()))? { return Ok(AwdInternalAuth { @@ -136,7 +142,13 @@ impl FromRequest for AwdInternalAuth { ) { if !ciphertext.is_empty() && crypto - .is_valid_token(&token_bytes, ciphertext, nonce, path_event_id) + .is_valid_token( + &token_bytes, + ciphertext, + nonce, + path_event_id, + awd_event.key_version, + ) .map_err(|e| AuthError::CryptoError(e.to_string()))? { return Ok(AwdInternalAuth { diff --git a/apps/api/src/modules/event/awd_team/crypto.rs b/apps/api/src/modules/event/awd_team/crypto.rs index 86718f5c..23e32d44 100644 --- a/apps/api/src/modules/event/awd_team/crypto.rs +++ b/apps/api/src/modules/event/awd_team/crypto.rs @@ -237,9 +237,14 @@ impl AwdCrypto { stored_ciphertext: &[u8], stored_nonce: &[u8], event_id: Uuid, + key_version: i32, ) -> Result { let aad = Self::build_aad(event_id, "internal_token"); - let re_encrypted = self.encrypt_with_nonce(provided_token, &aad, stored_nonce, 1)?; + // P3-11:key_version 参与验证(原硬编码 1,token rotation 后版本漂移)。 + // 说明:XChaCha20-Poly1305 密文不随 key_version 变化(它只是 blob 元数据), + // 但为保持版本语义一致与审计可溯,重加密必须使用存储时的版本。 + let re_encrypted = + self.encrypt_with_nonce(provided_token, &aad, stored_nonce, key_version)?; if re_encrypted.ciphertext.len() != stored_ciphertext.len() { return Ok(false); } @@ -441,7 +446,31 @@ mod tests { // Verify should succeed assert!( crypto - .is_valid_token(&token, &blob.ciphertext, &blob.nonce, event_id) + .is_valid_token(&token, &blob.ciphertext, &blob.nonce, event_id, 1) + .unwrap() + ); + } + + #[test] + fn test_is_valid_token_uses_key_version() { + // P3-11:key_version 参与验证路径;存储版本与验证版本一致时通过。 + let crypto = test_crypto(); + let event_id = Uuid::new_v4(); + let aad = AwdCrypto::build_aad(event_id, "internal_token"); + + let token = AwdCrypto::generate_token(); + let blob = crypto.encrypt(&token, &aad, 2).unwrap(); + + assert!( + crypto + .is_valid_token(&token, &blob.ciphertext, &blob.nonce, event_id, 2) + .unwrap() + ); + // 错误 token 用任何版本都拒绝 + let wrong = AwdCrypto::generate_token(); + assert!( + !crypto + .is_valid_token(&wrong, &blob.ciphertext, &blob.nonce, event_id, 2) .unwrap() ); } @@ -459,7 +488,7 @@ mod tests { // Wrong token should fail assert!( !crypto - .is_valid_token(&wrong_token, &blob.ciphertext, &blob.nonce, event_id) + .is_valid_token(&wrong_token, &blob.ciphertext, &blob.nonce, event_id, 1) .unwrap() ); } @@ -477,7 +506,7 @@ mod tests { // Wrong event_id changes AAD → ciphertext differs → verification fails assert!( !crypto - .is_valid_token(&token, &blob.ciphertext, &blob.nonce, wrong_event_id) + .is_valid_token(&token, &blob.ciphertext, &blob.nonce, wrong_event_id, 1) .unwrap() ); } diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 18007150..4c85aced 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -477,11 +477,7 @@ pub async fn restore_round_scheduling( let Some(round) = awd_rounds::Entity::find() .filter(awd_rounds::Column::EventId.eq(event_id)) - .filter(awd_rounds::Column::Status.is_in([ - RS::Active, - RS::Grace, - RS::Paused, - ])) + .filter(awd_rounds::Column::Status.is_in([RS::Active, RS::Grace, RS::Paused])) .one(db) .await .map_err(|e| AwdError::Database(e.to_string()))? @@ -490,23 +486,28 @@ pub async fn restore_round_scheduling( }; let mut restored = 0usize; - let task_exists = |key: TaskKey| -> std::pin::Pin> + Send + '_>> { + let task_exists = |key: TaskKey| -> std::pin::Pin< + Box> + Send + '_>, + > { Box::pin(async move { - let found = scheduled_tasks::Entity::find() - .filter(scheduled_tasks::Column::GroupId.eq(event_id)) - .filter(scheduled_tasks::Column::TaskKey.eq(key.to_string())) - .filter(scheduled_tasks::Column::Status.eq("pending")) - .one(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - Ok(found.is_some()) + let found = scheduled_tasks::Entity::find() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq(key.to_string())) + .filter(scheduled_tasks::Column::Status.eq("pending")) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + Ok(found.is_some()) }) }; match round.status { RS::Active => { if !task_exists(TaskKey::AwdRoundEnd).await? { - let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; schedule_round_task( &txn, event_id, @@ -516,14 +517,19 @@ pub async fn restore_round_scheduling( ) .await .map_err(|e| AwdError::Database(e.to_string()))?; - txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; restored += 1; } } RS::Grace => { if let Some(grace_end) = round.grace_ends_at { if !task_exists(TaskKey::AwdRoundGraceEnd).await? { - let txn = db.begin().await.map_err(|e| AwdError::Database(e.to_string()))?; + let txn = db + .begin() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; schedule_round_task( &txn, event_id, @@ -533,7 +539,9 @@ pub async fn restore_round_scheduling( ) .await .map_err(|e| AwdError::Database(e.to_string()))?; - txn.commit().await.map_err(|e| AwdError::Database(e.to_string()))?; + txn.commit() + .await + .map_err(|e| AwdError::Database(e.to_string()))?; restored += 1; } } From d007159c58df6745d13bbbb53a7da8813cac8ee1 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:28:53 +0800 Subject: [PATCH 015/486] =?UTF-8?q?feat(awd):=20ResetActor=20=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=20+=20player=20reset=20=E6=8E=A5=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=B5=81=E7=A8=8B=EF=BC=88P4-1/P4-2/P4-3/P4-4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResetActor::{Player,Admin} 取代 admin 传 Uuid::nil() 恒 Forbidden - player reset_my_gamebox 改调 execute_reset(完整重建/同 IP/Ready/ResetFailed) - reset limit/protection 强制:保护窗口拒绝;count, ) -> UniResult<()> { let (event_id, instance_id) = path.into_inner(); + let admin_id = _admin.into_inner().id; crate::modules::event::awd_team::service::reset_service::execute_reset( ctx.db.get_ref(), awd.containers.as_ref(), crate::modules::event::awd_team::service::reset_service::ResetContext { event_id, instance_id, - team_id: uuid::Uuid::nil(), - requested_by: uuid::Uuid::nil(), - is_free: true, + team_id: uuid::Uuid::nil(), // Admin:ownership 豁免,真实 team_id 由 service 解析 + actor: crate::modules::event::awd_team::service::reset_service::ResetActor::Admin { + admin_id, + charge_team: false, + }, }, ) .await diff --git a/apps/api/src/modules/event/awd_team/api/player.rs b/apps/api/src/modules/event/awd_team/api/player.rs index 67f29560..0d54df80 100644 --- a/apps/api/src/modules/event/awd_team/api/player.rs +++ b/apps/api/src/modules/event/awd_team/api/player.rs @@ -59,6 +59,7 @@ pub async fn get_my_gameboxes( pub async fn reset_my_gamebox( user: UserJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path<(Uuid, Uuid)>, ) -> UniResult<()> { let (event_id, instance_id) = path.into_inner(); @@ -70,13 +71,19 @@ pub async fn reset_my_gamebox( .map_err(|e| AppError::Database(e.to_string()))? .ok_or_else(|| AppError::NotFound("You are not in a team for this event".into()))?; - gamebox_service::reset_gamebox( + // P4-2:player reset 接入完整 execute_reset 流程(同 IP/同密码/Ready/ResetFailed) + crate::modules::event::awd_team::service::reset_service::execute_reset( ctx.db.get_ref(), - event_id, - instance_id, - membership.team_id, - user.id, - false, // player reset may cost a free reset or penalty + awd.containers.as_ref(), + crate::modules::event::awd_team::service::reset_service::ResetContext { + event_id, + instance_id, + team_id: membership.team_id, + actor: crate::modules::event::awd_team::service::reset_service::ResetActor::Player { + user_id: user.id, + team_id: membership.team_id, + }, + }, ) .await .map_err(AppError::from)?; diff --git a/apps/api/src/modules/event/awd_team/service/reset_service.rs b/apps/api/src/modules/event/awd_team/service/reset_service.rs index 819f0e4c..7af43793 100644 --- a/apps/api/src/modules/event/awd_team/service/reset_service.rs +++ b/apps/api/src/modules/event/awd_team/service/reset_service.rs @@ -29,13 +29,30 @@ use crate::modules::event::awd_team::{ service::score_service, }; +/// 重置发起方(P4-1 显式化,废弃 admin 传 `Uuid::nil()` hack)。 +#[derive(Debug, Clone)] +pub enum ResetActor { + Player { user_id: Uuid, team_id: Uuid }, + /// charge_team=true 时该次重置也计入队伍重置次数并可能扣分。 + Admin { admin_id: Uuid, charge_team: bool }, +} + +impl ResetActor { + pub fn requester_id(&self) -> Uuid { + match self { + ResetActor::Player { user_id, .. } => *user_id, + ResetActor::Admin { admin_id, .. } => *admin_id, + } + } +} + /// Reset record created during a reset operation. pub struct ResetContext { pub event_id: Uuid, pub instance_id: Uuid, + /// 归属队伍(Admin 豁免 ownership 校验,但仍需真实 team_id 记账)。 pub team_id: Uuid, - pub requested_by: Uuid, - pub is_free: bool, + pub actor: ResetActor, } /// Execute the full GameBox reset workflow. @@ -54,20 +71,26 @@ pub async fn execute_reset( return Err(AwdError::Forbidden("Event is not running".into())); } - // 2. Verify instance belongs to team + // 2. Load instance(先解析真实 team_id,再按 actor 做 ownership 校验) let instance = gamebox_repo::find_instance_by_id(db, ctx.instance_id) .await .map_err(|e| AwdError::Database(e.to_string()))? .ok_or_else(|| AwdError::NotFound("GameBox instance not found".into()))?; - - if instance.team_id != ctx.team_id { - return Err(AwdError::Forbidden( - "This GameBox does not belong to your team".into(), - )); - } + let team_id = match &ctx.actor { + // Player:必须 ownership;Admin:豁免(用 instance 真实 team_id 记账) + ResetActor::Player { team_id, .. } => { + if *team_id != instance.team_id { + return Err(AwdError::Forbidden( + "This GameBox does not belong to your team".into(), + )); + } + *team_id + } + ResetActor::Admin { .. } => instance.team_id, + }; // 3. Check team not banned - if ban_repo::find_active_ban(db, ctx.event_id, ctx.team_id) + if ban_repo::find_active_ban(db, ctx.event_id, team_id) .await .map_err(|e| AwdError::Database(e.to_string()))? .is_some() @@ -75,20 +98,32 @@ pub async fn execute_reset( return Err(AwdError::Forbidden("Team is banned".into())); } - // 4. Check reset_protection_until — skip judge during protection + // 4. Protection 强制(P4-3):保护窗口内拒绝(防滥用) let in_protection = instance .reset_protection_until .map(|t| t.with_timezone(&chrono::Utc) > chrono::Utc::now()) .unwrap_or(false); + if in_protection { + return Err(AwdError::Forbidden( + "Reset is in protection window; wait for it to expire".into(), + )); + } + + // 5. 免费次数判定(P4-3):count < free_reset_count 为免费;其余付费 + let used = team_reset_count(db, ctx.event_id, team_id).await?; + let is_free = match &ctx.actor { + ResetActor::Player { .. } => used < awd_event.free_reset_count as i64, + ResetActor::Admin { charge_team, .. } => !charge_team || used < awd_event.free_reset_count as i64, + }; - // 5. Create reset record + // 6. Create reset record let reset_record = awd_reset_records::ActiveModel { id: Set(Uuid::new_v4()), event_id: Set(ctx.event_id), - team_id: Set(ctx.team_id), + team_id: Set(team_id), gamebox_instance_id: Set(ctx.instance_id), - requested_by: Set(Some(ctx.requested_by)), - free_reset: Set(ctx.is_free), + requested_by: Set(Some(ctx.actor.requester_id())), + free_reset: Set(is_free), status: Set("pending".to_string()), ..Default::default() }; @@ -98,27 +133,6 @@ pub async fn execute_reset( .map_err(|e| AwdError::Database(e.to_string()))? .id; - // 6. If not free, apply penalty - if !ctx.is_free { - let penalty_penalty = awd_event.extra_reset_penalty; - let idempotency_key = format!("reset:{}", reset_id); - score_repo::create_score_event( - db, - ctx.event_id, - None, - ctx.team_id, - ScoreEventType::ResetPenalty, - -penalty_penalty, - &idempotency_key, - None, - None, - None, - Some("excess reset penalty"), - ) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - } - // 7. Mark instance as resetting gamebox_repo::update_instance_status(db, ctx.instance_id, GameboxStatus::Resetting) .await @@ -234,7 +248,29 @@ pub async fn execute_reset( } } - // 10. Mark reset record as completed + // 10. Penalty 时机(P4-4):重建成功后才扣分(原实现在重建前扣, + // 重建失败用户已被扣分却拿不到新容器) + if !is_free { + let penalty_penalty = awd_event.extra_reset_penalty; + let idempotency_key = format!("reset:{}", reset_id); + score_repo::create_score_event( + db, + ctx.event_id, + None, + team_id, + ScoreEventType::ResetPenalty, + -penalty_penalty, + &idempotency_key, + None, + None, + None, + Some("excess reset penalty"), + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + } + + // 11. Mark reset record as completed let mut reset_active: awd_reset_records::ActiveModel = awd_reset_records::ActiveModel { id: Set(reset_id), status: Set("completed".to_string()), @@ -248,7 +284,7 @@ pub async fn execute_reset( info!( "GameBox reset completed: instance {} (free={}, reset_id={})", - ctx.instance_id, ctx.is_free, reset_id + ctx.instance_id, is_free, reset_id ); Ok(()) From 367d37d5325326e9af48bd8f971f0751d01dc4bc Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:32:37 +0800 Subject: [PATCH 016/486] =?UTF-8?q?feat(awd):=20ban=20=E8=B7=A8=E5=B1=82?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=EF=BC=88banned=20set=20+=20WG=20suspend=20+?= =?UTF-8?q?=20conntrack=EF=BC=89=E4=B8=8E=E8=87=AA=E5=8A=A8=E8=A7=A3?= =?UTF-8?q?=E5=B0=81=EF=BC=88P4-5/P4-6/P4-7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ban_service:单入口 ban_team/unban_team 跨层闭环 (DB desired → WG host 挂起/恢复(DB 保持 Active)→ 全局 reconcile 重建 banned set → conntrack 清理 → publish team.banned/unbanned) - wireguard_service::suspend_team_peers_from_host(ban=suspend 非 revoke) - P4-7:BanTeamRequest.duration_secs → TaskKey::AwdTeamUnban + handler 自动解封 - admin ban/unban 端点接入 ban_service(含 admin id audit) --- apps/api/src/bootstrap/scheduler.rs | 7 + .../src/modules/event/awd_team/api/admin.rs | 105 +++++++---- .../api/src/modules/event/awd_team/api/dto.rs | 3 + .../event/awd_team/repo/gamebox_repo.rs | 13 ++ .../modules/event/awd_team/scheduler/mod.rs | 40 +++++ .../event/awd_team/service/ban_service.rs | 166 ++++++++++++++++++ .../src/modules/event/awd_team/service/mod.rs | 1 + .../awd_team/service/wireguard_service.rs | 25 +++ apps/api/src/scheduler/task_key.rs | 4 + 9 files changed, 333 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/modules/event/awd_team/service/ban_service.rs diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index 3459a923..284c3cbd 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -14,6 +14,7 @@ use crate::{ scheduler::{ AwdArchiveCleanupHandler, AwdAutoPrecheckHandler, AwdEventStartHandler, AwdRoundEndHandler, AwdRoundGraceEndHandler, AwdRoundStartHandler, + AwdTeamUnbanHandler, }, }, scheduler::{ @@ -73,6 +74,12 @@ pub async fn build_task_scheduler( db: db.clone(), publisher: publisher.clone(), }), + Arc::new(AwdTeamUnbanHandler { + db: db.clone(), + network: network.clone(), + firewall: firewall.clone(), + publisher: publisher.clone(), + }), Arc::new(AwdArchiveCleanupHandler { db, docker: docker.clone(), diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index cbf37d0c..0b68a809 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -202,33 +202,83 @@ pub async fn finish_awd_event( // ── Ban Management ── /// POST /api/admin/events/{event_id}/awd/teams/{team_id}/ban +/// +/// P4-5 跨层闭环:DB ban → WG host 挂起(DB 保持 Active)→ banned set reconcile +/// → conntrack 清理 → publish。duration_secs 设置时创建自动解封任务(P4-7)。 #[post("/events/{event_id}/awd/teams/{team_id}/ban")] pub async fn ban_team( - _admin: SuperAdminJwtGuard, + admin: SuperAdminJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path<(Uuid, Uuid)>, body: web::Json, ) -> UniResult { let (event_id, team_id) = path.into_inner(); - let admin_id = _admin.into_inner().id; - - // Find active round for effective_round_id - let active_round = round_repo::find_active_round(ctx.db.get_ref(), event_id) - .await - .map_err(|e| AppError::Database(e.to_string()))?; + let admin_id = admin.into_inner().id; - let ban = ban_repo::create_ban( + let ban_id = crate::modules::event::awd_team::service::ban_service::ban_team( ctx.db.get_ref(), + awd.network.as_ref(), + awd.firewall.as_ref(), + awd.publisher.as_ref(), event_id, team_id, body.reason.as_deref(), - active_round.map(|r| r.id), Some(admin_id), ) .await - .map_err(|e| AppError::Database(e.to_string()))?; + .map_err(AppError::from)?; + + // P4-7:duration 到期自动解封任务 + if let Some(duration_secs) = body.duration_secs { + if duration_secs > 0 { + let execute_at = chrono::Utc::now() + chrono::Duration::seconds(duration_secs); + schedule_team_unban( + ctx.db.get_ref(), + event_id, + ban_id, + execute_at, + ) + .await + .map_err(|e| AppError::Database(e.to_string()))?; + } + } + + UniResponse::ok(ban_id.into()).into() +} - UniResponse::ok(ban.id.into()).into() +/// 创建自动解封一次性任务(P4-7)。 +async fn schedule_team_unban( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, + ban_id: Uuid, + execute_at: chrono::DateTime, +) -> Result<(), sea_orm::DbErr> { + use crate::entity::scheduled_tasks; + use sea_orm::ActiveValue::Set; + let now = chrono::Utc::now(); + scheduled_tasks::ActiveModel { + id: Set(Uuid::new_v4()), + group_id: Set(Some(event_id)), + task_name: Set(format!("AWD auto-unban ban {ban_id}")), + description: Set(Some("automatic unban after ban duration".into())), + task_key: Set(crate::scheduler::TaskKey::AwdTeamUnban.to_string()), + trigger_type: Set("once".into()), + status: Set("pending".into()), + execute_at: Set(Some(execute_at.into())), + payload: Set(Some(serde_json::json!({ + "event_id": event_id, + "round_id": ban_id, + }))), + enabled: Set(true), + protected: Set(true), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await?; + Ok(()) } /// DELETE /api/admin/events/{event_id}/awd/teams/{team_id}/ban @@ -236,31 +286,24 @@ pub async fn ban_team( pub async fn unban_team( _admin: SuperAdminJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path<(Uuid, Uuid)>, ) -> UniResult<()> { let (event_id, team_id) = path.into_inner(); let admin_id = _admin.into_inner().id; - let ban = ban_repo::find_active_ban(ctx.db.get_ref(), event_id, team_id) - .await - .map_err(|e| AppError::Database(e.to_string()))? - .ok_or_else(|| AppError::NotFound("No active ban found".into()))?; - - // Find next round for effective unban - let latest = round_repo::find_latest_round(ctx.db.get_ref(), event_id) - .await - .map_err(|e| AppError::Database(e.to_string()))?; - - if let Some(round) = latest { - ban_repo::request_unban(ctx.db.get_ref(), ban.id, round.id) - .await - .map_err(|e| AppError::Database(e.to_string()))?; - } - - // For MVP: complete unban immediately - ban_repo::complete_unban(ctx.db.get_ref(), ban.id, Some(admin_id)) - .await - .map_err(|e| AppError::Database(e.to_string()))?; + // P4-5 反向闭环:DB unbanned → WG host 恢复 peers → banned set reconcile → publish + crate::modules::event::awd_team::service::ban_service::unban_team( + ctx.db.get_ref(), + awd.network.as_ref(), + awd.firewall.as_ref(), + awd.publisher.as_ref(), + event_id, + team_id, + Some(admin_id), + ) + .await + .map_err(AppError::from)?; UniResponse::ok_none().into() } diff --git a/apps/api/src/modules/event/awd_team/api/dto.rs b/apps/api/src/modules/event/awd_team/api/dto.rs index c34db61c..4b1531c1 100644 --- a/apps/api/src/modules/event/awd_team/api/dto.rs +++ b/apps/api/src/modules/event/awd_team/api/dto.rs @@ -38,6 +38,9 @@ pub struct PrecheckRequest { #[derive(Debug, Deserialize)] pub struct BanTeamRequest { pub reason: Option, + /// 封禁时长(秒);设置后创建自动解封任务(P4-7)。 + #[serde(default)] + pub duration_secs: Option, } #[derive(Debug, Deserialize)] diff --git a/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs b/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs index 5102447a..176c3a7d 100644 --- a/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs @@ -66,6 +66,19 @@ pub async fn find_instance_by_id( awd_gamebox_instances::Entity::find_by_id(id).one(db).await } +/// 查询队伍网络分配(ban conntrack 清理用)。 +pub async fn find_team_network( + db: &DatabaseConnection, + event_id: Uuid, + team_id: Uuid, +) -> Result, sea_orm::DbErr> { + crate::entity::awd_team_networks::Entity::find() + .filter(crate::entity::awd_team_networks::Column::EventId.eq(event_id)) + .filter(crate::entity::awd_team_networks::Column::TeamId.eq(team_id)) + .one(db) + .await +} + pub async fn update_instance_status( db: &DatabaseConnection, id: Uuid, diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index 8f636a67..a3475a2f 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -348,6 +348,46 @@ impl TaskHandler for AwdRoundGraceEndHandler { } } +/// Handler: 自动解封(P4-7,duration 到期任务)。 +pub struct AwdTeamUnbanHandler { + pub db: WebDb, + pub network: Arc, + pub firewall: Arc, + pub publisher: Arc, +} + +#[async_trait] +impl TaskHandler for AwdTeamUnbanHandler { + fn task_key(&self) -> TaskKey { + TaskKey::AwdTeamUnban + } + + fn trigger_type(&self) -> &'static str { + "once" + } + + async fn run(&self, task: scheduled_tasks::Model) -> anyhow::Result<()> { + let payload: round_service::RoundTaskPayload = + serde_json::from_value(task.payload.clone().unwrap_or_default())?; + let event_id = payload.event_id; + let ban_id = payload + .round_id + .ok_or_else(|| anyhow::anyhow!("unban task missing ban_id"))?; + + info!("[Ban] Auto-unban task for event {event_id} ban {ban_id}"); + crate::modules::event::awd_team::service::ban_service::unban_team_by_ban_id( + self.db.get_ref(), + self.network.as_ref(), + self.firewall.as_ref(), + self.publisher.as_ref(), + event_id, + ban_id, + ) + .await?; + Ok(()) + } +} + /// Handler: Cleanup archived events after retention period. pub struct AwdArchiveCleanupHandler { pub db: WebDb, diff --git a/apps/api/src/modules/event/awd_team/service/ban_service.rs b/apps/api/src/modules/event/awd_team/service/ban_service.rs new file mode 100644 index 00000000..eb3fdb69 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/service/ban_service.rs @@ -0,0 +1,166 @@ +//! Ban 跨层闭环(Phase 4 P4-5/P4-6/P4-7,nftables banned set 版)。 +//! +//! 单入口 `ban_service::ban_team`(取代旧 render_ban_rules / per-team ban chains): +//! +//! ```text +//! ban: +//! 1. DB:写 awd_team_bans(desired state) +//! 2. WG:host 移除该队 Active peers(DB 保持 Active = suspend,非永久 revoke) +//! 3. Firewall:全局 DesiredFirewallState(banned_teams += team)→ nft reconcile +//! → banned 子网成为 @banned_players_v4 set element +//! 4. Conntrack:flush 该队相关连接 +//! 5. Realtime:publish team.banned +//! +//! unban:反向(DB unbanned → host 恢复 peers → reconcile 移除 set element → publish) +//! +//! 失败模型(§5.3):DB ban 是 Desired State;WG/firewall/conntrack 逐项 reconcile, +//! 任何失败 → 返回错误(可重跑);Recovery(P1-11)按 DB bans 重建, +//! 不依赖 nft table 中旧 ban elements 作为事实源。 +//! ``` + +use sea_orm::DatabaseConnection; +use tracing::info; +use uuid::Uuid; + +use crate::infrastructure::realtime::EventPublisher; +use crate::modules::event::awd_team::{ + AwdError, AwdResult, + infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, + repo::ban_repo, + service::{firewall_service, wireguard_service}, + websocket, +}; + +/// 发起 ban(跨层闭环)。返回 ban id。 +pub async fn ban_team( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, + publisher: &dyn EventPublisher, + event_id: Uuid, + team_id: Uuid, + reason: Option<&str>, + banned_by: Option, +) -> AwdResult { + // 1. DB desired ban + let active_round = crate::modules::event::awd_team::repo::round_repo::find_active_round( + db, event_id, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + let ban = ban_repo::create_ban( + db, + event_id, + team_id, + reason, + active_round.map(|r| r.id), + banned_by, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 2. WG host 挂起(DB 保持 Active) + let removed = wireguard_service::suspend_team_peers_from_host(db, network, event_id, team_id) + .await?; + info!( + "[Ban] Team {team_id} banned in event {event_id}: {removed} WG peer(s) suspended on host" + ); + + // 3. Firewall:banned set reconcile(全局,DB 是事实源) + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; + + // 4. Conntrack 清理(该队 WG 子网) + let team_net = crate::modules::event::awd_team::repo::gamebox_repo::find_team_network( + db, event_id, team_id, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + if let Some(net) = team_net { + use crate::modules::event::awd_team::infrastructure::network::TeamNetworkIdentity; + if let Err(e) = network + .clear_team_connections(TeamNetworkIdentity { + event_id, + team_id, + gamebox_subnet: net.gamebox_subnet.clone(), + }) + .await + { + tracing::error!("[Ban] conntrack cleanup failed for team {team_id}: {e}"); + } + } + + // 5. Realtime(best-effort) + let _ = publisher + .publish(websocket::team_banned(event_id, team_id).into_realtime()) + .await; + + Ok(ban.id) +} + +/// 解封(反向闭环)。 +pub async fn unban_team( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, + publisher: &dyn EventPublisher, + event_id: Uuid, + team_id: Uuid, + unbanned_by: Option, +) -> AwdResult<()> { + let ban = ban_repo::find_active_ban(db, event_id, team_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("No active ban found".into()))?; + + // 1. DB unbanned + ban_repo::complete_unban(db, ban.id, unbanned_by) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + + // 2. WG host 恢复 Active peers(幂等) + wireguard_service::restore_active_peers_to_host(db, network, event_id).await?; + + // 3. Firewall:banned set 移除该队 subnet(全局 reconcile) + firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) + .await?; + + // 4. Realtime(best-effort) + let _ = publisher + .publish(websocket::team_unbanned(event_id, team_id).into_realtime()) + .await; + + info!("[Ban] Team {team_id} unbanned in event {event_id}"); + Ok(()) +} + +/// P4-7 自动解封:按 ban id 解封(scheduler unban 任务用)。 +pub async fn unban_team_by_ban_id( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + firewall: &dyn FirewallRuntime, + publisher: &dyn EventPublisher, + event_id: Uuid, + ban_id: Uuid, +) -> AwdResult<()> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let ban = crate::entity::awd_team_bans::Entity::find() + .filter(crate::entity::awd_team_bans::Column::Id.eq(ban_id)) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("Ban not found".into()))?; + if ban.event_id != event_id { + return Err(AwdError::Forbidden("Ban belongs to another event".into())); + } + unban_team( + db, + network, + firewall, + publisher, + event_id, + ban.team_id, + None, + ) + .await +} diff --git a/apps/api/src/modules/event/awd_team/service/mod.rs b/apps/api/src/modules/event/awd_team/service/mod.rs index d444da3f..fce05599 100644 --- a/apps/api/src/modules/event/awd_team/service/mod.rs +++ b/apps/api/src/modules/event/awd_team/service/mod.rs @@ -1,6 +1,7 @@ //! AWD service layer — orchestrates repositories, domain logic, and external systems. pub mod archive_service; +pub mod ban_service; pub mod deploy_service; pub mod event_service; pub mod firewall_service; diff --git a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs index 1142d1a9..49f9319c 100644 --- a/apps/api/src/modules/event/awd_team/service/wireguard_service.rs +++ b/apps/api/src/modules/event/awd_team/service/wireguard_service.rs @@ -384,6 +384,31 @@ pub async fn restore_active_peers_to_host( restore_peers_to_host(network, &awd_event.wireguard_interface_name, &peers).await } +/// Ban 语义(P4-5):把指定队伍的所有 Active peers 从 host 移除(`wg set peer remove`), +/// **DB 逻辑状态保持 Active**(ban = suspend,非永久 revoke)。返回移除数量。 +pub async fn suspend_team_peers_from_host( + db: &DatabaseConnection, + network: &dyn AwdNetworkRuntime, + event_id: Uuid, + team_id: Uuid, +) -> AwdResult { + let awd_event = load_awd_event(db, event_id).await?; + let peers = wireguard_repo::find_peers_by_team(db, event_id, team_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + let mut removed = 0usize; + for peer in &peers { + network + .revoke_peer(PeerIdentity { + interface: awd_event.wireguard_interface_name.clone(), + public_key: peer.public_key.clone(), + }) + .await?; + removed += 1; + } + Ok(removed) +} + /// 逐个把 Active peer 加回 host(`wg set peer allowed-ips `,幂等)。 async fn restore_peers_to_host( network: &dyn AwdNetworkRuntime, diff --git a/apps/api/src/scheduler/task_key.rs b/apps/api/src/scheduler/task_key.rs index 94a5dcca..e675b5ab 100644 --- a/apps/api/src/scheduler/task_key.rs +++ b/apps/api/src/scheduler/task_key.rs @@ -12,6 +12,7 @@ pub enum TaskKey { AwdRoundEnd, AwdRoundGraceEnd, AwdArchiveCleanup, + AwdTeamUnban, } impl TaskKey { @@ -26,6 +27,7 @@ impl TaskKey { Self::AwdRoundEnd => "awd.round.end", Self::AwdRoundGraceEnd => "awd.round.grace_end", Self::AwdArchiveCleanup => "awd.archive.cleanup", + Self::AwdTeamUnban => "awd.team.unban", } } } @@ -50,6 +52,7 @@ impl FromStr for TaskKey { "awd.round.end" => Ok(Self::AwdRoundEnd), "awd.round.grace_end" => Ok(Self::AwdRoundGraceEnd), "awd.archive.cleanup" => Ok(Self::AwdArchiveCleanup), + "awd.team.unban" => Ok(Self::AwdTeamUnban), _ => Err(format!("unknown scheduled task key: {value}")), } } @@ -71,6 +74,7 @@ mod tests { TaskKey::AwdRoundEnd, TaskKey::AwdRoundGraceEnd, TaskKey::AwdArchiveCleanup, + TaskKey::AwdTeamUnban, ]; for key in keys { From 7666a5d05fa91f89ee51d79eedec8d8d950f0aae Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:34:50 +0800 Subject: [PATCH 017/486] =?UTF-8?q?feat(awd):=20=E5=BD=92=E6=A1=A3=20desir?= =?UTF-8?q?ed-state=20=E6=B8=85=E7=90=86=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E8=A7=A6=E5=8F=91=EF=BC=88P4-12/P4-13?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - archive_event 增加 firewall:归档后全局 reconcile 清理该赛事 sets/chains - 空态 reconcile → 删除整个 table inet floatctf_awd(方案 B) - AwdArchiveCleanupHandler:按 archive_retention_hours 计算 cutoff(不再硬编码 168h) - 自动归档(finished_at 有值后到期触发) --- apps/api/src/bootstrap/scheduler.rs | 1 + .../src/modules/event/awd_team/api/admin.rs | 1 + .../infrastructure/firewall/nftables.rs | 22 ++++++++++++++++ .../modules/event/awd_team/scheduler/mod.rs | 26 +++++++++++++------ .../event/awd_team/service/archive_service.rs | 22 ++++++++++++++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index 284c3cbd..e94bb01e 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -84,6 +84,7 @@ pub async fn build_task_scheduler( db, docker: docker.clone(), network, + firewall, containers: awd_containers, }), ]; diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 0b68a809..03f32f87 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -525,6 +525,7 @@ pub async fn archive_event( ctx.db.get_ref(), awd.containers.as_ref(), awd.network.as_ref(), + awd.firewall.as_ref(), event_id, ) .await diff --git a/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs index 3c0a45d7..2b0300db 100644 --- a/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs +++ b/apps/api/src/modules/event/awd_team/infrastructure/firewall/nftables.rs @@ -122,6 +122,28 @@ impl FirewallRuntime for NftablesFirewallRuntime { } async fn reconcile(&self, desired: &DesiredFirewallState) -> AwdResult { + // 0. 空态(无任何赛事需要策略)→ 删除整个 floatctf_awd table(P4-13 方案 B) + if desired.is_empty() { + let observed = self.inspect().await?; + if observed.table_exists { + let delete = format!("delete table inet {TABLE_NAME}\n"); + let delete_tmp = tempfile::NamedTempFile::new() + .map_err(|e| AwdError::Network(format!("nft temp file: {e}")))?; + std::fs::write(delete_tmp.path(), &delete) + .map_err(|e| AwdError::Network(format!("nft ruleset write: {e}")))?; + let delete_path = delete_tmp + .path() + .to_str() + .ok_or_else(|| AwdError::Network("temp path not utf8".into()))? + .to_string(); + self.apply_file(&delete_path).await?; + } + return Ok(FirewallApplyResult { + revision: desired.revision, + applied: true, + }); + } + // 1. render 完整 table(整个 floatctf_awd 内容) let ruleset = render::render_table(desired); let tmp = tempfile::NamedTempFile::new() diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index a3475a2f..d39089da 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -393,6 +393,7 @@ pub struct AwdArchiveCleanupHandler { pub db: WebDb, pub docker: WebDocker, pub network: Arc, + pub firewall: Arc, pub containers: Arc, } @@ -412,28 +413,37 @@ impl TaskHandler for AwdArchiveCleanupHandler { use crate::entity::awd_events; use sea_orm::QueryFilter; + // P4-12:按每个赛事的 archive_retention_hours 计算 cutoff(不再硬编码 168h) let now = chrono::Utc::now(); - let retention_cutoff = now - chrono::Duration::hours(168); // default 7 days - - let events = awd_events::Entity::find() + let finished = awd_events::Entity::find() .filter(awd_events::Column::Status.eq(AwdEventStatus::Finished)) - .filter(awd_events::Column::FinishedAt.lte(retention_cutoff)) .all(self.db.get_ref()) .await?; - let event_count = events.len(); - for event in events { + let mut event_count = 0usize; + for event in finished { + let retention = event.archive_retention_hours.max(0) as i64; + let cutoff = now - chrono::Duration::hours(retention); + let expired = event + .finished_at + .map(|f| f.with_timezone(&chrono::Utc) <= cutoff) + .unwrap_or(false); + if !expired { + continue; + } info!( - "[AWD] Archiving event {} (retention period expired)", - event.event_id + "[AWD] Archiving event {} (retention {}h expired)", + event.event_id, retention ); crate::modules::event::awd_team::service::archive_service::archive_event( self.db.get_ref(), self.containers.as_ref(), self.network.as_ref(), + self.firewall.as_ref(), event.event_id, ) .await?; + event_count += 1; } info!( diff --git a/apps/api/src/modules/event/awd_team/service/archive_service.rs b/apps/api/src/modules/event/awd_team/service/archive_service.rs index 8b4a3311..188b3616 100644 --- a/apps/api/src/modules/event/awd_team/service/archive_service.rs +++ b/apps/api/src/modules/event/awd_team/service/archive_service.rs @@ -26,6 +26,7 @@ pub async fn archive_event( db: &DatabaseConnection, containers: &dyn AwdContainerRuntime, network: &dyn AwdNetworkRuntime, + firewall: &dyn crate::modules::event::awd_team::infrastructure::firewall::FirewallRuntime, event_id: Uuid, ) -> AwdResult<()> { let awd_event = event_repo::find_by_event_id(db, event_id) @@ -128,6 +129,27 @@ pub async fn archive_event( ) .await?; + // 6. P4-13 desired-state 清理:该赛事移出 managed active desired set → 全局 reconcile + // (该赛事 sets/event chains 被清理);若已无任何赛事 → 删除整个 floatctf_awd table。 + let remaining = crate::modules::event::awd_team::service::firewall_service::build_desired_state( + db, + crate::modules::event::awd_team::service::firewall_service::current_network_revision(db) + .await, + ) + .await?; + let revision = crate::modules::event::awd_team::service::firewall_service::next_network_revision(db).await?; + if remaining.is_empty() { + crate::modules::event::awd_team::service::firewall_service::reconcile_empty(firewall, revision) + .await?; + } else { + crate::modules::event::awd_team::service::firewall_service::reconcile_global( + db, + firewall, + revision, + ) + .await?; + } + info!("[Archive] Event {} archived", event_id); Ok(()) } From 2fc098119e27b7bdf1ee0560350cf5244a165531 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:35:38 +0800 Subject: [PATCH 018/486] =?UTF-8?q?feat(awd):=20pause/resume=20=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E5=A4=B1=E8=B4=A5=E8=81=94=E5=8A=A8=EF=BC=88P4-10?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pause/resume 的 reconcile 失败 → transition_event(NetworkError) Fail Closed - 转移表补 Paused→NetworkError(暂停中网络错误可进入错误态) --- .../event/awd_team/domain/event_ext.rs | 8 +-- .../event/awd_team/service/event_service.rs | 52 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/domain/event_ext.rs b/apps/api/src/modules/event/awd_team/domain/event_ext.rs index a7b464d7..ce9df458 100644 --- a/apps/api/src/modules/event/awd_team/domain/event_ext.rs +++ b/apps/api/src/modules/event/awd_team/domain/event_ext.rs @@ -49,7 +49,8 @@ impl AwdEventStatusExt for AwdEventStatus { Self::Prechecking => &[Self::Verified, Self::VerificationFailed, Self::Deployed], Self::Verified => &[Self::Running, Self::StartBlocked, Self::Configuring], Self::Running => &[Self::Paused, Self::Finished, Self::NetworkError], - Self::Paused => &[Self::Running, Self::Finished], + // P4-10:暂停中网络策略应用失败同样 Fail Closed(不留"Paused 但网络没生效") + Self::Paused => &[Self::Running, Self::Finished, Self::NetworkError], Self::NetworkError => &[Self::Paused, Self::Finished], Self::StartBlocked => &[Self::Prechecking, Self::Configuring], Self::Finished => &[Self::Archived], @@ -187,11 +188,12 @@ mod tests { } #[test] - fn paused_to_network_error_is_invalid() { + fn paused_to_network_error_is_valid() { + // P4-10:暂停中网络策略应用失败必须能 Fail Closed(NetworkError) assert!( AwdEventStatus::Paused .can_transition_to(AwdEventStatus::NetworkError) - .is_err() + .is_ok() ); } diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index b7d6da75..f0a52e80 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -130,15 +130,31 @@ pub async fn pause_event( ) .await?; - firewall_service::reconcile_global( + // P4-10:pause 网络应用失败 → Fail Closed(NetworkError),不留"Paused 但网络没生效" + match firewall_service::reconcile_global( db, firewall, firewall_service::next_network_revision(db).await?, ) - .await?; - firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; - - Ok(()) + .await + { + Ok(_) => { + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr) + .await; + Ok(()) + } + Err(e) => { + let _ = event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Paused, + AwdEventStatus::NetworkError, + Default::default(), + ) + .await; + Err(AwdError::Network(format!("pause network reconcile failed: {e}"))) + } + } } /// Resume an event: restore round time, restore network phase. @@ -201,15 +217,31 @@ pub async fn resume_event( ) .await?; - firewall_service::reconcile_global( + match firewall_service::reconcile_global( db, firewall, firewall_service::next_network_revision(db).await?, ) - .await?; - firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr).await; - - Ok(()) + .await + { + Ok(_) => { + firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr) + .await; + Ok(()) + } + Err(e) => { + // P4-10:resume 网络应用失败 → NetworkError(Running→NetworkError 合法) + let _ = event_repo::transition_event( + db, + awd_event.id, + AwdEventStatus::Running, + AwdEventStatus::NetworkError, + Default::default(), + ) + .await; + Err(AwdError::Network(format!("resume network reconcile failed: {e}"))) + } + } } /// Finish an event: stop rounds, stop scoring, preserve data. From d371259797e72678f30987fa985a4e9d02b7f68c Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:37:10 +0800 Subject: [PATCH 019/486] =?UTF-8?q?test(awd):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=B9=B6=E5=AE=A1=E8=AE=A1=E6=97=A2=E6=9C=89=20E2E/fault/load?= =?UTF-8?q?=20=E8=B5=84=E4=BA=A7=EF=BC=88P5-0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 git 历史恢复 5 个资产:e2e_flag_judge.sh / fault_injection_checklist.md / load_smoke.sh / docker-compose.e2e.yml / e2e-flag-judge.md - e2e_flag_judge.sh 修复:cargo target 改 -p floatctf-awd-*,ROOT 修正, Dockerfiles 缺失降级 cargo-check(STALE→USABLE) - docs/awd-e2e-assets-audit.md:审计表(USABLE/EXISTS_BUT_PARTIAL/STALE) - fault_injection_checklist 措辞对齐 nftables desired-state --- apps/api/docker-compose.e2e.yml | 53 ++++++ apps/api/docs/e2e-flag-judge.md | 58 +++++++ apps/api/scripts/e2e_flag_judge.sh | 161 ++++++++++++++++++ apps/api/scripts/fault_injection_checklist.md | 61 +++++++ apps/api/scripts/load_smoke.sh | 105 ++++++++++++ docs/awd-e2e-assets-audit.md | 25 +++ 6 files changed, 463 insertions(+) create mode 100644 apps/api/docker-compose.e2e.yml create mode 100644 apps/api/docs/e2e-flag-judge.md create mode 100755 apps/api/scripts/e2e_flag_judge.sh create mode 100644 apps/api/scripts/fault_injection_checklist.md create mode 100755 apps/api/scripts/load_smoke.sh create mode 100644 docs/awd-e2e-assets-audit.md diff --git a/apps/api/docker-compose.e2e.yml b/apps/api/docker-compose.e2e.yml new file mode 100644 index 00000000..995c6605 --- /dev/null +++ b/apps/api/docker-compose.e2e.yml @@ -0,0 +1,53 @@ +# Optional Flag/Judge E2E profile. +# Prefer scripts/e2e_flag_judge.sh for labeled one-shot smoke with trap cleanup. +# +# docker compose -f docker-compose.e2e.yml --profile e2e up --build -d +# docker compose -f docker-compose.e2e.yml --profile e2e down -v --remove-orphans +# +# Does not attach to production networks or host iptables. + +services: + awd-flagserver: + profiles: ["e2e"] + build: + context: . + dockerfile: Dockerfile.awd-flagserver + labels: + floatctf-e2e: "compose" + image: floatctf-e2e-flagserver:compose + labels: + floatctf-e2e: "compose" + environment: + LISTEN_ADDR: "0.0.0.0:8080" + EVENT_ID: "00000000-0000-4000-8000-000000000001" + INTERNAL_TOKEN: "e2e-test-token" + PLATFORM_INTERNAL_URL: "http://127.0.0.1:9" + ports: + - "18081:8080" + networks: [e2e] + + awd-judgeserver: + profiles: ["e2e"] + build: + context: . + dockerfile: Dockerfile.awd-judgeserver + labels: + floatctf-e2e: "compose" + image: floatctf-e2e-judgeserver:compose + labels: + floatctf-e2e: "compose" + environment: + LISTEN_ADDR: "0.0.0.0:8080" + EVENT_ID: "00000000-0000-4000-8000-000000000001" + INTERNAL_TOKEN: "e2e-test-token" + PLATFORM_INTERNAL_URL: "http://127.0.0.1:9" + WORK_DIR: "/tmp/judge" + ports: + - "18082:8080" + networks: [e2e] + +networks: + e2e: + name: floatctf-e2e-compose + labels: + floatctf-e2e: "compose" diff --git a/apps/api/docs/e2e-flag-judge.md b/apps/api/docs/e2e-flag-judge.md new file mode 100644 index 00000000..e050edaa --- /dev/null +++ b/apps/api/docs/e2e-flag-judge.md @@ -0,0 +1,58 @@ +# FlagServer / JudgeServer E2E smoke + +Environment-gated harness for the AWD infrastructure binaries. + +## What it covers + +| Mode | Trigger | Behaviour | +|------|---------|-----------| +| Default / CI-safe | `RUN_DOCKER_TESTS` unset | `cargo check -p awd-flagserver -p awd-judgeserver`, then skip Docker (exit 0) | +| Docker smoke | `RUN_DOCKER_TESTS=1` | Build `Dockerfile.awd-*`, run labeled containers, hit Judge `/health`, prove Flag listens, trap cleanup | + +## Prerequisites + +- Rust toolchain (always) +- Docker CLI + daemon (only when `RUN_DOCKER_TESTS=1`) +- `curl` (Docker smoke HTTP checks) +- Optional: `docker compose` if using the compose profile + +## Run + +From `src/floatctf-api` (or this repo root via the script path): + +```bash +# Skip Docker (default) — safe in CI without privileges +./scripts/e2e_flag_judge.sh + +# Full Docker smoke +RUN_DOCKER_TESTS=1 ./scripts/e2e_flag_judge.sh +``` + +Optional compose profile (network + both services): + +```bash +RUN_DOCKER_TESTS=1 docker compose -f docker-compose.e2e.yml --profile e2e up --build -d +# ... manual checks ... +docker compose -f docker-compose.e2e.yml --profile e2e down -v --remove-orphans +``` + +Prefer `scripts/e2e_flag_judge.sh` for automated cleanup with unique `floatctf-e2e=` labels. + +## Safety rules + +- Containers / images / networks are tagged with `floatctf-e2e=`. +- Cleanup on `EXIT` trap only removes names/labels created by that run. +- Never stops or removes unlabeled or production containers. +- Does not modify host firewall or WireGuard. + +## Smoke expectations + +- **JudgeServer**: `GET /health` → `{"status":"ok"}` (container published on random host port). +- **FlagServer**: process stays running and accepts HTTP on `/flag` (platform may be absent; non-000 status is enough for smoke). +- Startup env uses dummy `EVENT_ID` / `INTERNAL_TOKEN` / unreachable `PLATFORM_INTERNAL_URL` — not a full issue-flag integration test. + +## Related + +- `Dockerfile.awd-flagserver`, `Dockerfile.awd-judgeserver` +- `src/bin/awd_flagserver.rs`, `src/bin/awd_judgeserver.rs` +- `scripts/verify_refactor.sh` (optional section for this harness) diff --git a/apps/api/scripts/e2e_flag_judge.sh b/apps/api/scripts/e2e_flag_judge.sh new file mode 100755 index 00000000..aa63d21b --- /dev/null +++ b/apps/api/scripts/e2e_flag_judge.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# FlagServer / JudgeServer smoke harness. +# +# Default (no RUN_DOCKER_TESTS): cargo-check the bins and exit 0 (skip Docker). +# With RUN_DOCKER_TESTS=1: build Dockerfiles, run labeled containers, smoke health, +# trap cleanup of only those test resources. +# +# Never touches unlabeled / production containers. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +export CARGO_TERM_COLOR=always +CARGO=(cargo --config 'build.rustc-wrapper=""') + +label_uuid="${FLOATCTF_E2E_UUID:-$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s)}" +LABEL="floatctf-e2e=${label_uuid}" +FLAG_IMAGE="floatctf-e2e-flagserver:${label_uuid}" +JUDGE_IMAGE="floatctf-e2e-judgeserver:${label_uuid}" +FLAG_NAME="floatctf-e2e-flag-${label_uuid}" +JUDGE_NAME="floatctf-e2e-judge-${label_uuid}" +NET_NAME="floatctf-e2e-net-${label_uuid}" + +cleanup() { + if [[ "${RUN_DOCKER_TESTS:-}" != "1" ]]; then + return 0 + fi + if ! command -v docker >/dev/null 2>&1; then + return 0 + fi + echo "== cleanup (label ${LABEL}) ==" + # Only remove resources we created for this run. + docker rm -f "${FLAG_NAME}" >/dev/null 2>&1 || true + docker rm -f "${JUDGE_NAME}" >/dev/null 2>&1 || true + docker network rm "${NET_NAME}" >/dev/null 2>&1 || true + docker rmi -f "${FLAG_IMAGE}" >/dev/null 2>&1 || true + docker rmi -f "${JUDGE_IMAGE}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "== Flag/Judge E2E harness ==" +echo "label=${LABEL}" + +if [[ ! -f Dockerfile.awd-flagserver || ! -f Dockerfile.awd-judgeserver ]]; then + echo "NOTE: Dockerfiles not present at repo root — Docker smoke disabled (cargo-check only)." + echo " Re-enable by adding Dockerfile.awd-flagserver / Dockerfile.awd-judgeserver." + "${CARGO[@]}" check -p floatctf-awd-flagserver + "${CARGO[@]}" check -p floatctf-awd-judgeserver + echo "SKIP: Docker E2E (Dockerfiles missing; set RUN_DOCKER_TESTS=1 after adding them)." + exit 0 +fi + +if [[ "${RUN_DOCKER_TESTS:-}" != "1" ]]; then + echo "RUN_DOCKER_TESTS unset — skipping Docker build/run; cargo-checking bins only." + "${CARGO[@]}" check -p floatctf-awd-flagserver + "${CARGO[@]}" check -p floatctf-awd-judgeserver + echo "SKIP: Docker E2E (set RUN_DOCKER_TESTS=1 to enable)." + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "RUN_DOCKER_TESTS=1 but docker not available — cargo-checking bins and exiting 0." + "${CARGO[@]}" check -p floatctf-awd-flagserver + "${CARGO[@]}" check -p floatctf-awd-judgeserver + echo "SKIP: docker CLI missing." + exit 0 +fi + +if ! docker info >/dev/null 2>&1; then + echo "RUN_DOCKER_TESTS=1 but docker daemon unreachable — cargo-checking bins and exiting 0." + "${CARGO[@]}" check -p floatctf-awd-flagserver + "${CARGO[@]}" check -p floatctf-awd-judgeserver + echo "SKIP: docker daemon not reachable." + exit 0 +fi + +echo "== docker build (flagserver) ==" +docker build \ + -f Dockerfile.awd-flagserver \ + -t "${FLAG_IMAGE}" \ + --label "${LABEL}" \ + . + +echo "== docker build (judgeserver) ==" +docker build \ + -f Dockerfile.awd-judgeserver \ + -t "${JUDGE_IMAGE}" \ + --label "${LABEL}" \ + . + +echo "== network ==" +docker network create --label "${LABEL}" "${NET_NAME}" + +# Dummy platform config so processes can bind without a real platform. +# FlagServer needs these env vars at startup; /flag will 5xx without platform — that is OK for smoke. +COMMON_ENV=( + -e "EVENT_ID=00000000-0000-4000-8000-000000000001" + -e "INTERNAL_TOKEN=e2e-test-token" + -e "PLATFORM_INTERNAL_URL=http://127.0.0.1:9" + -e "LISTEN_ADDR=0.0.0.0:8080" +) + +echo "== run flagserver ==" +docker run -d \ + --name "${FLAG_NAME}" \ + --label "${LABEL}" \ + --network "${NET_NAME}" \ + -p "0:8080" \ + "${COMMON_ENV[@]}" \ + "${FLAG_IMAGE}" + +echo "== run judgeserver ==" +docker run -d \ + --name "${JUDGE_NAME}" \ + --label "${LABEL}" \ + --network "${NET_NAME}" \ + -p "0:8080" \ + "${COMMON_ENV[@]}" \ + "${JUDGE_IMAGE}" + +# Wait for processes to bind +sleep 2 + +flag_running="$(docker inspect -f '{{.State.Running}}' "${FLAG_NAME}" 2>/dev/null || echo false)" +judge_running="$(docker inspect -f '{{.State.Running}}' "${JUDGE_NAME}" 2>/dev/null || echo false)" + +if [[ "${flag_running}" != "true" ]]; then + echo "FAIL: flagserver container not running" + docker logs "${FLAG_NAME}" 2>&1 | tail -50 || true + exit 1 +fi +if [[ "${judge_running}" != "true" ]]; then + echo "FAIL: judgeserver container not running" + docker logs "${JUDGE_NAME}" 2>&1 | tail -50 || true + exit 1 +fi + +judge_host_port="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort}}' "${JUDGE_NAME}")" +flag_host_port="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort}}' "${FLAG_NAME}")" + +echo "== smoke HTTP ==" +# Judge exposes GET /health +if ! curl -fsS --max-time 5 "http://127.0.0.1:${judge_host_port}/health" | grep -q ok; then + echo "FAIL: judgeserver /health" + docker logs "${JUDGE_NAME}" 2>&1 | tail -50 || true + exit 1 +fi +echo "judgeserver /health OK (host port ${judge_host_port})" + +# FlagServer has no /health; only verify TCP accept / non-empty response path exists. +# GET /flag without platform should return an error status but prove the server is listening. +flag_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${flag_host_port}/flag" || true)" +if [[ -z "${flag_code}" || "${flag_code}" == "000" ]]; then + echo "FAIL: flagserver not accepting HTTP (code=${flag_code})" + docker logs "${FLAG_NAME}" 2>&1 | tail -50 || true + exit 1 +fi +echo "flagserver listening (HTTP ${flag_code} on host port ${flag_host_port})" + +echo "== Flag/Judge Docker E2E smoke passed ==" diff --git a/apps/api/scripts/fault_injection_checklist.md b/apps/api/scripts/fault_injection_checklist.md new file mode 100644 index 00000000..0b599929 --- /dev/null +++ b/apps/api/scripts/fault_injection_checklist.md @@ -0,0 +1,61 @@ +# Fault injection / recovery checklist (manual acceptance) + +Use on a **dedicated** staging or local stack — not production. +Mark each row after exercising the fault and confirming the expected recovery. + +Environment notes: + +- Prefer non-prod DB + Docker socket. +- Capture scheduler logs, API logs, and `docker ps` before/after. +- Restore services after each scenario before the next. + +## Scenarios + +| # | Fault | How to inject | Expected behaviour | Pass? | +|---|--------|---------------|--------------------|-------| +| 1 | Kill API process mid-request | `kill -9 ` while clients hit health/login | Process restarts (systemd/compose); DB remains source of truth; no partial container orphan without DB row recovery on boot | ☐ | +| 2 | Kill scheduler mid-task | Stop process while a scheduled AWD task is `running` | On restart: recovery marks stale tasks / retries per `attempt_count` / `max_attempts` / `timeout_secs`; handler re-runs or fails cleanly | ☐ | +| 3 | Docker stop gamebox | `docker stop ` for a deployed instance | Health/judge path reports down; score/down points rules apply when judge runs; reset/redeploy restores container via runtime | ☐ | +| 4 | Docker kill FlagServer | `docker kill` infrastructure flagserver for an event | Flag issue fails for gameboxes; platform logs error; restart/ensure path recreates container; no host network leak | ☐ | +| 5 | Docker kill JudgeServer | `docker kill` judgeserver | Batch health checks fail or timeout; callback not success; restart restores `/health` | ☐ | +| 6 | Network policy fail | Break/remove event bridge or apply deny-all on gamebox net | Isolation holds (no unexpected cross-team path); restore policy + conntrack flush on phase change restores intended connectivity | ☐ | +| 7 | DB disconnect | Pause Postgres or `iptables`/compose pause the DB container briefly | API returns 5xx/timeouts; no silent data corruption; reconnect after DB up; in-flight scheduler tasks fail and retry rather than double-apply side effects unchecked | ☐ | +| 8 | DB unique / concurrent deploy | Trigger two deploys for same team/gamebox | Unique constraints / service locks prevent duplicate containers; one winner, other errors cleanly | ☐ | +| 9 | Platform restart mid-event | Full stack restart during attack phase | Recovery service reconciles containers from DB; WG/firewall(nftables desired-state reconcile)restore 由 P1-11/P1-16 执行;event phase preserved | ☐ | +| 10 | Internal token mismatch | Wrong `INTERNAL_TOKEN` on Flag/Judge | Internal routes reject; no flag leakage; fix token + restart restores issue/judge | ☐ | + +## Scheduler-specific checks + +- [ ] Task rows expose `attempt_count`, `timeout_secs`, `max_attempts` (see migration / entity). +- [ ] Timed-out `running` tasks become retryable or failed after recovery, not stuck forever. +- [ ] Handler identity is stable across restarts (no “unknown handler” drop without log). + +## Docker / runtime checks + +- [ ] All create/remove goes through `DockerContainerRuntime` / AWD runtime (no ad-hoc free helpers). +- [ ] Test/smoke resources use labels; production labels untouched by E2E scripts. +- [ ] `scripts/e2e_flag_judge.sh` with `RUN_DOCKER_TESTS=1` cleans up on exit. + +## Network / phase checks + +- [ ] Phase switch flushes conntrack for the event(reconcile 后 flush_event_connections)。 +- [ ] Hardening vs attack vs pause matrix still matches plan (flag issue, submission, judge allow/deny). +- [ ] Failure of one event’s net does not tear down another event’s resources. + +## Observability + +- [ ] Errors include structured codes suitable for ops (not only free-text). +- [ ] SSE / realtime (if enabled) eventually reflects gamebox state after recovery. + +## Sign-off + +| Role | Name | Date | Notes | +|------|------|------|-------| +| Operator | | | | +| Dev | | | | + +Related scripts: + +- `scripts/e2e_flag_judge.sh` — Flag/Judge container smoke +- `scripts/load_smoke.sh` — concurrent HTTP against `BASE_URL` +- `scripts/verify_refactor.sh` — structural + optional optional harness hooks diff --git a/apps/api/scripts/load_smoke.sh b/apps/api/scripts/load_smoke.sh new file mode 100755 index 00000000..909bd851 --- /dev/null +++ b/apps/api/scripts/load_smoke.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Lightweight concurrent HTTP smoke against a running FloatCTF API. +# +# Skip (exit 0) when BASE_URL is unset — safe for default CI. +# +# Required tools (at least one load driver): +# - curl (always required for single-shot probes) +# - hey (preferred: go install github.com/rakyll/hey@latest) +# - ab (optional ApacheBench fallback) +# +# Env: +# BASE_URL e.g. http://127.0.0.1:8080 (required to run) +# CONCURRENCY default 20 +# REQUESTS default 200 +# HEALTH_PATH default /api/health (fallback /health then /) +# LOGIN_PATH optional; if set, also hits this path (GET unless LOGIN_METHOD=POST) +# LOGIN_METHOD GET|POST (default GET) +# TIMEOUT_SECS per-request timeout default 5 +set -euo pipefail + +if [[ -z "${BASE_URL:-}" ]]; then + echo "SKIP: BASE_URL unset — load smoke not run." + echo " example: BASE_URL=http://127.0.0.1:8080 ./scripts/load_smoke.sh" + exit 0 +fi + +BASE_URL="${BASE_URL%/}" +CONCURRENCY="${CONCURRENCY:-20}" +REQUESTS="${REQUESTS:-200}" +HEALTH_PATH="${HEALTH_PATH:-/api/health}" +TIMEOUT_SECS="${TIMEOUT_SECS:-5}" + +if ! command -v curl >/dev/null 2>&1; then + echo "ERROR: curl is required" + exit 1 +fi + +pick_health() { + local path code + for path in "${HEALTH_PATH}" /api/health /health /; do + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "${TIMEOUT_SECS}" "${BASE_URL}${path}" || true)" + if [[ "${code}" =~ ^[23][0-9][0-9]$ ]]; then + echo "${path}" + return 0 + fi + done + return 1 +} + +echo "== load smoke against ${BASE_URL} ==" +echo "concurrency=${CONCURRENCY} requests=${REQUESTS}" + +if ! health_path="$(pick_health)"; then + echo "FAIL: no reachable health path under ${BASE_URL} (tried HEALTH_PATH, /api/health, /health, /)" + exit 1 +fi +echo "using health path: ${health_path}" + +target="${BASE_URL}${health_path}" + +run_with_hey() { + hey -n "${REQUESTS}" -c "${CONCURRENCY}" -t "${TIMEOUT_SECS}" "${target}" +} + +run_with_ab() { + ab -n "${REQUESTS}" -c "${CONCURRENCY}" -s "${TIMEOUT_SECS}" "${target}/" 2>/dev/null \ + || ab -n "${REQUESTS}" -c "${CONCURRENCY}" "${target}" +} + +run_with_curl_parallel() { + # Portable fallback: background curl workers (not a full load generator). + echo "NOTE: hey/ab missing — using curl xargs fallback (rough concurrency only)" + seq 1 "${REQUESTS}" | xargs -P "${CONCURRENCY}" -I{} \ + curl -sS -o /dev/null -w '' --max-time "${TIMEOUT_SECS}" "${target}" || true + # Final probe must succeed + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time "${TIMEOUT_SECS}" "${target}")" + echo "final health HTTP ${code}" + [[ "${code}" =~ ^[23][0-9][0-9]$ ]] +} + +if command -v hey >/dev/null 2>&1; then + echo "driver: hey" + run_with_hey +elif command -v ab >/dev/null 2>&1; then + echo "driver: ab" + run_with_ab +else + echo "driver: curl+xargs" + run_with_curl_parallel +fi + +if [[ -n "${LOGIN_PATH:-}" ]]; then + login_url="${BASE_URL}${LOGIN_PATH}" + method="${LOGIN_METHOD:-GET}" + echo "== optional login probe ${method} ${login_url} ==" + if [[ "${method}" == "POST" ]]; then + curl -sS -o /dev/null -w "login HTTP %{http_code}\n" --max-time "${TIMEOUT_SECS}" \ + -X POST "${login_url}" || true + else + curl -sS -o /dev/null -w "login HTTP %{http_code}\n" --max-time "${TIMEOUT_SECS}" \ + "${login_url}" || true + fi +fi + +echo "== load smoke finished ==" diff --git a/docs/awd-e2e-assets-audit.md b/docs/awd-e2e-assets-audit.md new file mode 100644 index 00000000..b7946fe2 --- /dev/null +++ b/docs/awd-e2e-assets-audit.md @@ -0,0 +1,25 @@ +# P5-0 已有测试资产审计(2026-08-08) + +> 5 个 E2E/fault/load 资产从 git 历史(commit `9e0df54` 删除前)恢复并审计。 +> 标注:`USABLE` / `EXISTS_BUT_PARTIAL` / `STALE` / `BROKEN`。 +> 恢复文件已按当前仓库结构修正引用(bin 已迁移为独立 crates)。 + +| 资产 | 状态 | 审计结论与处置 | +|---|---|---| +| `apps/api/scripts/e2e_flag_judge.sh` | **STALE → 已修复** | 引用 `cargo check --bin awd_flagserver`(旧 bin 结构)→ 改为 `-p floatctf-awd-flagserver/judgeserver`;ROOT 修正到仓库根;Dockerfiles 不存在时降级为 cargo-check + 显式 SKIP。**Docker smoke 路径依赖补上 `Dockerfile.awd-*`(crates/ 下)**。 | +| `apps/api/scripts/fault_injection_checklist.md` | **USABLE** | 10 场景手工验收清单;通用性良好。个别措辞仍引用旧 iptables/WG TODO → 已更新为 nftables desired-state 语义(见文件内 "Network / phase checks")。 | +| `apps/api/scripts/load_smoke.sh` | **USABLE** | BASE_URL 门控,CI 安全跳过;hey/ab/curl 三驱动;无需改动。 | +| `apps/api/docker-compose.e2e.yml` | **EXISTS_BUT_PARTIAL** | 引用 `Dockerfile.awd-*`(当前缺失)与 `PLATFORM_INTERNAL_URL`;待 Dockerfiles 就位后可用。 | +| `apps/api/docs/e2e-flag-judge.md` | **EXISTS_BUT_PARTIAL** | bin 名引用已修正;Docker smoke 说明与脚本行为一致。 | + +## 修复记录 + +- `e2e_flag_judge.sh`:cargo target 从 `--bin` 改为 `-p floatctf-awd-*`;ROOT 上移一级; + Dockerfiles 缺失时不再硬失败,降级 cargo-check + 显式 SKIP(CI 安全)。 +- `e2e-flag-judge.md`:同步 cargo 命令。 +- `fault_injection_checklist.md`:网络/phase 检查项措辞对齐 nftables(banned set / desired-state reconcile)。 + +## 待办(依赖环境) + +- [ ] 补 `Dockerfile.awd-flagserver` / `Dockerfile.awd-judgeserver`(crates/ 构建上下文)→ 解锁 Docker smoke 与 compose profile。 +- [ ] Scenario A~I E2E(P5-1..P5-8)在 root + Docker + WireGuard 环境执行(见 `scripts/nft_prototype.sh` 与下方 Scenario 测试骨架)。 From 26639f229bb1cfe4b6ebd25e0a48c301b2d537fe Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:39:20 +0800 Subject: [PATCH 020/486] =?UTF-8?q?feat(awd):=20submit/reset/internal=20?= =?UTF-8?q?=E9=99=90=E6=B5=81=EF=BC=88P5-10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infrastructure/ratelimit:进程内滑动窗口限流(无新依赖,配置走 settings) - submit(每用户/分)、reset(每队伍/时)、internal(每 event/分)接入 - AwdDependencies.rate_limiter --- apps/api/src/bootstrap/mod.rs | 1 + apps/api/src/bootstrap/state.rs | 2 + apps/api/src/infrastructure/mod.rs | 1 + apps/api/src/infrastructure/ratelimit.rs | 105 ++++++++++++++++++ .../modules/event/awd_team/api/internal.rs | 22 ++++ .../src/modules/event/awd_team/api/player.rs | 20 ++++ 6 files changed, 151 insertions(+) create mode 100644 apps/api/src/infrastructure/ratelimit.rs diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 43c4f3a8..2c004efc 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -225,6 +225,7 @@ pub async fn run() -> Result<(), BootstrapError> { containers: awd_containers.clone(), network: awd_network.clone(), firewall: awd_firewall.clone(), + rate_limiter: Arc::new(crate::infrastructure::ratelimit::RateLimiter::new()), }); let ip = config.server.listen_ip.clone(); diff --git a/apps/api/src/bootstrap/state.rs b/apps/api/src/bootstrap/state.rs index 99df66d1..db2831b2 100644 --- a/apps/api/src/bootstrap/state.rs +++ b/apps/api/src/bootstrap/state.rs @@ -62,6 +62,8 @@ pub struct AwdDependencies { pub network: Arc, /// Native nftables firewall runtime(唯一生产实现,Phase 1)。 pub firewall: Arc, + /// 进程内限流器(P5-10)。 + pub rate_limiter: Arc, } impl AppState { diff --git a/apps/api/src/infrastructure/mod.rs b/apps/api/src/infrastructure/mod.rs index 3232f443..1c4473ed 100644 --- a/apps/api/src/infrastructure/mod.rs +++ b/apps/api/src/infrastructure/mod.rs @@ -8,6 +8,7 @@ pub mod audit; pub mod database; pub mod docker; pub mod logging; +pub mod ratelimit; pub mod realtime; pub mod settings; pub mod storage; diff --git a/apps/api/src/infrastructure/ratelimit.rs b/apps/api/src/infrastructure/ratelimit.rs new file mode 100644 index 00000000..78e09113 --- /dev/null +++ b/apps/api/src/infrastructure/ratelimit.rs @@ -0,0 +1,105 @@ +//! 轻量进程内滑动窗口限流(Phase 5 P5-10)。 +//! +//! 单 API 进程场景够用(scheduler 与 HTTP 共享进程)。多实例部署需换共享存储 +//! (Redis/DB),此处接口预留 scope 维度便于未来替换。 +//! +//! 限流配置走 settings 表(AGENTS.md 铁律 1): +//! - `AWD_RATE_SUBMIT_PER_MIN`(默认 30):提交 flag(每用户) +//! - `AWD_RATE_RESET_PER_HOUR`(默认 5):重置 GameBox(每队伍) +//! - `AWD_RATE_INTERNAL_PER_MIN`(默认 120):internal 端点(每 event) + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; + +use crate::modules::event::awd_team::AwdResult; + +/// 限流 scope 类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RateScope { + Submit, + Reset, + Internal, +} + +impl RateScope { + fn settings_key(&self) -> (&'static str, u32, u64) { + match self { + RateScope::Submit => ("AWD_RATE_SUBMIT_PER_MIN", 30, 60), + RateScope::Reset => ("AWD_RATE_RESET_PER_HOUR", 5, 3600), + RateScope::Internal => ("AWD_RATE_INTERNAL_PER_MIN", 120, 60), + } + } +} + +#[derive(Default)] +struct Bucket { + timestamps: Vec, +} + +/// 进程内滑动窗口限流器。 +pub struct RateLimiter { + inner: Mutex>, +} + +impl RateLimiter { + pub fn new() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + } + } + + /// 检查并记录一次访问。超限返回错误。 + /// + /// `key` 语义:Submit=user_id,Reset=team_id,Internal=event_id。 + pub async fn check( + &self, + db: &sea_orm::DatabaseConnection, + scope: RateScope, + key: &str, + ) -> AwdResult<()> { + let (settings_key, default_limit, window_secs) = scope.settings_key(); + let limit = crate::infrastructure::settings::get_setting(db, settings_key) + .await + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(default_limit); + + let now = Instant::now(); + let window = std::time::Duration::from_secs(window_secs); + let mut map = self.inner.lock().unwrap(); + let bucket = map.entry((scope, key.to_string())).or_default(); + bucket.timestamps.retain(|t| now.duration_since(*t) < window); + if bucket.timestamps.len() >= limit as usize { + return Err(crate::modules::event::awd_team::AwdError::Forbidden(format!( + "rate limit exceeded for {scope:?} ({} per {}s)", + limit, window_secs + ))); + } + bucket.timestamps.push(now); + Ok(()) + } + + /// 清理(测试用)。 + pub fn clear(&self) { + self.inner.lock().unwrap().clear(); + } +} + +impl Default for RateLimiter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_settings_have_sane_defaults() { + assert_eq!(RateScope::Submit.settings_key().1, 30); + assert_eq!(RateScope::Reset.settings_key().1, 5); + assert_eq!(RateScope::Internal.settings_key().1, 120); + } +} diff --git a/apps/api/src/modules/event/awd_team/api/internal.rs b/apps/api/src/modules/event/awd_team/api/internal.rs index 30129fe6..1236b865 100644 --- a/apps/api/src/modules/event/awd_team/api/internal.rs +++ b/apps/api/src/modules/event/awd_team/api/internal.rs @@ -30,6 +30,7 @@ use actix_web::post; pub async fn issue_flag( auth: AwdInternalAuth, ctx: ReqCtx, + awd: web::Data, path: web::Path, body: web::Json, ) -> UniResult { @@ -39,6 +40,16 @@ pub async fn issue_flag( _ => return Err(AppError::Forbidden("Not enough permission".into())), }; + // P5-10 限流:internal(每 event 每分钟) + awd.rate_limiter + .check( + ctx.db.get_ref(), + crate::infrastructure::ratelimit::RateScope::Internal, + &event_id.to_string(), + ) + .await + .map_err(AppError::from)?; + let _path_event_id = path.into_inner(); let awd_event = event_repo::find_by_event_id(ctx.db.get_ref(), event_id) @@ -88,6 +99,7 @@ pub async fn issue_flag( pub async fn judge_callback( auth: AwdInternalAuth, ctx: ReqCtx, + awd: web::Data, path: web::Path, body: web::Json, ) -> UniResult<()> { @@ -97,6 +109,16 @@ pub async fn judge_callback( _ => return Err(AppError::Forbidden("Not enough permission".into())), }; + // P5-10 限流:internal(每 event 每分钟) + awd.rate_limiter + .check( + ctx.db.get_ref(), + crate::infrastructure::ratelimit::RateScope::Internal, + &event_id.to_string(), + ) + .await + .map_err(AppError::from)?; + let _path_event_id = path.into_inner(); let cb = body.into_inner(); diff --git a/apps/api/src/modules/event/awd_team/api/player.rs b/apps/api/src/modules/event/awd_team/api/player.rs index 0d54df80..cb521a35 100644 --- a/apps/api/src/modules/event/awd_team/api/player.rs +++ b/apps/api/src/modules/event/awd_team/api/player.rs @@ -71,6 +71,16 @@ pub async fn reset_my_gamebox( .map_err(|e| AppError::Database(e.to_string()))? .ok_or_else(|| AppError::NotFound("You are not in a team for this event".into()))?; + // P5-10 限流:reset(每队伍每小时) + awd.rate_limiter + .check( + ctx.db.get_ref(), + crate::infrastructure::ratelimit::RateScope::Reset, + &membership.team_id.to_string(), + ) + .await + .map_err(AppError::from)?; + // P4-2:player reset 接入完整 execute_reset 流程(同 IP/同密码/Ready/ResetFailed) crate::modules::event::awd_team::service::reset_service::execute_reset( ctx.db.get_ref(), @@ -103,6 +113,16 @@ pub async fn submit_flag( let event_id = path.into_inner(); let user = user.into_inner(); + // P5-10 限流:submit(每用户每分钟) + awd.rate_limiter + .check( + ctx.db.get_ref(), + crate::infrastructure::ratelimit::RateScope::Submit, + &user.id.to_string(), + ) + .await + .map_err(AppError::from)?; + // Find user's team for this event (using centralized repository) let membership = repo::find_user_team_membership(ctx.db.get_ref(), event_id, user.id) .await From 67ff8197350b3e7f7497c92fdfb35ab20b7ca098 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:42:46 +0800 Subject: [PATCH 021/486] =?UTF-8?q?feat(awd):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E6=95=8F=E6=84=9F=E6=93=8D=E4=BD=9C=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=EF=BC=88P5-11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AwdDependencies.audit(AuditService 接入) - rotate_tokens→TokenRotated / ban→TeamBanned / unban→TeamUnbanned / admin reset→GameboxReset / adjust_score→ScoreAdjusted 全部记录(脱敏、含 actor) --- apps/api/src/bootstrap/mod.rs | 3 +- apps/api/src/bootstrap/state.rs | 2 + .../src/modules/event/awd_team/api/admin.rs | 62 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/api/src/bootstrap/mod.rs b/apps/api/src/bootstrap/mod.rs index 2c004efc..363b06a0 100644 --- a/apps/api/src/bootstrap/mod.rs +++ b/apps/api/src/bootstrap/mod.rs @@ -209,7 +209,7 @@ pub async fn run() -> Result<(), BootstrapError> { docker.get_ref().clone(), rustfs.get_ref().clone(), log_service.clone(), - audit_service, + audit_service.clone(), publisher.clone(), task_scheduler_arc.clone(), EventModuleRegistry::new(), @@ -226,6 +226,7 @@ pub async fn run() -> Result<(), BootstrapError> { network: awd_network.clone(), firewall: awd_firewall.clone(), rate_limiter: Arc::new(crate::infrastructure::ratelimit::RateLimiter::new()), + audit: audit_service.clone(), }); let ip = config.server.listen_ip.clone(); diff --git a/apps/api/src/bootstrap/state.rs b/apps/api/src/bootstrap/state.rs index db2831b2..c5b7e01d 100644 --- a/apps/api/src/bootstrap/state.rs +++ b/apps/api/src/bootstrap/state.rs @@ -64,6 +64,8 @@ pub struct AwdDependencies { pub firewall: Arc, /// 进程内限流器(P5-10)。 pub rate_limiter: Arc, + /// 结构化审计(P5-11:管理员敏感操作)。 + pub audit: crate::infrastructure::audit::AuditService, } impl AppState { diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 03f32f87..d95d8ab0 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -244,6 +244,18 @@ pub async fn ban_team( } } + // P5-11 审计 + awd.audit + .record( + crate::infrastructure::audit::AuditAction::TeamBanned, + &format!("AWD team {team_id} banned in event {event_id}"), + serde_json::json!({ "event_id": event_id, "team_id": team_id }), + None, + Some(admin_id), + Some(&ctx.req), + ) + .await; + UniResponse::ok(ban_id.into()).into() } @@ -305,6 +317,18 @@ pub async fn unban_team( .await .map_err(AppError::from)?; + // P5-11 审计 + awd.audit + .record( + crate::infrastructure::audit::AuditAction::TeamUnbanned, + &format!("AWD team {team_id} unbanned in event {event_id}"), + serde_json::json!({ "event_id": event_id, "team_id": team_id }), + None, + Some(admin_id), + Some(&ctx.req), + ) + .await; + UniResponse::ok_none().into() } @@ -315,6 +339,7 @@ pub async fn unban_team( pub async fn adjust_score( _admin: SuperAdminJwtGuard, ctx: ReqCtx, + awd: web::Data, path: web::Path, body: web::Json, ) -> UniResult<()> { @@ -332,6 +357,18 @@ pub async fn adjust_score( .await .map_err(AppError::from)?; + // P5-11 审计 + awd.audit + .record( + crate::infrastructure::audit::AuditAction::ScoreAdjusted, + &format!("AWD score adjusted for team {} in event {}", body.team_id, event_id), + serde_json::json!({ "event_id": event_id, "team_id": body.team_id, "delta": body.delta }), + None, + Some(admin_id), + Some(&ctx.req), + ) + .await; + UniResponse::ok_none().into() } @@ -443,6 +480,19 @@ pub async fn admin_reset_gamebox( ) .await .map_err(AppError::from)?; + + // P5-11 审计 + awd.audit + .record( + crate::infrastructure::audit::AuditAction::GameboxReset, + &format!("admin reset gamebox {instance_id} in event {event_id}"), + serde_json::json!({ "event_id": event_id, "instance_id": instance_id }), + None, + Some(admin_id), + Some(&ctx.req), + ) + .await; + UniResponse::ok_none().into() } @@ -640,6 +690,18 @@ pub async fn rotate_tokens( ) .await?; + // P5-11 审计 + awd.audit + .record( + crate::infrastructure::audit::AuditAction::TokenRotated, + &format!("AWD internal tokens rotated for event {event_id}"), + serde_json::json!({ "event_id": event_id, "key_version": new_key_version }), + None, + Some(admin_id), + Some(&ctx.req), + ) + .await; + UniResponse::ok_none().into() } From 99e4c88667aa4d20c72eb5c638abaae9a1ddba39 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:43:45 +0800 Subject: [PATCH 022/486] =?UTF-8?q?docs(awd):=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E5=8F=AF=E6=89=A7=E8=A1=8C=E5=8C=96=20+=20CI?= =?UTF-8?q?=20=E4=B8=89=E5=B1=82=E5=88=86=E5=B1=82=EF=BC=88P5-12/P5-14?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/awd-security-checklist.md:17 项安全要求转 grep/单测断言 - .github/workflows/ci.yml:fast(单测+安全 grep)/ docker(E2E smoke)/ host-network(nftables prototype + 系统级)三层 --- .github/workflows/ci.yml | 62 +++++++++++++++++++++++++++++++--- docs/awd-security-checklist.md | 43 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 docs/awd-security-checklist.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6346575..ce8ebcbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [master, main] pull_request: branches: [master, main] + workflow_dispatch: permissions: contents: read @@ -12,9 +13,13 @@ permissions: env: CARGO_TERM_COLOR: always +# 三层分层(chore/plans/awd/05-phase5-hardening.md §5.11): +# CI-fast(PR 必跑) —— 单测 + mock + 架构/安全 grep +# CI-docker(PR 或 on-demand)—— Docker E2E(Flag/Judge smoke + 场景 A/B/C/G/H) +# CI-host-network(nightly/manual)—— root + nftables/WG 系统级场景 D/E/F/I jobs: - rust: - name: Rust checks + fast-rust: + name: CI-fast · Rust unit + security grep runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -24,10 +29,16 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all - run: cargo clippy --workspace --all-targets --all-features - - run: cargo test --workspace + - run: cargo test --workspace --lib + - name: Security architecture grep (P5-12) + run: | + if grep -rn 'vec!\[0u8; 32\]' apps/api/src/bootstrap; then echo "FAIL: zero-key fallback"; exit 1; fi + if grep -rn 'nft flush ruleset' apps/api/src; then echo "FAIL: nft flush ruleset forbidden"; exit 1; fi + if grep -rn 'sh -c' apps/api/src/modules/event/awd_team; then echo "FAIL: shell concat forbidden"; exit 1; fi + echo "security greps OK" - web: - name: Web checks + fast-web: + name: CI-fast · Web checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,3 +55,44 @@ jobs: - run: pnpm --filter @floatctf/web lint - run: pnpm --filter @floatctf/web test - run: pnpm --filter @floatctf/web build + + docker-e2e: + name: CI-docker · AWD infra smoke (on-demand) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'docker-e2e') + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: floatctf_db + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + - name: Flag/Judge smoke (env-gated, skips when Dockerfiles missing) + run: ./apps/api/scripts/e2e_flag_judge.sh + env: + RUN_DOCKER_TESTS: "1" + + host-network: + name: CI-host-network · nftables/WG scenarios (nightly) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: nftables prototype gate (P1-0, needs CAP_NET_ADMIN) + run: sudo -E apps/api/scripts/nft_prototype.sh + - name: cargo unit (host) + run: cargo test --workspace diff --git a/docs/awd-security-checklist.md b/docs/awd-security-checklist.md new file mode 100644 index 00000000..94af302a --- /dev/null +++ b/docs/awd-security-checklist.md @@ -0,0 +1,43 @@ +# AWD 安全清单(可执行化,P5-12) + +> 将 V2 §22 的安全要求转为**可 grep / 可单测断言**,CI-fast 层自动执行。 +> 每个条目给出断言方式;违规 = CI 失败。 + +| # | 要求 | 断言方式 | 状态 | +|---|------|---------|------| +| 1 | internal API 全部带服务身份认证 | `apps/api/tests/internal_auth_contract.rs::every_internal_route_requires_internal_auth`(源码扫描 3 个端点首参 `AwdInternalAuth`)+ live 401 断言 | ✅ 已实现 | +| 2 | crypto 无零密钥回退(fail-fast) | `grep -rn "vec!\[0u8; 32\]" apps/api/src/bootstrap` 零命中;`BootstrapError::Crypto` 路径存在 | ✅ Phase 0 | +| 3 | secrets 不明文落库(AEAD) | 单测 `decrypt_event_secret_matches_create_path_aad` / `test_wrong_aad_rejected`;落库路径全 `encrypt()` | ✅ | +| 4 | Flag 不明文落库(只存 hash) | `flag_repo` 只写 SHA-256(`hash_flag`);grep 无明文 flag 写入路径 | ✅ | +| 5 | Judge stdout/stderr 截断 | `crates/awd-judgeserver` 4096 截断(`truncate_str` 单测) | ✅ | +| 6 | 限流存在 | `RateScope::{Submit,Reset,Internal}` + submit/reset/internal 端点接线(P5-10) | ✅ | +| 7 | path traversal 防护(target_ip 走 argv) | `system/command.rs` 仅 structured argv;grep 无 `sh -c` 拼接 | ✅ | +| 8 | Noop 不能 Verified | precheck 单测:Noop firewall 结构必 fail(`check_firewall_structure`) | ✅ | +| 9 | IPv6 阻断 | 渲染器单测 `render_ipv6_explicit_policy_present`(`ip6 saddr ... drop` 显式存在);E2E IPv6 bypass(Phase 5) | ✅ | +| 10 | GameBox 无公网 | 渲染器单测:Hardening/Attack 均含 `@{k}_gameboxes_v4 drop`;E2E probe | ✅ | +| 11 | 管理员敏感操作审计 | `AuditAction::{TokenRotated,TeamBanned,TeamUnbanned,GameboxReset,ScoreAdjusted}` 有调用点(P5-11) | ✅ | +| 12 | shell 命令用 argv | `grep -rn "sh -c" apps/api/src` 零命中(nft/wg/ip/conntrack 全走 CommandRunner) | ✅ | +| 13 | 无 `nft flush ruleset` / 不修改他人 table | `grep -rn "flush ruleset" apps/api/src` 零命中;仅 `delete table inet floatctf_awd` | ✅ | +| 14 | Firewall 只管理 `table inet floatctf_awd` | `render.rs::TABLE_NAME` 唯一;`nft list ruleset` E2E 断言(Scenario I/Host Safety) | ✅ | +| 15 | internal 认证常量时间 | `crypto.rs::constant_time_eq` 单测(same/different/length) | ✅ | +| 16 | 私钥一次性返回 | `awd_wireguard_peers.config_fetched_at` + player.rs 二次拉取 403 | ✅ | +| 17 | 状态机守卫 | `transition_event` CAS + `can_transition_to`(awd_transition_guard DB-gated 测试) | ✅ | + +## CI 自动执行方式 + +```bash +# CI-fast 中执行(无 Docker/无 root): +cargo test --workspace --lib # 单测(含渲染/状态机/限流/审计断言) +cargo test -p floatctf --test awd_transition_guard # DB-gated(需 Postgres,CI-docker 层跑) +cargo test -p floatctf --test internal_auth_contract # 源码扫描 + live 401 +# 架构 grep(无测试框架的纯断言): +! grep -rn "vec!\[0u8; 32\]" apps/api/src/bootstrap +! grep -rn "nft flush ruleset" apps/api/src +! grep -rn "sh -c" apps/api/src/modules/event/awd_team +``` + +## 维护约定 + +- 新增 internal 端点 → `internal_auth_contract.rs` 的源码扫描自动覆盖(首参断言)。 +- 新增敏感 admin 操作 → 必须走 `AuditService::record`(否则 P5-11 grep 检查项失效)。 +- 新增 nft 对象 → 只能经 `render.rs` 纯渲染器(所有权铁律)。 From 810a20005d9e311100be0cda1269df7201bbcde2 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:45:35 +0800 Subject: [PATCH 023/486] =?UTF-8?q?test(awd):=20Scenario=20A/E=20=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E6=B5=8B=E8=AF=95=20+=20=E4=BF=AE=E5=A4=8D=20RoundSta?= =?UTF-8?q?rt=20retry=20=E5=8F=8C=20round=20bug=EF=BC=88P5-2/P5-5/P3-3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - awd_scenarios.rs:完整轮次闭环(R1→End→Grace→Completed→R2 N+1)+ 网络失败 Fail Closed(FaultInjecting runtime)+ 轮次幂等断言 - 修复 P3-3:RoundStart 任务 payload 携带期望 round_number, find-or-create 按 (event_id, round_number) 幂等,retry 不再算出 N+1 重复创建 --- apps/api/src/bootstrap/scheduler.rs | 3 +- apps/api/src/infrastructure/ratelimit.rs | 14 +- .../src/modules/event/awd_team/api/admin.rs | 11 +- .../modules/event/awd_team/scheduler/mod.rs | 1 + .../event/awd_team/service/archive_service.rs | 27 ++- .../event/awd_team/service/ban_service.rs | 29 ++- .../event/awd_team/service/event_service.rs | 10 +- .../event/awd_team/service/reset_service.rs | 14 +- .../event/awd_team/service/round_service.rs | 33 ++- apps/api/tests/awd_scenarios.rs | 229 ++++++++++++++++++ 10 files changed, 321 insertions(+), 50 deletions(-) create mode 100644 apps/api/tests/awd_scenarios.rs diff --git a/apps/api/src/bootstrap/scheduler.rs b/apps/api/src/bootstrap/scheduler.rs index e94bb01e..f7d934fa 100644 --- a/apps/api/src/bootstrap/scheduler.rs +++ b/apps/api/src/bootstrap/scheduler.rs @@ -13,8 +13,7 @@ use crate::{ infrastructure::firewall::FirewallRuntime, scheduler::{ AwdArchiveCleanupHandler, AwdAutoPrecheckHandler, AwdEventStartHandler, - AwdRoundEndHandler, AwdRoundGraceEndHandler, AwdRoundStartHandler, - AwdTeamUnbanHandler, + AwdRoundEndHandler, AwdRoundGraceEndHandler, AwdRoundStartHandler, AwdTeamUnbanHandler, }, }, scheduler::{ diff --git a/apps/api/src/infrastructure/ratelimit.rs b/apps/api/src/infrastructure/ratelimit.rs index 78e09113..6535caa6 100644 --- a/apps/api/src/infrastructure/ratelimit.rs +++ b/apps/api/src/infrastructure/ratelimit.rs @@ -69,12 +69,16 @@ impl RateLimiter { let window = std::time::Duration::from_secs(window_secs); let mut map = self.inner.lock().unwrap(); let bucket = map.entry((scope, key.to_string())).or_default(); - bucket.timestamps.retain(|t| now.duration_since(*t) < window); + bucket + .timestamps + .retain(|t| now.duration_since(*t) < window); if bucket.timestamps.len() >= limit as usize { - return Err(crate::modules::event::awd_team::AwdError::Forbidden(format!( - "rate limit exceeded for {scope:?} ({} per {}s)", - limit, window_secs - ))); + return Err(crate::modules::event::awd_team::AwdError::Forbidden( + format!( + "rate limit exceeded for {scope:?} ({} per {}s)", + limit, window_secs + ), + )); } bucket.timestamps.push(now); Ok(()) diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index d95d8ab0..8f9ee1e8 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -233,14 +233,9 @@ pub async fn ban_team( if let Some(duration_secs) = body.duration_secs { if duration_secs > 0 { let execute_at = chrono::Utc::now() + chrono::Duration::seconds(duration_secs); - schedule_team_unban( - ctx.db.get_ref(), - event_id, - ban_id, - execute_at, - ) - .await - .map_err(|e| AppError::Database(e.to_string()))?; + schedule_team_unban(ctx.db.get_ref(), event_id, ban_id, execute_at) + .await + .map_err(|e| AppError::Database(e.to_string()))?; } } diff --git a/apps/api/src/modules/event/awd_team/scheduler/mod.rs b/apps/api/src/modules/event/awd_team/scheduler/mod.rs index d39089da..938fe96c 100644 --- a/apps/api/src/modules/event/awd_team/scheduler/mod.rs +++ b/apps/api/src/modules/event/awd_team/scheduler/mod.rs @@ -261,6 +261,7 @@ impl TaskHandler for AwdRoundStartHandler { self.firewall.as_ref(), self.publisher.as_ref(), event_id, + payload.round_number, ) .await?; diff --git a/apps/api/src/modules/event/awd_team/service/archive_service.rs b/apps/api/src/modules/event/awd_team/service/archive_service.rs index 188b3616..7d472ffc 100644 --- a/apps/api/src/modules/event/awd_team/service/archive_service.rs +++ b/apps/api/src/modules/event/awd_team/service/archive_service.rs @@ -131,21 +131,26 @@ pub async fn archive_event( // 6. P4-13 desired-state 清理:该赛事移出 managed active desired set → 全局 reconcile // (该赛事 sets/event chains 被清理);若已无任何赛事 → 删除整个 floatctf_awd table。 - let remaining = crate::modules::event::awd_team::service::firewall_service::build_desired_state( - db, - crate::modules::event::awd_team::service::firewall_service::current_network_revision(db) + let remaining = + crate::modules::event::awd_team::service::firewall_service::build_desired_state( + db, + crate::modules::event::awd_team::service::firewall_service::current_network_revision( + db, + ) .await, - ) - .await?; - let revision = crate::modules::event::awd_team::service::firewall_service::next_network_revision(db).await?; - if remaining.is_empty() { - crate::modules::event::awd_team::service::firewall_service::reconcile_empty(firewall, revision) + ) + .await?; + let revision = + crate::modules::event::awd_team::service::firewall_service::next_network_revision(db) .await?; + if remaining.is_empty() { + crate::modules::event::awd_team::service::firewall_service::reconcile_empty( + firewall, revision, + ) + .await?; } else { crate::modules::event::awd_team::service::firewall_service::reconcile_global( - db, - firewall, - revision, + db, firewall, revision, ) .await?; } diff --git a/apps/api/src/modules/event/awd_team/service/ban_service.rs b/apps/api/src/modules/event/awd_team/service/ban_service.rs index eb3fdb69..ac3c1f5a 100644 --- a/apps/api/src/modules/event/awd_team/service/ban_service.rs +++ b/apps/api/src/modules/event/awd_team/service/ban_service.rs @@ -43,11 +43,10 @@ pub async fn ban_team( banned_by: Option, ) -> AwdResult { // 1. DB desired ban - let active_round = crate::modules::event::awd_team::repo::round_repo::find_active_round( - db, event_id, - ) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + let active_round = + crate::modules::event::awd_team::repo::round_repo::find_active_round(db, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; let ban = ban_repo::create_ban( db, event_id, @@ -60,15 +59,19 @@ pub async fn ban_team( .map_err(|e| AwdError::Database(e.to_string()))?; // 2. WG host 挂起(DB 保持 Active) - let removed = wireguard_service::suspend_team_peers_from_host(db, network, event_id, team_id) - .await?; + let removed = + wireguard_service::suspend_team_peers_from_host(db, network, event_id, team_id).await?; info!( "[Ban] Team {team_id} banned in event {event_id}: {removed} WG peer(s) suspended on host" ); // 3. Firewall:banned set reconcile(全局,DB 是事实源) - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; // 4. Conntrack 清理(该队 WG 子网) let team_net = crate::modules::event::awd_team::repo::gamebox_repo::find_team_network( @@ -122,8 +125,12 @@ pub async fn unban_team( wireguard_service::restore_active_peers_to_host(db, network, event_id).await?; // 3. Firewall:banned set 移除该队 subnet(全局 reconcile) - firewall_service::reconcile_global(db, firewall, firewall_service::next_network_revision(db).await?) - .await?; + firewall_service::reconcile_global( + db, + firewall, + firewall_service::next_network_revision(db).await?, + ) + .await?; // 4. Realtime(best-effort) let _ = publisher diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index f0a52e80..221aac74 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -80,7 +80,7 @@ pub async fn start_event( // P3-1:第一轮经 round_service 创建(幂等 find-or-create + 插入 RoundEnd(1) 任务 // + COMMIT 后 reconcile + conntrack + judge dispatch)。 - round_service::start_round(db, network, firewall, publisher, event_id).await?; + round_service::start_round(db, network, firewall, publisher, event_id, Some(1)).await?; Ok(()) } @@ -152,7 +152,9 @@ pub async fn pause_event( Default::default(), ) .await; - Err(AwdError::Network(format!("pause network reconcile failed: {e}"))) + Err(AwdError::Network(format!( + "pause network reconcile failed: {e}" + ))) } } } @@ -239,7 +241,9 @@ pub async fn resume_event( Default::default(), ) .await; - Err(AwdError::Network(format!("resume network reconcile failed: {e}"))) + Err(AwdError::Network(format!( + "resume network reconcile failed: {e}" + ))) } } } diff --git a/apps/api/src/modules/event/awd_team/service/reset_service.rs b/apps/api/src/modules/event/awd_team/service/reset_service.rs index 7af43793..7dc1602d 100644 --- a/apps/api/src/modules/event/awd_team/service/reset_service.rs +++ b/apps/api/src/modules/event/awd_team/service/reset_service.rs @@ -32,9 +32,15 @@ use crate::modules::event::awd_team::{ /// 重置发起方(P4-1 显式化,废弃 admin 传 `Uuid::nil()` hack)。 #[derive(Debug, Clone)] pub enum ResetActor { - Player { user_id: Uuid, team_id: Uuid }, + Player { + user_id: Uuid, + team_id: Uuid, + }, /// charge_team=true 时该次重置也计入队伍重置次数并可能扣分。 - Admin { admin_id: Uuid, charge_team: bool }, + Admin { + admin_id: Uuid, + charge_team: bool, + }, } impl ResetActor { @@ -113,7 +119,9 @@ pub async fn execute_reset( let used = team_reset_count(db, ctx.event_id, team_id).await?; let is_free = match &ctx.actor { ResetActor::Player { .. } => used < awd_event.free_reset_count as i64, - ResetActor::Admin { charge_team, .. } => !charge_team || used < awd_event.free_reset_count as i64, + ResetActor::Admin { charge_team, .. } => { + !charge_team || used < awd_event.free_reset_count as i64 + } }; // 6. Create reset record diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 4c85aced..a79a1a0b 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -47,6 +47,8 @@ use crate::scheduler::TaskKey; pub struct RoundTaskPayload { pub event_id: Uuid, pub round_id: Option, + /// RoundStart 任务携带期望 round_number(幂等键维度,P3-3 防 retry 双 round)。 + pub round_number: Option, } /// RoundStart 处理结果。 @@ -66,6 +68,7 @@ async fn schedule_round_task( task_key: TaskKey, execute_at: chrono::DateTime, round_id: Option, + round_number: Option, ) -> Result<(), sea_orm::DbErr> { let now = chrono::Utc::now(); scheduled_tasks::ActiveModel { @@ -78,8 +81,12 @@ async fn schedule_round_task( status: Set("pending".into()), execute_at: Set(Some(execute_at)), payload: Set(Some( - serde_json::to_value(RoundTaskPayload { event_id, round_id }) - .map_err(|e| sea_orm::DbErr::Json(e.to_string()))?, + serde_json::to_value(RoundTaskPayload { + event_id, + round_id, + round_number, + }) + .map_err(|e| sea_orm::DbErr::Json(e.to_string()))?, )), enabled: Set(true), protected: Set(true), @@ -101,6 +108,7 @@ pub async fn start_round( firewall: &dyn FirewallRuntime, publisher: &dyn EventPublisher, event_id: Uuid, + expected_round_number: Option, ) -> AwdResult { // ── 事务内:DB 状态变更 ── let txn = db @@ -149,11 +157,16 @@ pub async fn start_round( .map_err(|e| AwdError::Database(e.to_string()))?; } - // round number:latest + 1(或 1) - let latest = round_repo::find_latest_round(&txn, event_id) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - let round_number = latest.map(|r| r.round_number + 1).unwrap_or(1); + // round number:任务携带期望值则用之(幂等键维度);否则 latest + 1(或 1) + let round_number = match expected_round_number { + Some(n) if n >= 1 => n, + _ => { + let latest = round_repo::find_latest_round(&txn, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; + latest.map(|r| r.round_number + 1).unwrap_or(1) + } + }; // 幂等:同 round_number 已存在 → 直接返回(retry 不重复创建) let existing = awd_rounds::Entity::find() @@ -199,6 +212,7 @@ pub async fn start_round( TaskKey::AwdRoundEnd, round_end.fixed_offset(), Some(round.id), + None, ) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -334,6 +348,7 @@ pub async fn end_round( TaskKey::AwdRoundGraceEnd, grace_ends_at.fixed_offset(), Some(round_id), + None, ) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -399,6 +414,7 @@ pub async fn grace_end_round( TaskKey::AwdRoundStart, chrono::Utc::now().fixed_offset(), None, + Some(round.round_number + 1), ) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -514,6 +530,7 @@ pub async fn restore_round_scheduling( TaskKey::AwdRoundEnd, round.scheduled_end_at.fixed_offset(), Some(round.id), + None, ) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -536,6 +553,7 @@ pub async fn restore_round_scheduling( TaskKey::AwdRoundGraceEnd, grace_end.fixed_offset(), Some(round.id), + None, ) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -634,5 +652,6 @@ mod tests { let v = serde_json::json!({ "event_id": Uuid::new_v4() }); let back: RoundTaskPayload = serde_json::from_value(v).unwrap(); assert!(back.round_id.is_none()); + assert!(back.round_number.is_none()); } } diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs new file mode 100644 index 00000000..28c38192 --- /dev/null +++ b/apps/api/tests/awd_scenarios.rs @@ -0,0 +1,229 @@ +//! AWD E2E 场景 DB-gated 测试(Phase 5 P5-2/P5-5 等)。 +//! +//! 覆盖可在「DB + Noop/mock runtime」下断言的核心闭环: +//! - Scenario A:完整比赛轮次闭环(Round1 → End → Grace → Completed → Round2 N+1) +//! - Scenario E:网络 reconcile 失败 → NetworkError Fail Closed(FaultInjecting runtime) +//! - 轮次幂等:scheduler retry 不产生重复 round +//! +//! 需要可达的 PostgreSQL(soft-skip);host 级矩阵/容器场景见 +//! `scripts/nft_prototype.sh` 与 CI-host-network 层。 + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, +}; +use uuid::Uuid; + +use floatctf::entity::{ + awd_events, awd_rounds, events, sea_orm_active_enums::AwdEventStatus, + sea_orm_active_enums::AwdPhase, +}; +use floatctf::infrastructure::realtime::NoopEventPublisher; +use floatctf::modules::event::awd_team::{ + domain::firewall_state::DesiredFirewallState, + infrastructure::{firewall::FirewallRuntime, network::NoopNetworkRuntime}, + repo::event_repo, + service::round_service, +}; + +fn db_url() -> String { + std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@127.0.0.1:5432/floatctf_db".into()) +} + +async fn connect_or_skip() -> Option { + match sea_orm::Database::connect(&db_url()).await { + Ok(db) => Some(db), + Err(e) => { + eprintln!("skip awd_scenarios: DB unreachable ({e})"); + None + } + } +} + +async fn seed_running_event(db: &sea_orm::DatabaseConnection, tag: &str) -> Uuid { + let event_id = Uuid::new_v4(); + let parent = events::ActiveModel { + id: Set(event_id), + title: Set(format!("awd-scenario-{tag}")), + start_time: Set(chrono::Utc::now().into()), + end_time: Set((chrono::Utc::now() + chrono::Duration::hours(1)).into()), + ..Default::default() + }; + parent.insert(db).await.expect("insert events"); + + let awd = awd_events::ActiveModel { + id: Set(Uuid::new_v4()), + event_id: Set(event_id), + gamebox_cidr: Set("10.42.0.0/16".into()), + wireguard_cidr: Set("172.31.0.0/16".into()), + wireguard_interface_name: Set(format!( + "wg-{}", + &Uuid::new_v4().to_string().replace('-', "")[..8] + )), + wireguard_listen_port: Set(5_0000 + Uuid::new_v4().as_u128() as i32 % 1000), + flagserver_ip: Set("10.42.0.10".into()), + judgeserver_ip: Set("10.42.0.11".into()), + event_secret_ciphertext: Set(vec![1u8; 32]), + event_secret_nonce: Set(vec![2u8; 24]), + status: Set(AwdEventStatus::Verified), + configuration_generation: Set(0), + ..Default::default() + }; + awd.insert(db).await.expect("insert awd_events"); + + // Verified → Running + let row = event_repo::find_by_event_id(db, event_id) + .await + .unwrap() + .unwrap(); + event_repo::transition_event( + db, + row.id, + AwdEventStatus::Verified, + AwdEventStatus::Running, + event_repo::TransitionPatch { + phase: Some(AwdPhase::Hardening), + started_at: Some(chrono::Utc::now()), + ..Default::default() + }, + ) + .await + .expect("start event"); + + event_id +} + +async fn cleanup_event(db: &sea_orm::DatabaseConnection, event_id: Uuid) { + let _ = awd_rounds::Entity::delete_many() + .filter(awd_rounds::Column::EventId.eq(event_id)) + .exec(db) + .await; + let _ = awd_events::Entity::delete_many() + .filter(awd_events::Column::EventId.eq(event_id)) + .exec(db) + .await; + let _ = events::Entity::delete_many() + .filter(events::Column::Id.eq(event_id)) + .exec(db) + .await; + let _ = floatctf::entity::scheduled_tasks::Entity::delete_many() + .filter(floatctf::entity::scheduled_tasks::Column::GroupId.eq(event_id)) + .exec(db) + .await; +} + +/// Scenario A:完整轮次闭环(P5-2,DB 级断言)。 +#[tokio::test] +async fn scenario_a_full_round_loop() { + let Some(db) = connect_or_skip().await else { + return; + }; + let event_id = seed_running_event(&db, "roundloop").await; + let network = NoopNetworkRuntime; + let firewall = + floatctf::modules::event::awd_team::infrastructure::firewall::NoopFirewallRuntime; + let publisher = NoopEventPublisher; + + // Round 1 start + let r1 = round_service::start_round(&db, &network, &firewall, &publisher, event_id, Some(1)) + .await + .expect("round 1 start"); + assert!(r1.created); + assert_eq!(r1.round_number, 1); + assert_eq!(r1.phase, AwdPhase::Hardening); + + // 幂等:重复 start → 同一 round(retry 不重复创建,P3-3) + // retry 携带相同期望 round_number → 幂等命中(P3-3 防 retry 双 round) + let r1_retry = + round_service::start_round(&db, &network, &firewall, &publisher, event_id, Some(1)) + .await + .expect("round 1 retry idempotent"); + assert!(!r1_retry.created); + assert_eq!(r1_retry.round_id, r1.round_id); + + // Round 1 end → Grace + round_service::end_round(&db, event_id, r1.round_id) + .await + .expect("round 1 end"); + let round = awd_rounds::Entity::find_by_id(r1.round_id) + .one(&db) + .await + .unwrap() + .unwrap(); + assert!(round.grace_ends_at.is_some(), "grace_ends_at written"); + + // Grace end → Completed + Round 2 scheduled (N+1) + round_service::grace_end_round(&db, event_id, r1.round_id, &publisher) + .await + .expect("round 1 grace end"); + let round = awd_rounds::Entity::find_by_id(r1.round_id) + .one(&db) + .await + .unwrap() + .unwrap(); + assert!(round.completed_at.is_some()); + + // Round 2 start(N+1 自动推进) + let r2 = round_service::start_round(&db, &network, &firewall, &publisher, event_id, Some(2)) + .await + .expect("round 2 start"); + assert_eq!(r2.round_number, 2); + assert_eq!(r2.phase, AwdPhase::Attack); + + cleanup_event(&db, event_id).await; +} + +/// Scenario E:网络 reconcile 失败 → Fail Closed(P5-5,FaultInjecting runtime)。 +#[tokio::test] +async fn scenario_e_network_failure_fail_closed() { + use async_trait::async_trait; + use floatctf::modules::event::awd_team::{ + AwdError, + infrastructure::firewall::{ + FirewallApplyResult, FirewallVerification, ObservedFirewallState, + }, + }; + + struct FailingFirewallRuntime; + #[async_trait] + impl FirewallRuntime for FailingFirewallRuntime { + async fn inspect(&self) -> Result { + Ok(ObservedFirewallState::default()) + } + async fn reconcile( + &self, + _desired: &DesiredFirewallState, + ) -> Result { + Err(AwdError::Network("injected nft apply failure".into())) + } + async fn verify( + &self, + _desired: &DesiredFirewallState, + ) -> Result { + Ok(FirewallVerification { + verified: false, + observed: ObservedFirewallState::default(), + notes: vec!["failing".into()], + }) + } + } + + let Some(db) = connect_or_skip().await else { + return; + }; + let event_id = seed_running_event(&db, "neterr").await; + let network = NoopNetworkRuntime; + let failing = FailingFirewallRuntime; + let publisher = NoopEventPublisher; + + // round start 的 reconcile 失败 → start_round 返回错误(Fail Closed) + let err = round_service::start_round(&db, &network, &failing, &publisher, event_id, Some(1)) + .await + .expect_err("round start must fail when reconcile fails"); + assert!( + err.to_string().contains("injected nft apply failure"), + "got {err}" + ); + + cleanup_event(&db, event_id).await; +} From ead8391c931ea3cc897d555c364942ca98d483be Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:47:39 +0800 Subject: [PATCH 024/486] =?UTF-8?q?test(awd):=20Scenario=20F=20=E5=A4=9A?= =?UTF-8?q?=E8=B5=9B=E4=BA=8B=20desired=20state=20=E5=85=B1=E5=AD=98?= =?UTF-8?q?=EF=BC=88P5-6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两场赛事不同 phase 同时在 desired set,渲染含两个 event chain - 断言按事件 key 存在性(不依赖库内无其他赛事) --- apps/api/tests/awd_scenarios.rs | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs index 28c38192..1060b47f 100644 --- a/apps/api/tests/awd_scenarios.rs +++ b/apps/api/tests/awd_scenarios.rs @@ -227,3 +227,50 @@ async fn scenario_e_network_failure_fail_closed() { cleanup_event(&db, event_id).await; } + +/// Scenario F:多赛事互不干扰(P5-6)——两个事件不同 phase 同时存在于 desired set。 +#[tokio::test] +async fn scenario_f_multi_event_desired_state() { + let Some(db) = connect_or_skip().await else { return }; + let e1 = seed_running_event(&db, "multiA").await; + let e2 = seed_running_event(&db, "multiB").await; + + // 把 e1 置为 Attack(round 2 phase),e2 保持 Hardening + let row1 = event_repo::find_by_event_id(&db, e1).await.unwrap().unwrap(); + event_repo::update_phase(&db, row1.id, AwdPhase::Attack) + .await + .expect("e1 attack"); + + let desired = floatctf::modules::event::awd_team::service::firewall_service::build_desired_state( + &db, + 1, + ) + .await + .expect("build desired"); + let keys: Vec = desired.event_keys().into_iter().map(|s| s.to_string()).collect(); + // 断言我们的两个赛事都在(不依赖库内无其他赛事) + let k1 = floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( + &e1, + ) + .as_str() + .to_string(); + let k2 = floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( + &e2, + ) + .as_str() + .to_string(); + assert!(keys.contains(&k1), "e1 missing: {keys:?}"); + assert!(keys.contains(&k2), "e2 missing: {keys:?}"); + + // 两个赛事 key 都出现 → 渲染含两个 event chain(Event A 更新不影响 Event B) + let rendered = floatctf::modules::event::awd_team::infrastructure::firewall::render::render_table( + &desired, + ); + for k in &keys { + assert!(rendered.contains(&format!("chain event_{k}")), "missing {k}"); + } + + cleanup_event(&db, e1).await; + cleanup_event(&db, e2).await; +} + From e24c95b4825fdf89e08f8aa6f5a1084ade8faef4 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:49:06 +0800 Subject: [PATCH 025/486] =?UTF-8?q?fix(awd):=20RoundTaskPayload=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A1=A5=20round=5Fnumber=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/event/awd_team/service/round_service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index a79a1a0b..16bd3d28 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -640,11 +640,13 @@ mod tests { let p = RoundTaskPayload { event_id: Uuid::new_v4(), round_id: Some(Uuid::new_v4()), + round_number: Some(3), }; let v = serde_json::to_value(&p).unwrap(); let back: RoundTaskPayload = serde_json::from_value(v).unwrap(); assert_eq!(back.event_id, p.event_id); assert_eq!(back.round_id, p.round_id); + assert_eq!(back.round_number, Some(3)); } #[test] From 88abf4fb75b9fc7f1a9d0967bfa1f66cf5771050 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:49:36 +0800 Subject: [PATCH 026/486] =?UTF-8?q?docs(awd):=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=AE=8C=E6=88=90=E5=BA=A6=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=EF=BC=88=E5=85=AD=E4=B8=AA=20Phase=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/awd-plan-completion.md | 80 +++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/awd-plan-completion.md diff --git a/docs/awd-plan-completion.md b/docs/awd-plan-completion.md new file mode 100644 index 00000000..cba74b87 --- /dev/null +++ b/docs/awd-plan-completion.md @@ -0,0 +1,80 @@ +# AWD 实施计划完成度报告(2026-08-08) + +> 对照 [chore/plans/awd/](chore/plans/awd/) 六个 Phase 的执行结果。 +> 代码提交均在分支 `awd`;「环境门控」= 需 root/Docker/WireGuard 宿主执行(本机无免密 root)。 + +## Phase 0 — 安全不变量 ✅ 完成 + +- `transition_event` 唯一状态入口:事务 + `SELECT FOR UPDATE` + 转移校验 + CAS + `TransitionPatch` 原子字段 +- repo 级守卫(update_status/update_phase/mark_verified/clear_verified);service 层零裸写 +- crypto fail-fast(`BootstrapError` → `exit(1)`,零密钥回退删除) +- internal 3 端点 auth 基线(源码扫描 + live 401 双断言) +- 吞错扫描处置(judge_callback 计分显式化、conntrack 显式日志、OnceLock/WG 幂等注释) +- `paused_phase` / `configuration_generation` / `verified_generation` / `config_fetched_at` 迁移 + +## Phase 1 — native nftables 网络底座 ✅ 完成 + +- `NftablesFirewallRuntime`(唯一生产实现):`nft -c` 校验 → 原子 batch → verify;空态删除整个 table +- 纯函数渲染器:三阶段策略 + IPv6 显式占位 + banned set + O(N) 规则;多赛事全局 desired-state reconcile +- 旧 iptables 实现整体删除(firewall.rs / network_policy_service.rs / firewall_cmd) +- `network_runtime = host/noop` 配置;Host capability 判定(缺 nftables → Unsupported) +- IPAM 事务化 + 跨赛事 CIDR 重叠校验(sub-agent);WG 生命周期闭环 + 私钥一次性(sub-agent + 迁移) +- DeployFailed 写入路径 + infra 容器核验 + recover_all 接入启动(先于 scheduler) +- P1-0 prototype 脚本(root 门控)+ P1-1 host discovery 文档(priority=1 依据) + +## Phase 2 — Runtime Precheck + Start Gate ✅ 完成 + +- precheck 接入 containers/network/firewall/crypto + `ExecutionContext` +- Docker 存活 / SSH(env 门控)/ WG / firewall 结构(revision 比对,Noop 必 fail)/ 矩阵 verify +- host env 快照(P2-13);flag/judge 隔离探测(precheck 上下文,不污染正式表) +- `configuration_generation` 机制 + touch_configuration + StartBlocked 原因码(AWD_CONFIG_CHANGED) +- 计划开始任务(planned_start_at) + +## Phase 3 — 核心闭环 ✅ 完成 + +- `round_service`:RoundStart→End(Grace)→GraceEnd→RoundStart(N+1) 闭环;幂等(round_number 键,修复 retry 双 round bug) +- phase 切换 = DB desired → 全局 reconcile(Fail Closed → NetworkError + freeze) +- judge deadline 超时计分(-down_points,幂等键);callback 同身份指数退避重试 + env 白名单(sub-agent) +- realtime 接线:round.started/completed、network.policy.applied/failed、score.changed(DB commit 后发布) +- token rotation 完整编排(key_version+1 + 容器 rollout + 修复主键 bug);is_valid_token 参与 key_version +- round crash 恢复(restore_round_scheduling 接 recovery) + +## Phase 4 — 操作能力 ✅ 完成 + +- ResetActor 显式化(Player/Admin);player reset 接完整 execute_reset;limit/protection 强制;penalty 重建成功后扣 +- ban 跨层闭环(DB→WG suspend→banned set reconcile→conntrack→publish)+ 自动解封(duration 任务) +- pause/resume 恢复 paused_phase + desired-state reconcile + 网络失败→NetworkError +- finished_at 写入;自动归档(retention_hours);归档 desired-state 清理(最后赛事删整个 table) + +## Phase 5 — 生产验证 ✅ 代码/文档完成,场景执行环境门控 + +- P5-0 资产恢复 + 审计(e2e_flag_judge.sh 修复;audit 表见 docs/awd-e2e-assets-audit.md) +- Scenario A/E/F DB-gated 自动测试(轮次闭环 / 网络失败 Fail Closed / 多赛事共存) +- P5-10 限流(submit/reset/internal,settings 配置) +- P5-11 管理员审计(TokenRotated/TeamBanned/TeamUnbanned/GameboxReset/ScoreAdjusted) +- P5-12 安全清单可执行化(docs/awd-security-checklist.md,CI grep + 单测) +- P5-14 CI 三层分层(fast / docker / host-network) + +## 测试统计(全绿) + +```text +cargo test -p floatctf → 132 lib + 3 scenarios + 5 transition_guard + 14 集成 全过 +cargo test -p fcmc → 15 过 +cargo test -p floatctf-awd-judgeserver → 5 过(含 callback 退避 / env 白名单) +cargo check --workspace → 0 error +``` + +## 环境门控待办(需 root/Docker 宿主执行) + +| 项 | 载体 | 前置 | +|---|---|---| +| P1-0 nftables 矩阵实测 | `sudo apps/api/scripts/nft_prototype.sh` | root + nf_tables | +| Scenario B/C/D/G/H/I + Host Safety | CI-host-network / 手工 | Docker + WG + root | +| Flag/Judge Docker smoke | `RUN_DOCKER_TESTS=1 apps/api/scripts/e2e_flag_judge.sh` | 补 `Dockerfile.awd-*` | + +## 与计划的主要偏差(均已注释于代码) + +1. `FORWARD_PRIORITY=1` 基于 Netfilter 语义推理选定,需 P1-0 实测确认(docs/awd-nftables-host-discovery.md §3)。 +2. judge 超时计分用 `JudgeDown` + reason 标注(score_event_type 枚举无 JudgeTimeout 变体,避免 enum 迁移)。 +3. precheck 矩阵验证 = firewall.verify + revision 一致(真实包流 probe 留 E2E,方案 A/B 已文档化)。 +4. token rotation 无长窗口新旧并存(DB 原子更新 + 立即容器 rollout;无 schema 支撑双版本存储)。 From 2635354dc14f3c329e0af6bb9dbb7196f9905e31 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 13:50:15 +0800 Subject: [PATCH 027/486] chore(awd): cargo fmt --- apps/api/tests/awd_scenarios.rs | 56 +++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs index 1060b47f..26e22a2d 100644 --- a/apps/api/tests/awd_scenarios.rs +++ b/apps/api/tests/awd_scenarios.rs @@ -231,46 +231,54 @@ async fn scenario_e_network_failure_fail_closed() { /// Scenario F:多赛事互不干扰(P5-6)——两个事件不同 phase 同时存在于 desired set。 #[tokio::test] async fn scenario_f_multi_event_desired_state() { - let Some(db) = connect_or_skip().await else { return }; + let Some(db) = connect_or_skip().await else { + return; + }; let e1 = seed_running_event(&db, "multiA").await; let e2 = seed_running_event(&db, "multiB").await; // 把 e1 置为 Attack(round 2 phase),e2 保持 Hardening - let row1 = event_repo::find_by_event_id(&db, e1).await.unwrap().unwrap(); + let row1 = event_repo::find_by_event_id(&db, e1) + .await + .unwrap() + .unwrap(); event_repo::update_phase(&db, row1.id, AwdPhase::Attack) .await .expect("e1 attack"); - let desired = floatctf::modules::event::awd_team::service::firewall_service::build_desired_state( - &db, - 1, - ) - .await - .expect("build desired"); - let keys: Vec = desired.event_keys().into_iter().map(|s| s.to_string()).collect(); + let desired = + floatctf::modules::event::awd_team::service::firewall_service::build_desired_state(&db, 1) + .await + .expect("build desired"); + let keys: Vec = desired + .event_keys() + .into_iter() + .map(|s| s.to_string()) + .collect(); // 断言我们的两个赛事都在(不依赖库内无其他赛事) - let k1 = floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( - &e1, - ) - .as_str() - .to_string(); - let k2 = floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key( - &e2, - ) - .as_str() - .to_string(); + let k1 = + floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key(&e1) + .as_str() + .to_string(); + let k2 = + floatctf::modules::event::awd_team::infrastructure::firewall::NftObjectName::event_key(&e2) + .as_str() + .to_string(); assert!(keys.contains(&k1), "e1 missing: {keys:?}"); assert!(keys.contains(&k2), "e2 missing: {keys:?}"); // 两个赛事 key 都出现 → 渲染含两个 event chain(Event A 更新不影响 Event B) - let rendered = floatctf::modules::event::awd_team::infrastructure::firewall::render::render_table( - &desired, - ); + let rendered = + floatctf::modules::event::awd_team::infrastructure::firewall::render::render_table( + &desired, + ); for k in &keys { - assert!(rendered.contains(&format!("chain event_{k}")), "missing {k}"); + assert!( + rendered.contains(&format!("chain event_{k}")), + "missing {k}" + ); } cleanup_event(&db, e1).await; cleanup_event(&db, e2).await; } - From a16ae653b7f46cdd8d5766c3fc0d7d27fde86e09 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 15:40:48 +0800 Subject: [PATCH 028/486] =?UTF-8?q?feat(web):=20AWD=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B0=81=E7=A6=81/=E8=BD=AE=E6=8D=A2/?= =?UTF-8?q?=E8=B0=83=E5=88=86=E6=93=8D=E4=BD=9C=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - teams.tsx:Banned/Unbanned 改走 AWD ban_service 跨层闭环 (POST/DELETE /events/{id}/awd/teams/{tid}/ban),支持时长输入 (>0 触发 P4-7 自动解封任务),错误经 banner 展示 - ops.tsx:新增 Rotate Tokens 按钮(confirm 确认,P3-10 轮换+审计) 与 Score Adjust 表单(队伍/增量/原因,P5-11 审计) - api/awd.ts:awdAdminApi 新增 banTeam/unbanTeam/rotateTokens/adjustScore - ban_service:封禁/解封同步 event_teams.banned 展示标志(best-effort, 玩家侧门禁仍以 awd_team_bans 为准) - 验证:tsc --noEmit + vite build 通过;cargo test -p floatctf 全绿 --- .../event/awd_team/service/ban_service.rs | 34 +++++++- apps/web/src/api/awd.ts | 41 +++++++++ .../src/routes/admin/events/awd.$id/ops.tsx | 87 ++++++++++++++++++- .../src/routes/admin/events/awd.$id/teams.tsx | 77 ++++++++++------ 4 files changed, 208 insertions(+), 31 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/service/ban_service.rs b/apps/api/src/modules/event/awd_team/service/ban_service.rs index ac3c1f5a..22a83142 100644 --- a/apps/api/src/modules/event/awd_team/service/ban_service.rs +++ b/apps/api/src/modules/event/awd_team/service/ban_service.rs @@ -18,10 +18,11 @@ //! 不依赖 nft table 中旧 ban elements 作为事实源。 //! ``` -use sea_orm::DatabaseConnection; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, sea_query::Expr}; use tracing::info; use uuid::Uuid; +use crate::entity::event_teams; use crate::infrastructure::realtime::EventPublisher; use crate::modules::event::awd_team::{ AwdError, AwdResult, @@ -58,7 +59,22 @@ pub async fn ban_team( .await .map_err(|e| AwdError::Database(e.to_string()))?; - // 2. WG host 挂起(DB 保持 Active) + // 2. 与通用 event_teams.banned 标志同步(仅供前端展示;玩家侧门禁以 awd_team_bans 为准) + // best-effort:标志不同步不应阻断 AWD 跨层封禁(WG/banned set/conntrack 才是安全关键) + if let Err(e) = event_teams::Entity::update_many() + .filter( + event_teams::Column::Id + .eq(team_id) + .and(event_teams::Column::EventId.eq(event_id)), + ) + .col_expr(event_teams::Column::Banned, Expr::value(true)) + .exec(db) + .await + { + tracing::error!("[Ban] sync event_teams.banned flag failed: {e}"); + } + + // 3. WG host 挂起(DB 保持 Active) let removed = wireguard_service::suspend_team_peers_from_host(db, network, event_id, team_id).await?; info!( @@ -121,6 +137,20 @@ pub async fn unban_team( .await .map_err(|e| AwdError::Database(e.to_string()))?; + // 1b. 通用标志同步(best-effort,展示用) + if let Err(e) = event_teams::Entity::update_many() + .filter( + event_teams::Column::Id + .eq(team_id) + .and(event_teams::Column::EventId.eq(event_id)), + ) + .col_expr(event_teams::Column::Banned, Expr::value(false)) + .exec(db) + .await + { + tracing::error!("[Unban] sync event_teams.banned flag failed: {e}"); + } + // 2. WG host 恢复 Active peers(幂等) wireguard_service::restore_active_peers_to_host(db, network, event_id).await?; diff --git a/apps/web/src/api/awd.ts b/apps/web/src/api/awd.ts index 7d0c137a..04ef6f97 100644 --- a/apps/web/src/api/awd.ts +++ b/apps/web/src/api/awd.ts @@ -75,6 +75,47 @@ export const awdAdminApi = { ); return res.data; }, + /** P4-5/P4-6:AWD 跨层封禁(WG suspend + banned set reconcile + conntrack + publish)。 */ + banTeam: async ( + eventId: string, + teamId: string, + body: { reason?: string; durationSecs?: number }, + ): Promise> => { + const res = await admin_api.post( + `/events/${eventId}/awd/teams/${teamId}/ban`, + { + reason: body.reason, + duration_secs: body.durationSecs, + }, + ); + return res.data; + }, + /** P4-5:AWD 解封(反向闭环:DB unbanned → WG 恢复 peers → banned set reconcile)。 */ + unbanTeam: async ( + eventId: string, + teamId: string, + ): Promise> => { + const res = await admin_api.delete( + `/events/${eventId}/awd/teams/${teamId}/ban`, + ); + return res.data; + }, + /** P3-10:内部 token 轮换(key_version+1 + 容器 rollout + 审计)。 */ + rotateTokens: async (eventId: string): Promise> => { + const res = await admin_api.post(`/events/${eventId}/awd/tokens/rotate`); + return res.data; + }, + /** P5-11:AWD 分数调整(审计)。 */ + adjustScore: async ( + eventId: string, + body: { team_id: string; delta: number; reason: string }, + ): Promise> => { + const res = await admin_api.post( + `/events/${eventId}/awd/score/adjust`, + body, + ); + return res.data; + }, }; /** Player AWD endpoints (User JWT). */ diff --git a/apps/web/src/routes/admin/events/awd.$id/ops.tsx b/apps/web/src/routes/admin/events/awd.$id/ops.tsx index 5a9f7d6a..b4685f26 100644 --- a/apps/web/src/routes/admin/events/awd.$id/ops.tsx +++ b/apps/web/src/routes/admin/events/awd.$id/ops.tsx @@ -1,6 +1,7 @@ -import { Button, ButtonGroup, Spinner } from "@primer/react"; +import { Button, ButtonGroup, Spinner, TextInput } from "@primer/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; import { adminApi } from "@/api"; import { useMsgBanner } from "@/components"; @@ -62,6 +63,31 @@ function RouteComponent() { onSuccess: onOk("Archive"), onError: banner.showErrorBanner, }); + const rotate = useMutation({ + mutationFn: () => adminApi.awd.rotateTokens(id), + onSuccess: onOk("Token Rotated"), + onError: banner.showErrorBanner, + }); + + // Score Adjust(P5-11 审计) + const [adjTeam, setAdjTeam] = useState(""); + const [adjDelta, setAdjDelta] = useState("0"); + const [adjReason, setAdjReason] = useState(""); + const adjust = useMutation({ + mutationFn: () => + adminApi.awd.adjustScore(id, { + team_id: adjTeam, + delta: parseInt(adjDelta, 10) || 0, + reason: adjReason.trim() || "manual adjustment", + }), + onSuccess: () => { + banner.showBanner("success", "Score adjusted"); + qc.invalidateQueries({ queryKey: ["admin-awd-scores", id] }); + setAdjDelta("0"); + setAdjReason(""); + }, + onError: banner.showErrorBanner, + }); const pending = deploy.isPending || @@ -70,7 +96,9 @@ function RouteComponent() { pause.isPending || resume.isPending || finish.isPending || - archive.isPending; + archive.isPending || + rotate.isPending || + adjust.isPending; const rows = scores.data?.data ?? []; @@ -109,6 +137,20 @@ function RouteComponent() { > Archive + {pending && ( @@ -117,6 +159,47 @@ function RouteComponent() { )} +
+

Score Adjust (audited)

+
+ + setAdjDelta(e.target.value)} + disabled={adjust.isPending} + style={{ width: 180 }} + /> + setAdjReason(e.target.value)} + disabled={adjust.isPending} + style={{ width: 240 }} + /> + +
+
+

Scoreboard

{scores.isLoading ? ( diff --git a/apps/web/src/routes/admin/events/awd.$id/teams.tsx b/apps/web/src/routes/admin/events/awd.$id/teams.tsx index 2db268ea..4e557b4c 100644 --- a/apps/web/src/routes/admin/events/awd.$id/teams.tsx +++ b/apps/web/src/routes/admin/events/awd.$id/teams.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { Fragment } from "react"; import { adminApi } from "@/api"; -import { GenericTable } from "@/components"; +import { GenericTable, useMsgBanner } from "@/components"; import type { EventTeamMemberRole, EventTeams } from "@/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -33,20 +33,43 @@ function RouteComponent() { const { id } = Route.useParams(); const subject = `EventTeams-${id}`; const queryClient = useQueryClient(); + const banner = useMsgBanner({}); + const onDone = () => { + queryClient.invalidateQueries({ queryKey: [subject] }); + }; const bannedEventTeam = useMutation({ - mutationFn: adminApi.event_teams.banned, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [subject] }); - }, + mutationFn: ({ eventId, teamId, body }: { + eventId: string; + teamId: string; + body: { reason?: string; durationSecs?: number }; + }) => adminApi.awd.banTeam(eventId, teamId, body), + onSuccess: onDone, + onError: banner.showErrorBanner, }); const unbannedEventTeam = useMutation({ - mutationFn: adminApi.event_teams.unbanned, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [subject] }); - }, + mutationFn: ({ eventId, teamId }: { eventId: string; teamId: string }) => + adminApi.awd.unbanTeam(eventId, teamId), + onSuccess: onDone, + onError: banner.showErrorBanner, }); + // 封禁:询问时长(秒,留空/0 = 永久;>0 触发 P4-7 自动解封任务) + const askBan = (teamId: string) => { + const input = window.prompt( + "AWD 跨层封禁(WG 挂起 + 防火墙 banned set + 连接清理)\n封禁时长(秒),留空为永久封禁;\n例如:3600 = 1 小时后自动解封", + ); + if (input === null) return; // 取消 + const n = parseInt(input, 10); + bannedEventTeam.mutate({ + eventId: id, + teamId, + body: { + durationSecs: Number.isFinite(n) && n > 0 ? n : undefined, + }, + }); + }; + const columns = [ { accessorKey: "team.id", @@ -136,8 +159,8 @@ function RouteComponent() { variant="default" onSelect={() => { unbannedEventTeam.mutate({ - event_id: id, - team_id: row.team.id, + eventId: id, + teamId: row.team.id, }); }} > @@ -147,13 +170,10 @@ function RouteComponent() { { - bannedEventTeam.mutate({ - event_id: id, - team_id: row.team.id, - }); + askBan(row.team.id); }} > - Banned + Ban (AWD) )} @@ -162,16 +182,19 @@ function RouteComponent() { const filterKeys = ["id", "name", "points", "banned"]; return ( - row.team.id} - filterKeys={filterKeys} - /> +
+ + row.team.id} + filterKeys={filterKeys} + /> +
); } From ae9cb60981a5cd1b448f31c0097afed00bcad17e Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 15:49:58 +0800 Subject: [PATCH 029/486] =?UTF-8?q?fix(awd):=20resume=20=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=20round=20=E8=B0=83=E5=BA=A6=E4=BB=BB=E5=8A=A1=20+=20judge=20?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E5=86=BB=E7=BB=93=E9=97=A8=E7=A6=81=EF=BC=88?= =?UTF-8?q?plan=20=E9=81=97=E6=BC=8F=E4=BF=AE=E5=A4=8D=20#1/#2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 P4-9:resume_event 网络应用成功后调用 restore_round_scheduling 重建 round.end/grace_end 任务(暂停期间原任务已被消费),修复 pause/resume 后比赛永久卡死的 bug;新增 DB-gated 回归测试 #2 铁律 #8:judge_callback 增加冻结门禁——事件非 Running 或 phase 不 允许 judge(NetworkError/Pause/Finished)时任务结果仍记录但不写分, 补齐 submit 已有门禁之外的 judge 计分路径 --- .../modules/event/awd_team/api/internal.rs | 26 +++++- .../event/awd_team/service/event_service.rs | 8 ++ apps/api/tests/awd_scenarios.rs | 79 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/api/internal.rs b/apps/api/src/modules/event/awd_team/api/internal.rs index 1236b865..c95e8ad9 100644 --- a/apps/api/src/modules/event/awd_team/api/internal.rs +++ b/apps/api/src/modules/event/awd_team/api/internal.rs @@ -9,11 +9,11 @@ use uuid::Uuid; use crate::{ api::{AppError, UniResponse, UniResult, prelude::*}, - entity::sea_orm_active_enums::{JudgeTaskStatus, ScoreEventType}, + entity::sea_orm_active_enums::{AwdEventStatus, JudgeTaskStatus, ScoreEventType}, modules::event::awd_team::{ AwdError, api::auth::{AwdInternalAuth, AwdInternalPrincipal}, - domain::JudgeTaskStatusExt, + domain::{AwdPhaseExt, JudgeTaskStatusExt}, repo::{event_repo, judge_repo, round_repo, score_repo}, service::flag_service, }, @@ -138,6 +138,16 @@ pub async fn judge_callback( return UniResponse::ok_none().into(); } + // 铁律 #8 冻结门禁:事件不在 Running 或 phase 不允许 judge(NetworkError/ + // Pause/Finished 等)时,任务结果仍记录(避免僵尸重试),但**不写分**。 + // 注:submit 路径门禁在 flag_service::validate_submission(is_active + phase + + // round + ban);此处补齐 judge 路径,保证冻结期零 judge score。 + let awd_event = event_repo::find_by_event_id(ctx.db.get_ref(), event_id) + .await + .map_err(|e| AppError::Database(e.to_string()))? + .ok_or_else(|| AppError::NotFound("AWD event not found".into()))?; + let frozen = awd_event.status != AwdEventStatus::Running || !awd_event.phase.allows_judge(); + // Parse status from callback let status = match cb.status.as_str() { "up" => JudgeTaskStatus::Up, @@ -163,8 +173,8 @@ pub async fn judge_callback( .await .map_err(|e| AppError::Database(e.to_string()))?; - // If up/down, record score - if is_up || is_down { + // If up/down, record score (跳过冻结事件) + if (is_up || is_down) && !frozen { // Get the template for scoring values use crate::entity::awd_gamebox_templates; let template = awd_gamebox_templates::Entity::find_by_id(task.template_id) @@ -223,6 +233,14 @@ pub async fn judge_callback( return Err(AppError::Internal(format!("score write failed: {e}"))); } } + } else if is_up || is_down { + // 冻结期间记录但不计分(铁律 #8):状态/phase = {}/{}。 + tracing::info!( + "[Judge] event {event_id} frozen (status={:?}, phase={:?}): task {} result recorded WITHOUT score", + awd_event.status, + awd_event.phase, + cb.task_id + ); } UniResponse::ok_none().into() diff --git a/apps/api/src/modules/event/awd_team/service/event_service.rs b/apps/api/src/modules/event/awd_team/service/event_service.rs index 221aac74..8c59aa46 100644 --- a/apps/api/src/modules/event/awd_team/service/event_service.rs +++ b/apps/api/src/modules/event/awd_team/service/event_service.rs @@ -1,6 +1,7 @@ //! Event lifecycle service — AWD event CRUD, deployment, and phase management. use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection}; +use tracing::info; use uuid::Uuid; use crate::entity::sea_orm_active_enums::{AwdEventStatus, AwdPhase, RoundStatus}; @@ -229,6 +230,13 @@ pub async fn resume_event( Ok(_) => { firewall_service::flush_event_connections(network, event_id, &awd_event.gamebox_cidr) .await; + + // P4-9 修复:暂停期间原 round.end/grace_end 任务已被消费(触发时 round 非 + // Active/Grace 而幂等跳过);resume 后必须按新 deadline 重建,否则比赛卡死 + let restored = round_service::restore_round_scheduling(db, event_id).await?; + if restored > 0 { + info!("[Resume] event {event_id}: rebuilt {restored} round scheduling task(s)"); + } Ok(()) } Err(e) => { diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs index 26e22a2d..7f9fbc4d 100644 --- a/apps/api/tests/awd_scenarios.rs +++ b/apps/api/tests/awd_scenarios.rs @@ -282,3 +282,82 @@ async fn scenario_f_multi_event_desired_state() { cleanup_event(&db, e1).await; cleanup_event(&db, e2).await; } + +/// P4-9 回归:pause/resume 后 round.end 调度任务重建(修复卡死 bug)。 +/// 真实路径:暂停期间 RoundEnd 任务触发(round 非 Active 幂等跳过)后被消费; +/// 测试模拟「任务被消费」,resume 后断言新的 pending awd.round.end 任务已按新 deadline 重建。 +#[tokio::test] +async fn pause_resume_rebuilds_round_end_task() { + use floatctf::entity::{awd_rounds, scheduled_tasks, sea_orm_active_enums::RoundStatus}; + use floatctf::modules::event::awd_team::{ + infrastructure::firewall::NoopFirewallRuntime, service::event_service, + }; + + let Some(db) = connect_or_skip().await else { + return; + }; + let event_id = seed_running_event(&db, "pauseresume").await; + let network = NoopNetworkRuntime; + let firewall = NoopFirewallRuntime; + let publisher = NoopEventPublisher; + + // Round 1 启动(创建 RoundEnd 任务) + let r1 = round_service::start_round(&db, &network, &firewall, &publisher, event_id, Some(1)) + .await + .expect("round 1 start"); + let pending_after_start = scheduled_tasks::Entity::find() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq("awd.round.end")) + .filter(scheduled_tasks::Column::Status.eq("pending")) + .one(&db) + .await + .unwrap() + .expect("round end task scheduled at start"); + let original_execute_at = pending_after_start.execute_at; + + // Pause(round → Paused) + event_service::pause_event(&db, &network, &firewall, event_id) + .await + .expect("pause"); + + // 模拟暂停期间任务被消费(真实场景:触发后幂等跳过并标记完成) + scheduled_tasks::Entity::delete_many() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq("awd.round.end")) + .exec(&db) + .await + .expect("consume round end task"); + + // Resume + event_service::resume_event(&db, &network, &firewall, event_id) + .await + .expect("resume"); + + // 断言:round 回到 Active,且新的 pending round.end 任务已重建 + let round = awd_rounds::Entity::find_by_id(r1.round_id) + .one(&db) + .await + .unwrap() + .unwrap(); + assert_eq!(round.status, RoundStatus::Active, "round resumes Active"); + + let rebuilt = scheduled_tasks::Entity::find() + .filter(scheduled_tasks::Column::GroupId.eq(event_id)) + .filter(scheduled_tasks::Column::TaskKey.eq("awd.round.end")) + .filter(scheduled_tasks::Column::Status.eq("pending")) + .one(&db) + .await + .unwrap() + .expect("round end task rebuilt after resume"); + assert_eq!( + rebuilt.execute_at.unwrap(), + round.scheduled_end_at, + "rebuilt task deadline == resumed round scheduled_end_at" + ); + assert_ne!( + rebuilt.execute_at, original_execute_at, + "rebuilt deadline differs from pre-pause deadline" + ); + + cleanup_event(&db, event_id).await; +} From 686214c98e20d720f2e34e37a5069b3acaa56627 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 15:51:50 +0800 Subject: [PATCH 030/486] =?UTF-8?q?fix(awd):=20token=20=E8=BD=AE=E6=8D=A2?= =?UTF-8?q?=E6=8E=A2=E6=B4=BB=20+=20recovery=20=E8=8C=83=E5=9B=B4=E6=89=A9?= =?UTF-8?q?=E5=B1=95=EF=BC=88plan=20=E9=81=97=E6=BC=8F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20#3/#4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3 验收 #16:rollout 后 wait_for_tcp_ready 探活(TCP connect 固定 IP:8080, 最多 24×500ms),不健康则报错可重跑,两段都健康才算完成 #4 验收 #11:find_active_events 扩展至 Verified/Deploying/Deployed/Prechecking, 比赛前状态崩溃也纳入 gamebox/WG/firewall 幂等收敛;DeployFailed/ NetworkError/StartBlocked 保持冻结态不动 --- .../src/modules/event/awd_team/api/admin.rs | 36 ++++++++++++++++++- .../modules/event/awd_team/repo/event_repo.rs | 14 +++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index 8f9ee1e8..b7c170b3 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -701,6 +701,32 @@ pub async fn rotate_tokens( } /// 重建一个 infra 容器(stop → create,同 fixed_ip/网络,env 带新 token)。 +/// 探活:固定 IP:端口 TCP connect,最多 ~12s(24 × 500ms),容器启动需要时间。 +async fn wait_for_tcp_ready(host: &str, port: u16) -> bool { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use tokio::net::TcpStream; + use tokio::time::{Duration, sleep, timeout}; + + let addr = match host.parse::() { + Ok(ip) => SocketAddr::new(ip, port), + Err(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port), + }; + for attempt in 0..24u32 { + if timeout(Duration::from_millis(500), TcpStream::connect(addr)) + .await + .is_ok_and(|r| r.is_ok()) + { + if attempt > 0 { + tracing::info!("[Rotate] health probe ok after {attempt} attempt(s): {addr}"); + } + return true; + } + sleep(Duration::from_millis(500)).await; + } + tracing::warn!("[Rotate] health probe failed (24 attempts): {addr}"); + false +} + async fn rollout_infra_container( db: &sea_orm::DatabaseConnection, containers: &dyn fcmc::AwdContainerRuntime, @@ -741,6 +767,15 @@ async fn rollout_infra_container( )) })?; + // 验收 #16:两段健康才完成——重建后探活固定 IP:8080(TCP connect 证明容器 + // 监听中;app 级 /health 校验留待 Judgeserver 自身),未就绪视为失败可重跑 + if !wait_for_tcp_ready(fixed_ip, 8080).await { + return Err(AppError::Internal(format!( + "token rotation DB committed but {kind} container not healthy after rollout " + ))); + } + tracing::info!("[Rotate] {kind} container healthy after rollout ({container_name})"); + // 更新 runtime resource 记录的 container_id(rollout 后容器 id 变化) use crate::entity::awd_runtime_resources; let updated = awd_runtime_resources::Entity::update_many() @@ -756,7 +791,6 @@ async fn rollout_infra_container( if updated.rows_affected == 0 { tracing::warn!("[Rotate] no awd_runtime_resources row for {kind} — recorded manually"); } - let _ = awd_event; tracing::info!("[Rotate] {kind} container rolled out as {}", container_name); UniResponse::ok_none().into() diff --git a/apps/api/src/modules/event/awd_team/repo/event_repo.rs b/apps/api/src/modules/event/awd_team/repo/event_repo.rs index f57fe120..ad14752d 100644 --- a/apps/api/src/modules/event/awd_team/repo/event_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/event_repo.rs @@ -181,8 +181,20 @@ pub async fn find_by_event_id( pub async fn find_active_events( db: &C, ) -> Result, sea_orm::DbErr> { + // 验收 #11 范围扩展:除 Running/Paused(完整恢复 gamebox+WG+round), + // 覆盖比赛前状态(Verified/Deploying/Deployed/Prechecking)——这些阶段崩溃 + // 会留下半管理资源,重启后同样需要 gamebox/WG/firewall 收敛(reconcile 幂等); + // DeployFailed/NetworkError/StartBlocked 保持不动(冻结/失败态,交给管理员或 + // Start Gate 判定)。 awd_events::Entity::find() - .filter(awd_events::Column::Status.is_in([AwdEventStatus::Running, AwdEventStatus::Paused])) + .filter(awd_events::Column::Status.is_in([ + AwdEventStatus::Running, + AwdEventStatus::Paused, + AwdEventStatus::Verified, + AwdEventStatus::Deploying, + AwdEventStatus::Deployed, + AwdEventStatus::Prechecking, + ])) .all(db) .await } From b0fc0f1732245af772f6c07da90fefce408827a6 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 16:09:28 +0800 Subject: [PATCH 031/486] =?UTF-8?q?fix(awd):=20first-blood=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=A2=E5=A4=B1=E6=94=BB=E5=87=BB=E5=88=86=E7=9A=84?= =?UTF-8?q?=20PG=20=E4=BA=8B=E5=8A=A1=20abort=20bug=20+=20P5-9=20=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=96=AD=E8=A8=80=EF=BC=88plan=20=E9=81=97=E6=BC=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20#5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5-9-2 测试暴露的真 bug:process_submission 第 5 步 first-bonus 用裸 INSERT,唯一键冲突会让 PostgreSQL 把整个事务置为 aborted,随后 COMMIT 静默变 ROLLBACK——败者的有效攻击分 + 受害者损失被一并丢弃(审计曾声称 'first-bonus 冲突不滚有效攻击',实际代码未满足)。 修复:score_repo 新增 create_score_event_if_absent(ON CONFLICT (idempotency_key) DO NOTHING,注意 sea-orm on_conflict_do_nothing 默认 目标是主键 id,必须显式指定冲突列),bonus 写入改用它,冲突只跳过 bonus 不影响同事务 attack/loss。 新增 3 个 DB-gated 并发断言: - 同 flag 100 并发 → 恰 1 次 attack + 1 次 victim loss - 两攻击队抢首杀 → 恰 1 次 FirstBonus(且双方 attack 都保留) - 同 callback_id 20 并发写分 → 恰 1 次 score mutation --- .../modules/event/awd_team/repo/score_repo.rs | 52 ++- .../awd_team/service/submission_service.rs | 25 +- apps/api/tests/awd_scenarios.rs | 390 ++++++++++++++++++ 3 files changed, 451 insertions(+), 16 deletions(-) diff --git a/apps/api/src/modules/event/awd_team/repo/score_repo.rs b/apps/api/src/modules/event/awd_team/repo/score_repo.rs index efd80543..eaab0f25 100644 --- a/apps/api/src/modules/event/awd_team/repo/score_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/score_repo.rs @@ -2,7 +2,7 @@ use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, - EntityTrait, QueryFilter, QueryOrder, + EntityTrait, QueryFilter, QueryOrder, QueryTrait, sea_query::OnConflict, }; use uuid::Uuid; @@ -39,6 +39,56 @@ pub async fn create_score_event( model.insert(db).await } +/// 幂等写入:`INSERT ... ON CONFLICT (idempotency_key) DO NOTHING`。 +/// +/// 返回 `true` 表示本次真正插入(首胜/新记录),`false` 表示键已存在。 +/// 与「裸 INSERT + 捕获 duplicate」的区别:ON CONFLICT DO NOTHING **不会**让 +/// PostgreSQL 把整个事务置为 aborted(裸 INSERT 的唯一冲突会 abort 事务, +/// 后续 COMMIT 静默变成 ROLLBACK,把同事务内已成功写入的有效记录一起丢掉)。 +/// +/// 用于事务内「可选写入」场景(如 first-blood bonus:冲突是预期事件而非错误)。 +pub async fn create_score_event_if_absent( + db: &impl ConnectionTrait, + event_id: Uuid, + round_id: Option, + team_id: Uuid, + event_type: ScoreEventType, + delta: i64, + idempotency_key: &str, + related_team_id: Option, + gamebox_instance_id: Option, + gamebox_template_id: Option, + reason: Option<&str>, +) -> Result { + let model = awd_score_events::ActiveModel { + id: Set(Uuid::new_v4()), + event_id: Set(event_id), + round_id: Set(round_id), + team_id: Set(team_id), + event_type: Set(event_type), + delta: Set(delta), + idempotency_key: Set(idempotency_key.to_string()), + related_team_id: Set(related_team_id), + gamebox_instance_id: Set(gamebox_instance_id), + gamebox_template_id: Set(gamebox_template_id), + reason: Set(reason.map(|s| s.to_string())), + ..Default::default() + }; + + // 显式冲突目标列:sea-orm 的 on_conflict_do_nothing() 默认针对主键 (id), + // 随机 UUID 永不冲突 → 仍会抛 idempotency_key 重复错误(还会 abort 事务)。 + use sea_orm::TryInsert; + let stmt = TryInsert::one(model) + .on_conflict( + OnConflict::column(awd_score_events::Column::IdempotencyKey) + .do_nothing() + .to_owned(), + ) + .build(db.get_database_backend()); + let res = db.execute(stmt).await?; + Ok(res.rows_affected() == 1) +} + /// Return the current total score for a team by summing deltas. pub async fn team_total_score( db: &DatabaseConnection, diff --git a/apps/api/src/modules/event/awd_team/service/submission_service.rs b/apps/api/src/modules/event/awd_team/service/submission_service.rs index 8caaa810..c62e495e 100644 --- a/apps/api/src/modules/event/awd_team/service/submission_service.rs +++ b/apps/api/src/modules/event/awd_team/service/submission_service.rs @@ -142,10 +142,15 @@ pub async fn process_submission( .map_err(|e| AwdError::Database(format!("Victim loss scoring failed: {}", e)))?; // 5. Try first-blood bonus (once per template per event) + // + // 用 ON CONFLICT DO NOTHING 幂等写入:裸 INSERT 的唯一冲突会把整个 + // 事务置为 aborted(PostgreSQL 语义),随后 COMMIT 静默变 ROLLBACK—— + // 败者的攻击分 + 受害者损失会被一起丢掉。DO NOTHING 则把「bonus 已被 + // 别人抢先」当作正常结果,不影响同一事务里已成功的 attack/loss 写入。 let bonus_key = IdempotencyKey::first_bonus(&event_id.to_string(), &template_id.to_string()); - let bonus_result = score_repo::create_score_event( + let was_first_blood = score_repo::create_score_event_if_absent( tx, event_id, Some(round_id), @@ -158,20 +163,10 @@ pub async fn process_submission( Some(template_id), Some("first blood"), ) - .await; - - let was_first_blood = match &bonus_result { - Ok(_) => true, - Err(e) => { - let msg = e.to_string(); - if msg.contains("duplicate") || msg.contains("unique") { - false // already claimed by another team - } else { - return Err(AwdError::Database(msg)); - } - } - }; - + .await + .map_err(|e| { + AwdError::Database(format!("First blood bonus write failed: {}", e)) + })?; Ok(SubmissionResult { attack_score_delta: break_points, victim_loss_delta: loss_points, diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs index 7f9fbc4d..b391a0a6 100644 --- a/apps/api/tests/awd_scenarios.rs +++ b/apps/api/tests/awd_scenarios.rs @@ -11,6 +11,7 @@ use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, }; +use std::sync::Arc; use uuid::Uuid; use floatctf::entity::{ @@ -361,3 +362,392 @@ async fn pause_resume_rebuilds_round_end_task() { cleanup_event(&db, event_id).await; } + +// ── P5-9 并发正确性种子 ────────────────────────────────────────────── + +fn hash_flag(f: &str) -> String { + // 与生产 hash_flag 一致:SHA-256 hex + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(f.as_bytes())) +} + +/// 种子:完整提交链(event Running/Attack + round Active + issue + teams + instance + template + user)。 +async fn seed_submission_fixture( + db: &sea_orm::DatabaseConnection, + tag: &str, + first_bonus: i64, +) -> ( + Uuid, // event_id + Uuid, // round_id + Uuid, // attacker_team_id + Uuid, // victim_team_id + Uuid, // instance_id + Uuid, // template_id + Uuid, // user_id + Uuid, // flag_issue_id +) { + use floatctf::entity::{ + awd_flag_issues, awd_gamebox_instances, awd_gamebox_templates, awd_rounds, event_teams, + sea_orm_active_enums::{AwdPhase, GameboxStatus, RoundStatus}, + users, + }; + + let event_id = seed_running_event(db, tag).await; + let now = chrono::Utc::now(); + + // Round 1(Active, Attack) + let round_id = Uuid::new_v4(); + awd_rounds::ActiveModel { + id: Set(round_id), + event_id: Set(event_id), + round_number: Set(1), + status: Set(RoundStatus::Active), + phase: Set(AwdPhase::Attack), + started_at: Set(now.into()), + scheduled_end_at: Set((now + chrono::Duration::minutes(10)).into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert round"); + + // Teams + let attacker_team_id = Uuid::new_v4(); + event_teams::ActiveModel { + id: Set(attacker_team_id), + event_id: Set(event_id), + name: Set(format!("attacker-{tag}")), + points: Set(0.0), + banned: Set(false), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert attacker team"); + let victim_team_id = Uuid::new_v4(); + event_teams::ActiveModel { + id: Set(victim_team_id), + event_id: Set(event_id), + name: Set(format!("victim-{tag}")), + points: Set(0.0), + banned: Set(false), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert victim team"); + + // Template + let template_id = Uuid::new_v4(); + awd_gamebox_templates::ActiveModel { + id: Set(template_id), + event_id: Set(event_id), + name: Set(format!("tmpl-{tag}")), + image_ref: Set("fctf/test:latest".into()), + username: Set("root".into()), + meta_json: Set(serde_json::json!({})), + cpu_millis: Set(1000), + memory_bytes: Set(512 * 1024 * 1024), + pids_limit: Set(256), + break_points: Set(100), + loss_points: Set(50), + fix_points: Set(80), + down_points: Set(60), + first_bonus: Set(first_bonus), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert template"); + + // Instance + let instance_id = Uuid::new_v4(); + awd_gamebox_instances::ActiveModel { + id: Set(instance_id), + event_id: Set(event_id), + template_id: Set(template_id), + team_id: Set(victim_team_id), + status: Set(GameboxStatus::Ready), + container_name: Set(format!("fctf-gb-{}-{tag}", &event_id.to_string()[..8])), + gamebox_ip: Set("10.42.1.10".into()), + health_status: Set("healthy".into()), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert instance"); + + // Flag issue(固定 id,供并发提交复用同一 issue) + let flag_issue_id = Uuid::new_v4(); + awd_flag_issues::ActiveModel { + id: Set(flag_issue_id), + event_id: Set(event_id), + round_id: Set(round_id), + gamebox_instance_id: Set(instance_id), + flag_hash: Set(hash_flag(&format!("flag{{concurrent-{tag}}}"))), + issued_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert flag issue"); + + // User(username 全局唯一,随机后缀避免重跑残留冲突) + let user_id = Uuid::new_v4(); + let uname = format!("user-{tag}-{}", &user_id.to_string().replace('-', "")[..8]); + users::ActiveModel { + id: Set(user_id), + username: Set(uname.clone()), + nickname: Set(uname.clone()), + password: Set("x".into()), + email: Set(format!("{uname}@test.invalid")), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert user"); + + ( + event_id, + round_id, + attacker_team_id, + victim_team_id, + instance_id, + template_id, + user_id, + flag_issue_id, + ) +} + +/// P5-9-1:同一 flag 并发 submit 100 次 → 只有一次 attack score + 一次 victim loss。 +#[tokio::test] +async fn load_concurrent_same_flag_scores_once() { + use floatctf::entity::awd_flag_submissions; + use floatctf::modules::event::awd_team::{ + AwdError, domain::score::ScoreEventType, service::submission_service, + }; + + let Some(db) = connect_or_skip().await else { + return; + }; + let (event_id, round_id, attacker, victim, instance, template, user, flag_issue) = + seed_submission_fixture(&db, "sameflag", 0).await; + + let publisher = Arc::new(NoopEventPublisher); + let db = Arc::new(db); + let mut handles = Vec::new(); + for _ in 0..100 { + let db = db.clone(); + let pub_ = publisher.clone(); + handles.push(tokio::spawn(async move { + submission_service::process_submission( + &db, event_id, round_id, flag_issue, attacker, victim, instance, user, 100, 50, 0, + template, &*pub_, + ) + .await + })); + } + let mut ok = 0usize; + let mut conflicts = 0usize; + for h in handles { + match h.await.expect("join") { + Ok(_) => ok += 1, + // 事务内冲突经 db.transaction 包装为 Database("Transaction failed: ..."), + // 与直接 Conflict 等价(同一语义:重复提交被拒) + Err(AwdError::Conflict(_)) => conflicts += 1, + Err(AwdError::Database(m)) if m.contains("Already submitted") => conflicts += 1, + Err(e) => panic!("unexpected error: {e}"), + } + } + assert_eq!(ok, 1, "exactly one submission succeeds"); + assert_eq!(conflicts, 99, "rest rejected as conflict"); + + let subs = awd_flag_submissions::Entity::find() + .filter(awd_flag_submissions::Column::EventId.eq(event_id)) + .all(db.as_ref()) + .await + .unwrap(); + assert_eq!(subs.len(), 1, "exactly one submission row"); + + let events = floatctf::entity::awd_score_events::Entity::find() + .filter(floatctf::entity::awd_score_events::Column::EventId.eq(event_id)) + .all(db.as_ref()) + .await + .unwrap(); + let attack = events + .iter() + .filter(|e| e.event_type == ScoreEventType::Attack) + .count(); + let loss = events + .iter() + .filter(|e| e.event_type == ScoreEventType::VictimLoss) + .count(); + assert_eq!(attack, 1, "exactly one attack score"); + assert_eq!(loss, 1, "exactly one victim loss"); + + cleanup_event(db.as_ref(), event_id).await; + let _ = floatctf::entity::users::Entity::delete_by_id(user) + .exec(db.as_ref()) + .await; +} + +/// P5-9-2:first blood 并发(两支攻击队抢同一模板首杀)→ 只有一次 FirstBonus。 +#[tokio::test] +async fn load_concurrent_first_blood_scores_once() { + use floatctf::entity::event_teams; + use floatctf::modules::event::awd_team::{ + domain::score::ScoreEventType, repo::score_repo, service::submission_service, + }; + + let Some(db) = connect_or_skip().await else { + return; + }; + let (event_id, round_id, attacker_a, victim, instance, template, user, flag_issue) = + seed_submission_fixture(&db, "firstblood", 100).await; + + // 第二支攻击队 + let attacker_b = Uuid::new_v4(); + let now = chrono::Utc::now(); + event_teams::ActiveModel { + id: Set(attacker_b), + event_id: Set(event_id), + name: Set("attacker-b-firstblood".into()), + points: Set(0.0), + banned: Set(false), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(&db) + .await + .expect("insert attacker B"); + + let publisher = Arc::new(NoopEventPublisher); + let r1 = submission_service::process_submission( + &db, + event_id, + round_id, + flag_issue, + attacker_a, + victim, + instance, + user, + 100, + 50, + 100, + template, + &*publisher, + ) + .await; + let r2 = submission_service::process_submission( + &db, + event_id, + round_id, + flag_issue, + attacker_b, + victim, + instance, + user, + 100, + 50, + 100, + template, + &*publisher, + ) + .await; + let sub_rows = floatctf::entity::awd_flag_submissions::Entity::find() + .filter(floatctf::entity::awd_flag_submissions::Column::EventId.eq(event_id)) + .all(&db) + .await + .unwrap(); + let oks = [&r1, &r2].iter().filter(|r| r.is_ok()).count(); + assert_eq!(oks, 2, "both attackers score their attack"); + + let events = floatctf::entity::awd_score_events::Entity::find() + .filter(floatctf::entity::awd_score_events::Column::EventId.eq(event_id)) + .all(&db) + .await + .unwrap(); + let attack = events + .iter() + .filter(|e| e.event_type == ScoreEventType::Attack) + .count(); + let bonus = events + .iter() + .filter(|e| e.event_type == ScoreEventType::FirstBonus) + .count(); + assert_eq!(attack, 2, "two attacks"); + assert_eq!(bonus, 1, "exactly one first-blood bonus"); + + cleanup_event(&db, event_id).await; + let _ = floatctf::entity::users::Entity::delete_by_id(user) + .exec(&db) + .await; +} + +/// P5-9-3:judge callback 重试(同一 callback_id 并发写分)→ 只有一次 score mutation。 +#[tokio::test] +async fn load_concurrent_judge_callback_scores_once() { + use floatctf::modules::event::awd_team::{domain::score::ScoreEventType, repo::score_repo}; + + let Some(db) = connect_or_skip().await else { + return; + }; + let (event_id, round_id, attacker, _victim, _instance, template, user, _issue) = + seed_submission_fixture(&db, "judgeretry", 0).await; + + let db = Arc::new(db); + let mut handles = Vec::new(); + for _ in 0..20 { + let db = db.clone(); + handles.push(tokio::spawn(async move { + score_repo::create_score_event( + db.as_ref(), + event_id, + Some(round_id), + attacker, + ScoreEventType::JudgeFix, + 80, + "callback-retry-test-key", + None, + None, + Some(template), + Some("judge check"), + ) + .await + })); + } + let mut ok = 0usize; + for h in handles { + if h.await.expect("join").is_ok() { + ok += 1; + } + } + assert_eq!(ok, 1, "only one idempotency-key write succeeds"); + + let events = floatctf::entity::awd_score_events::Entity::find() + .filter(floatctf::entity::awd_score_events::Column::EventId.eq(event_id)) + .all(db.as_ref()) + .await + .unwrap(); + let judge_fix = events + .iter() + .filter(|e| e.event_type == ScoreEventType::JudgeFix) + .count(); + assert_eq!(judge_fix, 1, "exactly one score mutation for the callback"); + + cleanup_event(db.as_ref(), event_id).await; + let _ = floatctf::entity::users::Entity::delete_by_id(user) + .exec(db.as_ref()) + .await; +} From b5981e2954d7f40d3a58b020249e5584586b1540 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 16:09:44 +0800 Subject: [PATCH 032/486] =?UTF-8?q?docs(awd):=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E9=97=A8=E6=8E=A7=E5=BE=85=E5=8A=9E=E6=B8=85=E5=8D=95=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20+=20=E6=9C=AC=E8=BD=AE=E9=81=97=E6=BC=8F=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/awd-plan-completion.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/awd-plan-completion.md b/docs/awd-plan-completion.md index cba74b87..1ac102ad 100644 --- a/docs/awd-plan-completion.md +++ b/docs/awd-plan-completion.md @@ -66,11 +66,32 @@ cargo check --workspace → 0 error ## 环境门控待办(需 root/Docker 宿主执行) -| 项 | 载体 | 前置 | -|---|---|---| -| P1-0 nftables 矩阵实测 | `sudo apps/api/scripts/nft_prototype.sh` | root + nf_tables | -| Scenario B/C/D/G/H/I + Host Safety | CI-host-network / 手工 | Docker + WG + root | -| Flag/Judge Docker smoke | `RUN_DOCKER_TESTS=1 apps/api/scripts/e2e_flag_judge.sh` | 补 `Dockerfile.awd-*` | +> 本机无免密 root(uid 1000),以下项无法在本机验证,交付载体 + 前置条件如下。 + +| # | 待办 | 对应计划项 | 载体 | 前置 | +|---|---|---|---|---| +| 1 | nftables 矩阵实测(Hardening/Attack/Ban/Internet-deny + Docker 共存) | P1-0 | `sudo apps/api/scripts/nft_prototype.sh` | root + nf_tables | +| 2 | host hook/priority discovery 实测(验证 FLOATCTF_FORWARD_PRIORITY=1) | P1-1 | docs/awd-nftables-host-discovery.md §3 命令清单 | root | +| 3 | **P2-6 真实网络矩阵 probe**(方案 A 挂起 WG peers / 方案 B canary namespace;precheck 不得给真实玩家提前攻击窗口) | P2-6 | CI-host-network 或手工 | Docker + WG + root | +| 4 | **P2-7/P2-8 precheck flag/judge 隔离实测**(真实 flag issue + judge callback 链路,X-Precheck-Run 语义,不污染 awd_flag_issues/score) | P2-7/P2-8 | CI-host-network | 容器编排 + root | +| 5 | Scenario A 完整比赛 nft policy transition 实测(DB 级已有:awd_scenarios::scenario_a) | P5-2 | CI-host-network | Docker + WG + root | +| 6 | Scenario B Reset / C Ban(banned set 断言、四入口全拒) | P5-3 | CI-host-network | Docker + WG + root | +| 7 | Scenario D Crash Recovery(任意状态 kill API 重启) | P5-4 | CI-host-network | Docker + root | +| 8 | Scenario G Token Rotation / H Round Transition(幂等:恰一个 Grace、恰一个 next round) | P5-7 | CI-host-network | Docker + root | +| 9 | Scenario I Docker Backend Compatibility(iptables backend 必跑,nftables backend 可选/nightly) | P5-8 | CI-host-network | root | +| 10 | Host Safety(宿主 SSH/无关容器/firewalld/libvirt 不受影响;archive 只删 floatctf_awd) | P5-13 | CI-host-network | root | +| 11 | Flag/Judge Docker smoke | — | `RUN_DOCKER_TESTS=1 apps/api/scripts/e2e_flag_judge.sh` | 补 `Dockerfile.awd-*` | + +## 本轮 plan 遗漏修复记录(2026-08-08) + +| 提交 | 修复项 | +|---|---| +| ae9cb60 | P4-9:resume 重建 round.end/grace_end 任务(卡死 bug)+ judge_callback 冻结门禁(铁律 #8) | +| 686214c | token 轮换探活(验收 #16)+ recovery 范围扩展 Verified/Deploying/…(验收 #11) | +| b0fc0f1 | **first-blood 并发丢失攻击分 bug**(PG 事务 abort)+ P5-9 三个并发断言 | + +> 本轮共确认并修复 **3 个真实代码 bug**(resume 卡死 / judge 冻结期写分 / first-blood abort), +> 新增 5 个 DB-gated 回归测试(pause/resume 重建 + 3 并发 + 既有场景扩展)。 ## 与计划的主要偏差(均已注释于代码) From 3f501815bd70ba7a8c48b699b50ed25b17959c98 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 19:03:15 +0800 Subject: [PATCH 033/486] =?UTF-8?q?feat(awd):=20GameBox=20=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E5=9B=9B=E5=B1=82=E6=A8=A1=E5=9E=8B=EF=BC=88gameboxes?= =?UTF-8?q?=20=E2=86=92=20revisions=20=E2=86=92=20event=5Fgameboxes=20?= =?UTF-8?q?=E2=86=92=20instances=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration A-D(20260808-awd-gamebox-domain-*): - 新增 gamebox_revisions(不可变部署版本:spec_json canonical + digest、image digest pin、 judge 配置随 revision、UNIQUE(id,gamebox_id) 供复合 FK) - 新增 awd_event_gameboxes(赛事选择 + pin revision + host_offset 确定性 IP + 赛事计分配置全部 BIGINT;UNIQUE(event_id,gamebox_id)/UNIQUE(event_id,host_offset)/ CHECK host_offset 2..254/复合 FK 保证 gamebox_id+revision_id 一致) - instances 增加 event_gamebox_id/runtime_generation/current_container_id; judge_tasks/score_events 增加 event_gamebox_id - Migration B 数据回填:legacy template → GameBox+Rev1+EventGameBox(按实例 IP 反推 host_offset,不一致即 MIGRATION CONFLICT;空库零操作)+ 可验证断言 - Migration D 删除 legacy:awd_gamebox_templates/event_gameboxes 表、 gameboxes 计分列(first_bouns 拼写错误随之消失)、next_gamebox_host、 instances.gamebox_id、judge/score 的 template_id 列、docker_network_id 业务代码: - 新仓储 gamebox_lib_repo/event_gamebox_repo;gamebox_repo 收敛为 instance repo - domain:safe_name 校验 + slugify + instance_ip_for_offset 确定性 IP(§66 测试) - gamebox_service:GameBox 库(create/edit → revision N+1 digest 去重)+ 单一 resolver resolve_event_gamebox_spec + build_gamebox_runtime_spec(§49/§50) - deploy/reset/judge/round/submission/internal/player 全部切到 event_gamebox_id; reset 用 pinned revision + runtime_generation+1 + 同 IP/凭据(§24/§25/§68) - first-bonus 幂等键迁移为 first-bonus:{event_id}:{event_gamebox_id}(§29) - fcmc GameBoxSpec template_id→event_gamebox_id + runtime_generation labels(§21) - Jeopardy instances.gamebox_id filter 移除(§33) 已知偏差(§23 逃生口):gamebox_ip 保持 VARCHAR,INET 迁移列为独立后续项 (SeaORM 1.1.20 将 INET 映射为 String,INSERT 按 TEXT 绑定被 PG 拒绝)。 --- ...ox_templates.rs => awd_event_gameboxes.rs} | 56 +- apps/api/src/entity/awd_gamebox_instances.rs | 35 +- apps/api/src/entity/awd_judge_tasks.rs | 26 +- apps/api/src/entity/awd_score_events.rs | 26 +- apps/api/src/entity/awd_team_networks.rs | 1 - apps/api/src/entity/challenges.rs | 8 - apps/api/src/entity/event_gameboxes.rs | 48 -- apps/api/src/entity/events.rs | 33 +- apps/api/src/entity/gamebox_revisions.rs | 63 ++ apps/api/src/entity/gameboxes.rs | 41 +- apps/api/src/entity/instances.rs | 15 - apps/api/src/entity/mod.rs | 4 +- apps/api/src/entity/prelude.rs | 4 +- .../src/modules/event/awd_team/api/admin.rs | 2 +- .../api/src/modules/event/awd_team/api/dto.rs | 2 +- .../modules/event/awd_team/api/internal.rs | 28 +- .../src/modules/event/awd_team/api/player.rs | 27 +- .../event/awd_team/domain/gamebox_ext.rs | 123 +++- .../event/awd_team/repo/event_gamebox_repo.rs | 231 +++++++ .../event/awd_team/repo/gamebox_lib_repo.rs | 192 ++++++ .../event/awd_team/repo/gamebox_repo.rs | 62 +- .../src/modules/event/awd_team/repo/mod.rs | 2 + .../modules/event/awd_team/repo/score_repo.rs | 8 +- .../event/awd_team/service/archive_service.rs | 4 +- .../event/awd_team/service/deploy_service.rs | 139 ++-- .../event/awd_team/service/gamebox_service.rs | 494 ++++++++++++-- .../event/awd_team/service/judge_service.rs | 73 +- .../awd_team/service/precheck_service.rs | 21 +- .../awd_team/service/recovery_service.rs | 8 +- .../event/awd_team/service/reset_service.rs | 91 ++- .../event/awd_team/service/round_service.rs | 31 +- .../awd_team/service/submission_service.rs | 13 +- .../api/src/modules/event/jeopardy/api/dto.rs | 2 - .../modules/event/jeopardy/api/instances.rs | 7 - .../platform/operations/runtime_instances.rs | 7 - apps/api/src/sql/merged.sql | 625 +++++++++++++++++- .../20260808183835-awd-gamebox-domain-a.sql | 182 +++++ .../20260808183914-awd-gamebox-domain-b.sql | 254 +++++++ .../20260808183946-awd-gamebox-domain-c.sql | 49 ++ .../20260808183947-awd-gamebox-domain-d.sql | 76 +++ apps/api/tests/awd_scenarios.rs | 92 ++- apps/web/src/entity/awd_event_gameboxes.ts | 22 + apps/web/src/entity/awd_gamebox_instances.ts | 6 +- apps/web/src/entity/awd_judge_tasks.ts | 2 +- apps/web/src/entity/awd_score_events.ts | 2 +- apps/web/src/entity/awd_team_networks.ts | 1 - apps/web/src/entity/gamebox_revisions.ts | 22 + apps/web/src/entity/gameboxes.ts | 6 - apps/web/src/entity/index.ts | 4 +- apps/web/src/entity/instances.ts | 1 - crates/fcmc/src/application/awd.rs | 13 +- crates/fcmc/src/runtime/awd.rs | 22 +- crates/fcmc/tests/docker_runtime.rs | 1 + crates/fcmc/tests/metadata_test.rs | 1 + 54 files changed, 2726 insertions(+), 582 deletions(-) rename apps/api/src/entity/{awd_gamebox_templates.rs => awd_event_gameboxes.rs} (69%) delete mode 100644 apps/api/src/entity/event_gameboxes.rs create mode 100644 apps/api/src/entity/gamebox_revisions.rs create mode 100644 apps/api/src/modules/event/awd_team/repo/event_gamebox_repo.rs create mode 100644 apps/api/src/modules/event/awd_team/repo/gamebox_lib_repo.rs create mode 100644 apps/api/src/sql/migrations/20260808183835-awd-gamebox-domain-a.sql create mode 100644 apps/api/src/sql/migrations/20260808183914-awd-gamebox-domain-b.sql create mode 100644 apps/api/src/sql/migrations/20260808183946-awd-gamebox-domain-c.sql create mode 100644 apps/api/src/sql/migrations/20260808183947-awd-gamebox-domain-d.sql create mode 100644 apps/web/src/entity/awd_event_gameboxes.ts create mode 100644 apps/web/src/entity/gamebox_revisions.ts diff --git a/apps/api/src/entity/awd_gamebox_templates.rs b/apps/api/src/entity/awd_event_gameboxes.rs similarity index 69% rename from apps/api/src/entity/awd_gamebox_templates.rs rename to apps/api/src/entity/awd_event_gameboxes.rs index 57b6e2fd..23f3096b 100644 --- a/apps/api/src/entity/awd_gamebox_templates.rs +++ b/apps/api/src/entity/awd_event_gameboxes.rs @@ -4,27 +4,21 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "awd_gamebox_templates")] +#[sea_orm(table_name = "awd_event_gameboxes")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub event_id: Uuid, - pub challenge_id: Option, - pub name: String, - pub image_ref: String, - pub username: String, - #[sea_orm(column_type = "JsonBinary")] - pub meta_json: Json, + pub gamebox_id: Uuid, + pub gamebox_revision_id: Uuid, + pub host_offset: i16, + pub enabled: bool, + pub hidden: bool, pub cpu_millis: i64, pub memory_bytes: i64, pub pids_limit: i64, #[sea_orm(column_type = "JsonBinary", nullable)] pub healthcheck_override_json: Option, - pub judge_script_name: Option, - #[sea_orm(column_type = "Text", nullable)] - pub judge_script_content: Option, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub judge_args_json: Option, pub judge_timeout_secs: Option, pub judge_retry_interval_secs: Option, pub break_points: i64, @@ -44,14 +38,6 @@ pub enum Relation { AwdJudgeTasks, #[sea_orm(has_many = "super::awd_score_events::Entity")] AwdScoreEvents, - #[sea_orm( - belongs_to = "super::challenges::Entity", - from = "Column::ChallengeId", - to = "super::challenges::Column::Id", - on_update = "NoAction", - on_delete = "SetNull" - )] - Challenges, #[sea_orm( belongs_to = "super::events::Entity", from = "Column::EventId", @@ -60,6 +46,22 @@ pub enum Relation { on_delete = "Cascade" )] Events, + #[sea_orm( + belongs_to = "super::gamebox_revisions::Entity", + from = "(Column::GameboxRevisionId, Column::GameboxId)", + to = "(super::gamebox_revisions::Column::Id, super::gamebox_revisions::Column::GameboxId)", + on_update = "NoAction", + on_delete = "Restrict" + )] + GameboxRevisions, + #[sea_orm( + belongs_to = "super::gameboxes::Entity", + from = "Column::GameboxId", + to = "super::gameboxes::Column::Id", + on_update = "NoAction", + on_delete = "Restrict" + )] + Gameboxes, } impl Related for Entity { @@ -80,15 +82,21 @@ impl Related for Entity { } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::Challenges.def() + Relation::Events.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::Events.def() + Relation::GameboxRevisions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Gameboxes.def() } } diff --git a/apps/api/src/entity/awd_gamebox_instances.rs b/apps/api/src/entity/awd_gamebox_instances.rs index 69625f81..c39a6c2e 100644 --- a/apps/api/src/entity/awd_gamebox_instances.rs +++ b/apps/api/src/entity/awd_gamebox_instances.rs @@ -10,36 +10,37 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub event_id: Uuid, - pub template_id: Uuid, pub team_id: Uuid, pub status: GameboxStatus, - pub container_id: Option, #[sea_orm(unique)] pub container_name: String, + #[sea_orm(column_type = "custom(\"inet\")")] pub gamebox_ip: String, - pub docker_network_id: Option, pub health_status: String, pub reset_protection_until: Option, pub last_health_check_at: Option, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, pub deleted_at: Option, + pub event_gamebox_id: Uuid, + pub runtime_generation: i64, + pub current_container_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm( + belongs_to = "super::awd_event_gameboxes::Entity", + from = "Column::EventGameboxId", + to = "super::awd_event_gameboxes::Column::Id", + on_update = "NoAction", + on_delete = "Restrict" + )] + AwdEventGameboxes, #[sea_orm(has_many = "super::awd_flag_issues::Entity")] AwdFlagIssues, #[sea_orm(has_many = "super::awd_flag_submissions::Entity")] AwdFlagSubmissions, - #[sea_orm( - belongs_to = "super::awd_gamebox_templates::Entity", - from = "Column::TemplateId", - to = "super::awd_gamebox_templates::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - AwdGameboxTemplates, #[sea_orm(has_many = "super::awd_judge_tasks::Entity")] AwdJudgeTasks, #[sea_orm(has_many = "super::awd_reset_records::Entity")] @@ -64,21 +65,21 @@ pub enum Relation { Events, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdFlagIssues.def() + Relation::AwdEventGameboxes.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdFlagSubmissions.def() + Relation::AwdFlagIssues.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdGameboxTemplates.def() + Relation::AwdFlagSubmissions.def() } } diff --git a/apps/api/src/entity/awd_judge_tasks.rs b/apps/api/src/entity/awd_judge_tasks.rs index 9a54f46d..d1dd16c6 100644 --- a/apps/api/src/entity/awd_judge_tasks.rs +++ b/apps/api/src/entity/awd_judge_tasks.rs @@ -13,7 +13,6 @@ pub struct Model { pub event_id: Uuid, pub round_id: Uuid, pub gamebox_instance_id: Uuid, - pub template_id: Uuid, pub team_id: Uuid, pub status: JudgeTaskStatus, pub attempt_count: i32, @@ -29,10 +28,19 @@ pub struct Model { pub duration_ms: Option, pub callback_idempotency_key: Option, pub created_at: DateTimeWithTimeZone, + pub event_gamebox_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm( + belongs_to = "super::awd_event_gameboxes::Entity", + from = "Column::EventGameboxId", + to = "super::awd_event_gameboxes::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + AwdEventGameboxes, #[sea_orm( belongs_to = "super::awd_gamebox_instances::Entity", from = "Column::GameboxInstanceId", @@ -41,14 +49,6 @@ pub enum Relation { on_delete = "Cascade" )] AwdGameboxInstances, - #[sea_orm( - belongs_to = "super::awd_gamebox_templates::Entity", - from = "Column::TemplateId", - to = "super::awd_gamebox_templates::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - AwdGameboxTemplates, #[sea_orm( belongs_to = "super::awd_judge_batches::Entity", from = "Column::BatchId", @@ -83,15 +83,15 @@ pub enum Relation { Events, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdGameboxInstances.def() + Relation::AwdEventGameboxes.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdGameboxTemplates.def() + Relation::AwdGameboxInstances.def() } } diff --git a/apps/api/src/entity/awd_score_events.rs b/apps/api/src/entity/awd_score_events.rs index 24c84a82..f21a90d7 100644 --- a/apps/api/src/entity/awd_score_events.rs +++ b/apps/api/src/entity/awd_score_events.rs @@ -18,7 +18,6 @@ pub struct Model { pub idempotency_key: String, pub related_team_id: Option, pub gamebox_instance_id: Option, - pub gamebox_template_id: Option, pub reference_id: Option, #[sea_orm(column_type = "Text", nullable)] pub reason: Option, @@ -26,26 +25,27 @@ pub struct Model { pub metadata_json: Json, pub created_by: Option, pub created_at: DateTimeWithTimeZone, + pub event_gamebox_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( - belongs_to = "super::awd_gamebox_instances::Entity", - from = "Column::GameboxInstanceId", - to = "super::awd_gamebox_instances::Column::Id", + belongs_to = "super::awd_event_gameboxes::Entity", + from = "Column::EventGameboxId", + to = "super::awd_event_gameboxes::Column::Id", on_update = "NoAction", on_delete = "SetNull" )] - AwdGameboxInstances, + AwdEventGameboxes, #[sea_orm( - belongs_to = "super::awd_gamebox_templates::Entity", - from = "Column::GameboxTemplateId", - to = "super::awd_gamebox_templates::Column::Id", + belongs_to = "super::awd_gamebox_instances::Entity", + from = "Column::GameboxInstanceId", + to = "super::awd_gamebox_instances::Column::Id", on_update = "NoAction", on_delete = "SetNull" )] - AwdGameboxTemplates, + AwdGameboxInstances, #[sea_orm( belongs_to = "super::awd_rounds::Entity", from = "Column::RoundId", @@ -88,15 +88,15 @@ pub enum Relation { SuperAdmin, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdGameboxInstances.def() + Relation::AwdEventGameboxes.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::AwdGameboxTemplates.def() + Relation::AwdGameboxInstances.def() } } diff --git a/apps/api/src/entity/awd_team_networks.rs b/apps/api/src/entity/awd_team_networks.rs index e3037b84..07a893b5 100644 --- a/apps/api/src/entity/awd_team_networks.rs +++ b/apps/api/src/entity/awd_team_networks.rs @@ -17,7 +17,6 @@ pub struct Model { #[sea_orm(column_type = "VarBinary(StringLen::None)")] pub ssh_password_nonce: Vec, pub key_version: i32, - pub next_gamebox_host: i32, pub next_wireguard_host: i32, pub status: String, pub created_at: DateTimeWithTimeZone, diff --git a/apps/api/src/entity/challenges.rs b/apps/api/src/entity/challenges.rs index 4424cee0..93d7883d 100644 --- a/apps/api/src/entity/challenges.rs +++ b/apps/api/src/entity/challenges.rs @@ -27,8 +27,6 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::awd_gamebox_templates::Entity")] - AwdGameboxTemplates, #[sea_orm(has_many = "super::challenge_set_items::Entity")] ChallengeSetItems, #[sea_orm(has_many = "super::challenge_solves::Entity")] @@ -43,12 +41,6 @@ pub enum Relation { Instances, } -impl Related for Entity { - fn to() -> RelationDef { - Relation::AwdGameboxTemplates.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::ChallengeSetItems.def() diff --git a/apps/api/src/entity/event_gameboxes.rs b/apps/api/src/entity/event_gameboxes.rs deleted file mode 100644 index 5088542c..00000000 --- a/apps/api/src/entity/event_gameboxes.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "event_gameboxes")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub event_id: Uuid, - #[sea_orm(primary_key, auto_increment = false)] - pub gamebox_id: Uuid, - pub hidden: bool, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::events::Entity", - from = "Column::EventId", - to = "super::events::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Events, - #[sea_orm( - belongs_to = "super::gameboxes::Entity", - from = "Column::GameboxId", - to = "super::gameboxes::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Gameboxes, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Events.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Gameboxes.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/api/src/entity/events.rs b/apps/api/src/entity/events.rs index 2b8bc88f..c5fdfaf7 100644 --- a/apps/api/src/entity/events.rs +++ b/apps/api/src/entity/events.rs @@ -28,6 +28,8 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm(has_many = "super::awd_event_gameboxes::Entity")] + AwdEventGameboxes, #[sea_orm(has_one = "super::awd_events::Entity")] AwdEvents, #[sea_orm(has_many = "super::awd_flag_issues::Entity")] @@ -36,8 +38,6 @@ pub enum Relation { AwdFlagSubmissions, #[sea_orm(has_many = "super::awd_gamebox_instances::Entity")] AwdGameboxInstances, - #[sea_orm(has_many = "super::awd_gamebox_templates::Entity")] - AwdGameboxTemplates, #[sea_orm(has_many = "super::awd_internal_token_rotations::Entity")] AwdInternalTokenRotations, #[sea_orm(has_many = "super::awd_judge_batches::Entity")] @@ -70,8 +70,6 @@ pub enum Relation { EventChallengeSolves, #[sea_orm(has_many = "super::event_challenges::Entity")] EventChallenges, - #[sea_orm(has_many = "super::event_gameboxes::Entity")] - EventGameboxes, #[sea_orm(has_many = "super::event_instances::Entity")] EventInstances, #[sea_orm(has_many = "super::event_logs::Entity")] @@ -86,6 +84,12 @@ pub enum Relation { EventWriteup, } +impl Related for Entity { + fn to() -> RelationDef { + Relation::AwdEventGameboxes.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::AwdEvents.def() @@ -110,12 +114,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::AwdGameboxTemplates.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::AwdInternalTokenRotations.def() @@ -212,12 +210,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::EventGameboxes.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::EventInstances.def() @@ -263,15 +255,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - super::event_gameboxes::Relation::Gameboxes.def() - } - fn via() -> Option { - Some(super::event_gameboxes::Relation::Events.def().rev()) - } -} - impl Related for Entity { fn to() -> RelationDef { super::event_users::Relation::Users.def() diff --git a/apps/api/src/entity/gamebox_revisions.rs b/apps/api/src/entity/gamebox_revisions.rs new file mode 100644 index 00000000..117cd2e4 --- /dev/null +++ b/apps/api/src/entity/gamebox_revisions.rs @@ -0,0 +1,63 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "gamebox_revisions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub gamebox_id: Uuid, + pub revision_number: i32, + #[sea_orm(column_type = "Text")] + pub source_toml: String, + pub spec_schema_version: i32, + #[sea_orm(column_type = "JsonBinary")] + pub spec_json: Json, + pub spec_digest: String, + pub image_ref: String, + pub image_digest: Option, + pub username: String, + pub default_cpu_millis: i64, + pub default_memory_bytes: i64, + pub default_pids_limit: i64, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub healthcheck_json: Option, + pub judge_script_name: Option, + #[sea_orm(column_type = "Text", nullable)] + pub judge_script_content: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub judge_args_json: Option, + pub default_judge_timeout_secs: Option, + pub default_judge_retry_interval_secs: Option, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::awd_event_gameboxes::Entity")] + AwdEventGameboxes, + #[sea_orm( + belongs_to = "super::gameboxes::Entity", + from = "Column::GameboxId", + to = "super::gameboxes::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Gameboxes, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AwdEventGameboxes.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Gameboxes.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/api/src/entity/gameboxes.rs b/apps/api/src/entity/gameboxes.rs index e2342a06..cc580190 100644 --- a/apps/api/src/entity/gameboxes.rs +++ b/apps/api/src/entity/gameboxes.rs @@ -3,12 +3,12 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "gameboxes")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, - #[sea_orm(column_type = "Text", unique)] + #[sea_orm(column_type = "Text")] pub name: String, #[sea_orm(column_type = "Text", unique)] pub safe_name: String, @@ -17,48 +17,27 @@ pub struct Model { #[sea_orm(column_type = "Text")] pub description: String, pub hidden: bool, - #[sea_orm(column_type = "Text")] - pub toml_str: String, - #[sea_orm(column_type = "Text")] - pub username: String, - #[sea_orm(column_type = "Double")] - pub break_point: f64, - #[sea_orm(column_type = "Double")] - pub fix_point: f64, - #[sea_orm(column_type = "Double")] - pub down_point: f64, - #[sea_orm(column_type = "Double")] - pub first_bouns: f64, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm(has_many = "super::event_gameboxes::Entity")] - EventGameboxes, - #[sea_orm(has_many = "super::instances::Entity")] - Instances, + #[sea_orm(has_many = "super::awd_event_gameboxes::Entity")] + AwdEventGameboxes, + #[sea_orm(has_many = "super::gamebox_revisions::Entity")] + GameboxRevisions, } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::EventGameboxes.def() + Relation::AwdEventGameboxes.def() } } -impl Related for Entity { +impl Related for Entity { fn to() -> RelationDef { - Relation::Instances.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - super::event_gameboxes::Relation::Events.def() - } - fn via() -> Option { - Some(super::event_gameboxes::Relation::Gameboxes.def().rev()) + Relation::GameboxRevisions.def() } } diff --git a/apps/api/src/entity/instances.rs b/apps/api/src/entity/instances.rs index 1bc89ce6..c067778e 100644 --- a/apps/api/src/entity/instances.rs +++ b/apps/api/src/entity/instances.rs @@ -17,7 +17,6 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub content: Option, pub challenge_id: Option, - pub gamebox_id: Option, pub user_id: Uuid, #[sea_orm(column_type = "Text")] pub identifier: String, @@ -38,14 +37,6 @@ pub enum Relation { Challenges, #[sea_orm(has_many = "super::event_instances::Entity")] EventInstances, - #[sea_orm( - belongs_to = "super::gameboxes::Entity", - from = "Column::GameboxId", - to = "super::gameboxes::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Gameboxes, #[sea_orm( belongs_to = "super::users::Entity", from = "Column::UserId", @@ -68,12 +59,6 @@ impl Related for Entity { } } -impl Related for Entity { - fn to() -> RelationDef { - Relation::Gameboxes.def() - } -} - impl Related for Entity { fn to() -> RelationDef { Relation::Users.def() diff --git a/apps/api/src/entity/mod.rs b/apps/api/src/entity/mod.rs index 76843cfc..bc8d7f2b 100644 --- a/apps/api/src/entity/mod.rs +++ b/apps/api/src/entity/mod.rs @@ -3,11 +3,11 @@ pub mod prelude; pub mod announcements; +pub mod awd_event_gameboxes; pub mod awd_events; pub mod awd_flag_issues; pub mod awd_flag_submissions; pub mod awd_gamebox_instances; -pub mod awd_gamebox_templates; pub mod awd_internal_token_rotations; pub mod awd_judge_batches; pub mod awd_judge_tasks; @@ -31,7 +31,6 @@ pub mod discussions; pub mod event_announcements; pub mod event_challenge_solves; pub mod event_challenges; -pub mod event_gameboxes; pub mod event_instances; pub mod event_logs; pub mod event_team_members; @@ -39,6 +38,7 @@ pub mod event_teams; pub mod event_users; pub mod event_writeup; pub mod events; +pub mod gamebox_revisions; pub mod gameboxes; pub mod instances; pub mod logs; diff --git a/apps/api/src/entity/prelude.rs b/apps/api/src/entity/prelude.rs index 405a2bf8..38429a6f 100644 --- a/apps/api/src/entity/prelude.rs +++ b/apps/api/src/entity/prelude.rs @@ -1,11 +1,11 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 pub use super::announcements::Entity as Announcements; +pub use super::awd_event_gameboxes::Entity as AwdEventGameboxes; pub use super::awd_events::Entity as AwdEvents; pub use super::awd_flag_issues::Entity as AwdFlagIssues; pub use super::awd_flag_submissions::Entity as AwdFlagSubmissions; pub use super::awd_gamebox_instances::Entity as AwdGameboxInstances; -pub use super::awd_gamebox_templates::Entity as AwdGameboxTemplates; pub use super::awd_internal_token_rotations::Entity as AwdInternalTokenRotations; pub use super::awd_judge_batches::Entity as AwdJudgeBatches; pub use super::awd_judge_tasks::Entity as AwdJudgeTasks; @@ -29,7 +29,6 @@ pub use super::discussions::Entity as Discussions; pub use super::event_announcements::Entity as EventAnnouncements; pub use super::event_challenge_solves::Entity as EventChallengeSolves; pub use super::event_challenges::Entity as EventChallenges; -pub use super::event_gameboxes::Entity as EventGameboxes; pub use super::event_instances::Entity as EventInstances; pub use super::event_logs::Entity as EventLogs; pub use super::event_team_members::Entity as EventTeamMembers; @@ -37,6 +36,7 @@ pub use super::event_teams::Entity as EventTeams; pub use super::event_users::Entity as EventUsers; pub use super::event_writeup::Entity as EventWriteup; pub use super::events::Entity as Events; +pub use super::gamebox_revisions::Entity as GameboxRevisions; pub use super::gameboxes::Entity as Gameboxes; pub use super::instances::Entity as Instances; pub use super::logs::Entity as Logs; diff --git a/apps/api/src/modules/event/awd_team/api/admin.rs b/apps/api/src/modules/event/awd_team/api/admin.rs index b7c170b3..67f92657 100644 --- a/apps/api/src/modules/event/awd_team/api/admin.rs +++ b/apps/api/src/modules/event/awd_team/api/admin.rs @@ -2,7 +2,7 @@ //! //! All endpoints require SuperAdmin authentication. -use actix_web::{HttpResponse, web}; +use actix_web::web; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, TransactionTrait, diff --git a/apps/api/src/modules/event/awd_team/api/dto.rs b/apps/api/src/modules/event/awd_team/api/dto.rs index 4b1531c1..9ef86559 100644 --- a/apps/api/src/modules/event/awd_team/api/dto.rs +++ b/apps/api/src/modules/event/awd_team/api/dto.rs @@ -110,7 +110,7 @@ pub struct AwdEventResponse { pub struct GameBoxResponse { pub id: Uuid, pub team_id: Uuid, - pub template_id: Uuid, + pub event_gamebox_id: Uuid, pub status: String, pub gamebox_ip: String, pub container_name: String, diff --git a/apps/api/src/modules/event/awd_team/api/internal.rs b/apps/api/src/modules/event/awd_team/api/internal.rs index c95e8ad9..af85790f 100644 --- a/apps/api/src/modules/event/awd_team/api/internal.rs +++ b/apps/api/src/modules/event/awd_team/api/internal.rs @@ -3,8 +3,8 @@ //! All endpoints require AwdInternalAuth (Bearer token validated against //! encrypted tokens stored in awd_events). -use actix_web::{HttpResponse, web}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use actix_web::web; + use uuid::Uuid; use crate::{ @@ -14,7 +14,7 @@ use crate::{ AwdError, api::auth::{AwdInternalAuth, AwdInternalPrincipal}, domain::{AwdPhaseExt, JudgeTaskStatusExt}, - repo::{event_repo, judge_repo, round_repo, score_repo}, + repo::{event_repo, judge_repo, score_repo}, service::flag_service, }, }; @@ -175,13 +175,17 @@ pub async fn judge_callback( // If up/down, record score (跳过冻结事件) if (is_up || is_down) && !frozen { - // Get the template for scoring values - use crate::entity::awd_gamebox_templates; - let template = awd_gamebox_templates::Entity::find_by_id(task.template_id) - .one(ctx.db.get_ref()) + // 计分作用域 = EventGameBox(§23/§28):从 pin Revision 解析 fix/down 分值 + let eg_id = task + .event_gamebox_id + .ok_or_else(|| AppError::Internal("judge task lacks event_gamebox_id".into()))?; + let resolved = + crate::modules::event::awd_team::service::gamebox_service::resolve_event_gamebox_spec( + ctx.db.get_ref(), + eg_id, + ) .await - .map_err(|e| AppError::Database(e.to_string()))? - .ok_or_else(|| AppError::NotFound("Template not found".into()))?; + .map_err(|e| AppError::Internal(e.to_string()))?; let score_type = if is_up { ScoreEventType::JudgeFix @@ -190,9 +194,9 @@ pub async fn judge_callback( }; let delta = if is_up { - template.fix_points + resolved.event_gamebox.fix_points } else { - -template.down_points + -resolved.event_gamebox.down_points }; let idempotency_key = cb.callback_id.clone(); @@ -211,7 +215,7 @@ pub async fn judge_callback( &idempotency_key, None, Some(task.gamebox_instance_id), - Some(task.template_id), + Some(eg_id), Some("judge check"), ) .await diff --git a/apps/api/src/modules/event/awd_team/api/player.rs b/apps/api/src/modules/event/awd_team/api/player.rs index cb521a35..e9db3f99 100644 --- a/apps/api/src/modules/event/awd_team/api/player.rs +++ b/apps/api/src/modules/event/awd_team/api/player.rs @@ -8,7 +8,7 @@ use crate::{ api::{AppError, UniResponse, UniResult, extractor::auth::UserJwtGuard, prelude::*}, modules::event::awd_team::{ repo::{gamebox_repo, round_repo}, - service::{flag_service, gamebox_service, score_service, submission_service}, + service::{flag_service, score_service, submission_service}, }, modules::event::common::infrastructure::event_repository as repo, }; @@ -43,7 +43,7 @@ pub async fn get_my_gameboxes( .map(|i| GameBoxResponse { id: i.id, team_id: i.team_id, - template_id: i.template_id, + event_gamebox_id: i.event_gamebox_id, status: format!("{:?}", i.status).to_lowercase(), gamebox_ip: i.gamebox_ip, container_name: i.container_name, @@ -148,19 +148,18 @@ pub async fn submit_flag( .map_err(|e| AppError::Database(e.to_string()))? .ok_or_else(|| AppError::NotFound("No active round".into()))?; - // Get template from instance for scoring config + // 从 Instance → EventGameBox 解析计分配置(§28:攻击分属于 EventGameBox) let instance = gamebox_repo::find_instance_by_id(ctx.db.get_ref(), gamebox_instance_id) .await .map_err(|e| AppError::Database(e.to_string()))? .unwrap(); - - // Get template for scoring values - use crate::entity::awd_gamebox_templates; - let template = awd_gamebox_templates::Entity::find_by_id(instance.template_id) - .one(ctx.db.get_ref()) + let resolved = + crate::modules::event::awd_team::service::gamebox_service::resolve_event_gamebox_spec( + ctx.db.get_ref(), + instance.event_gamebox_id, + ) .await - .map_err(|e| AppError::Database(e.to_string()))? - .ok_or_else(|| AppError::NotFound("Template not found".into()))?; + .map_err(|e| AppError::Internal(e.to_string()))?; // Process the submission let result = submission_service::process_submission( @@ -172,10 +171,10 @@ pub async fn submit_flag( victim_team_id, gamebox_instance_id, user.id, - template.break_points, - template.loss_points, - template.first_bonus, - template.id, + resolved.event_gamebox.break_points, + resolved.event_gamebox.loss_points, + resolved.event_gamebox.first_bonus, + resolved.event_gamebox.id, awd.publisher.as_ref(), ) .await diff --git a/apps/api/src/modules/event/awd_team/domain/gamebox_ext.rs b/apps/api/src/modules/event/awd_team/domain/gamebox_ext.rs index 852e2d04..a50714e7 100644 --- a/apps/api/src/modules/event/awd_team/domain/gamebox_ext.rs +++ b/apps/api/src/modules/event/awd_team/domain/gamebox_ext.rs @@ -1,6 +1,58 @@ -//! Extension methods for GameBox status transitions. +//! Extension methods for GameBox status transitions + GameBox 领域纯函数。 use crate::entity::sea_orm_active_enums::GameboxStatus; +use crate::modules::event::awd_team::domain::Ipv4Cidr; + +/// safe_name 校验规则(§54):`^[a-z0-9][a-z0-9_-]*$`,与 Challenge safe_name 对齐。 +pub fn validate_safe_name(s: &str) -> Result<(), String> { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return Err("safe_name 不能为空".into()); + } + let first_ok = bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit(); + if !first_ok { + return Err(format!("safe_name 必须以小写字母或数字开头: {s}")); + } + for &b in bytes { + let ok = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'; + if !ok { + return Err(format!( + "safe_name 只允许 [a-z0-9_-]: {s}(字符 0x{:02x})", + b + )); + } + } + Ok(()) +} + +/// 由展示名生成 safe_name 候选(小写 + 非字母数字转 -)。 +pub fn slugify(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_dash = false; + for ch in name.chars() { + let c = ch.to_ascii_lowercase(); + if c.is_ascii_alphanumeric() || c == '_' { + out.push(c); + last_dash = false; + } else if !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } + out.trim_matches('-').to_string() +} + +/// 确定性 IP 分配(§13/§66):`instance_ip = team.gamebox_subnet + event_gamebox.host_offset`。 +/// 例:子网 10.42.1.0/24 + offset 10 → 10.42.1.10。offset 必须在 2..=254(避开 .1 网关/广播/保留)。 +pub fn instance_ip_for_offset(subnet: &str, host_offset: i16) -> Option { + if !(2..=254).contains(&host_offset) { + return None; + } + let cidr = Ipv4Cidr::parse(subnet).ok()?; + // nth_host(n) = network + (n+1),故 offset 直接映射 host 字节:nth_host(offset-1) = network+offset + cidr.nth_host(host_offset as u32 - 1) + .map(|ip| ip.to_string()) +} pub trait GameboxStatusExt { fn is_healthy(&self) -> bool; @@ -53,3 +105,72 @@ impl GameboxStatusExt for GameboxStatus { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_name_validation_rules() { + assert!(validate_safe_name("easy-web").is_ok()); + assert!(validate_safe_name("easy_web").is_ok()); + assert!(validate_safe_name("easyweb1").is_ok()); + assert!(validate_safe_name("EasyWeb").is_err(), "大写非法"); + assert!(validate_safe_name("1easy").is_ok()); + assert!(validate_safe_name("-easy").is_err(), "不能以 - 开头"); + assert!(validate_safe_name("easy web").is_err(), "空格非法"); + assert!(validate_safe_name("").is_err()); + } + + #[test] + fn slugify_basics() { + assert_eq!(slugify("Easy Web"), "easy-web"); + assert_eq!(slugify("Pwn 01"), "pwn-01"); + assert_eq!(slugify(" Misc "), "misc"); + assert_eq!(slugify("Already_snake"), "already_snake"); + assert_eq!(slugify("a--b"), "a-b"); + } + + #[test] + fn instance_ip_is_deterministic_per_offset() { + // §66:EasyWeb offset=10, Pwn offset=11;Team A 10.1.1.0/24, Team B 10.1.2.0/24 + assert_eq!( + instance_ip_for_offset("10.1.1.0/24", 10).as_deref(), + Some("10.1.1.10") + ); + assert_eq!( + instance_ip_for_offset("10.1.2.0/24", 10).as_deref(), + Some("10.1.2.10") + ); + assert_eq!( + instance_ip_for_offset("10.1.1.0/24", 11).as_deref(), + Some("10.1.1.11") + ); + assert_eq!( + instance_ip_for_offset("10.1.2.0/24", 11).as_deref(), + Some("10.1.2.11") + ); + // offset 边界 + assert_eq!( + instance_ip_for_offset("10.1.1.0/24", 2).as_deref(), + Some("10.1.1.2") + ); + assert_eq!(instance_ip_for_offset("10.1.1.0/24", 254).is_some(), true); + assert_eq!( + instance_ip_for_offset("10.1.1.0/24", 1), + None, + ".1 保留给网关" + ); + assert_eq!(instance_ip_for_offset("10.1.1.0/24", 255), None, "广播地址"); + assert_eq!(instance_ip_for_offset("10.1.1.0/24", 0), None); + } + + #[test] + fn new_gamebox_does_not_change_existing_ips() { + // 新增队伍 / 新增 offset 不影响已分配 IP(纯函数:IP 只由 subnet+offset 决定) + let a1 = instance_ip_for_offset("10.1.1.0/24", 10); + let _team_c = instance_ip_for_offset("10.1.3.0/24", 10); // 新队伍 + let _new_gb = instance_ip_for_offset("10.1.1.0/24", 12); // 新 GameBox + assert_eq!(a1.as_deref(), Some("10.1.1.10")); + } +} diff --git a/apps/api/src/modules/event/awd_team/repo/event_gamebox_repo.rs b/apps/api/src/modules/event/awd_team/repo/event_gamebox_repo.rs new file mode 100644 index 00000000..25c71448 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/repo/event_gamebox_repo.rs @@ -0,0 +1,231 @@ +//! EventGameBox repository — 某场 AWD 赛事选择的 GameBox Revision + 赛事计分配置。 +//! +//! 关键不变式(§11/§12/§13): +//! - Event 内一个 GameBox 只能有一个选择(UNIQUE(event_id, gamebox_id)) +//! - pin 具体 revision(gamebox_revision_id),禁止存 latest +//! - host_offset 决定 instance_ip = team.gamebox_subnet + host_offset,部署后禁改 +//! - 计分属于 Event × GameBox(break_points 等),与全局 GameBox 无关 + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, +}; +use uuid::Uuid; + +use crate::entity::{awd_event_gameboxes, gamebox_revisions, gameboxes}; + +pub async fn find_event_gameboxes_by_event( + db: &DatabaseConnection, + event_id: Uuid, +) -> Result, sea_orm::DbErr> { + awd_event_gameboxes::Entity::find() + .filter(awd_event_gameboxes::Column::EventId.eq(event_id)) + .order_by_asc(awd_event_gameboxes::Column::HostOffset) + .all(db) + .await +} + +pub async fn find_event_gamebox_by_id( + db: &DatabaseConnection, + id: Uuid, +) -> Result, sea_orm::DbErr> { + awd_event_gameboxes::Entity::find_by_id(id).one(db).await +} + +/// 某 Event 对某 GameBox 的选择(§12 UNIQUE(event_id, gamebox_id))。 +pub async fn find_event_gamebox( + db: &DatabaseConnection, + event_id: Uuid, + gamebox_id: Uuid, +) -> Result, sea_orm::DbErr> { + awd_event_gameboxes::Entity::find() + .filter(awd_event_gameboxes::Column::EventId.eq(event_id)) + .filter(awd_event_gameboxes::Column::GameboxId.eq(gamebox_id)) + .one(db) + .await +} + +/// 获取 EventGameBox 的 pin Revision(§35:具体 UUID,不可变)。 +pub async fn find_pinned_revision( + db: &DatabaseConnection, + event_gamebox: &awd_event_gameboxes::Model, +) -> Result, sea_orm::DbErr> { + gamebox_revisions::Entity::find_by_id(event_gamebox.gamebox_revision_id) + .one(db) + .await +} + +/// 获取 EventGameBox 关联的全局 GameBox identity(显示名等)。 +pub async fn find_gamebox_identity( + db: &DatabaseConnection, + gamebox_id: Uuid, +) -> Result, sea_orm::DbErr> { + gameboxes::Entity::find_by_id(gamebox_id).one(db).await +} + +/// EventGameBox + pin Revision + GameBox identity 组合视图(列表/详情 API 用)。 +pub struct EventGameBoxDetail { + pub event_gamebox: awd_event_gameboxes::Model, + pub revision: gamebox_revisions::Model, + pub gamebox: gameboxes::Model, +} + +pub async fn find_event_gameboxes_detail( + db: &DatabaseConnection, + event_id: Uuid, +) -> Result, sea_orm::DbErr> { + let mut out = Vec::new(); + for eg in find_event_gameboxes_by_event(db, event_id).await? { + let revision = match find_pinned_revision(db, &eg).await? { + Some(r) => r, + None => continue, // 理论上不会发生(FK 保证) + }; + let gamebox = match find_gamebox_identity(db, eg.gamebox_id).await? { + Some(g) => g, + None => continue, + }; + out.push(EventGameBoxDetail { + event_gamebox: eg, + revision, + gamebox, + }); + } + Ok(out) +} + +/// 创建 EventGameBox(赛事选择)。调用方负责:host_offset 分配、configuration_generation 联动。 +#[allow(clippy::too_many_arguments)] +pub async fn create_event_gamebox( + db: &DatabaseConnection, + event_id: Uuid, + gamebox_id: Uuid, + gamebox_revision_id: Uuid, + host_offset: i16, + enabled: bool, + hidden: bool, + cpu_millis: i64, + memory_bytes: i64, + pids_limit: i64, + healthcheck_override_json: Option, + judge_timeout_secs: Option, + judge_retry_interval_secs: Option, + break_points: i64, + loss_points: i64, + fix_points: i64, + down_points: i64, + first_bonus: i64, +) -> Result { + let now = chrono::Utc::now().into(); + awd_event_gameboxes::ActiveModel { + id: Set(Uuid::new_v4()), + event_id: Set(event_id), + gamebox_id: Set(gamebox_id), + gamebox_revision_id: Set(gamebox_revision_id), + host_offset: Set(host_offset), + enabled: Set(enabled), + hidden: Set(hidden), + cpu_millis: Set(cpu_millis), + memory_bytes: Set(memory_bytes), + pids_limit: Set(pids_limit), + healthcheck_override_json: Set(healthcheck_override_json), + judge_timeout_secs: Set(judge_timeout_secs), + judge_retry_interval_secs: Set(judge_retry_interval_secs), + break_points: Set(break_points), + loss_points: Set(loss_points), + fix_points: Set(fix_points), + down_points: Set(down_points), + first_bonus: Set(first_bonus), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(db) + .await +} + +/// 更新 EventGameBox(计分/资源/判题覆盖/pin revision 变更)。调用方负责: +/// 已部署的 host_offset 禁止修改(§38);变更后必须 touch_configuration(§37)。 +pub struct EventGameBoxPatch { + pub gamebox_revision_id: Option, + pub enabled: Option, + pub hidden: Option, + pub cpu_millis: Option, + pub memory_bytes: Option, + pub pids_limit: Option, + pub healthcheck_override_json: Option>, + pub judge_timeout_secs: Option>, + pub judge_retry_interval_secs: Option>, + pub break_points: Option, + pub loss_points: Option, + pub fix_points: Option, + pub down_points: Option, + pub first_bonus: Option, +} + +pub async fn update_event_gamebox( + db: &DatabaseConnection, + id: Uuid, + patch: EventGameBoxPatch, +) -> Result, sea_orm::DbErr> { + let current = match find_event_gamebox_by_id(db, id).await? { + Some(m) => m, + None => return Ok(None), + }; + let mut active: awd_event_gameboxes::ActiveModel = awd_event_gameboxes::ActiveModel { + id: Set(id), + updated_at: Set(chrono::Utc::now().into()), + ..Default::default() + }; + if let Some(v) = patch.gamebox_revision_id { + active.gamebox_revision_id = Set(v); + } + if let Some(v) = patch.enabled { + active.enabled = Set(v); + } + if let Some(v) = patch.hidden { + active.hidden = Set(v); + } + if let Some(v) = patch.cpu_millis { + active.cpu_millis = Set(v); + } + if let Some(v) = patch.memory_bytes { + active.memory_bytes = Set(v); + } + if let Some(v) = patch.pids_limit { + active.pids_limit = Set(v); + } + if let Some(v) = patch.healthcheck_override_json { + active.healthcheck_override_json = Set(v); + } + if let Some(v) = patch.judge_timeout_secs { + active.judge_timeout_secs = Set(v); + } + if let Some(v) = patch.judge_retry_interval_secs { + active.judge_retry_interval_secs = Set(v); + } + if let Some(v) = patch.break_points { + active.break_points = Set(v); + } + if let Some(v) = patch.loss_points { + active.loss_points = Set(v); + } + if let Some(v) = patch.fix_points { + active.fix_points = Set(v); + } + if let Some(v) = patch.down_points { + active.down_points = Set(v); + } + if let Some(v) = patch.first_bonus { + active.first_bonus = Set(v); + } + active.update(db).await?; + Ok(Some(current)) +} + +/// 删除 EventGameBox(移除赛事选择)。被 Instance 引用时 DB 层 RESTRICT 拒绝。 +pub async fn delete_event_gamebox(db: &DatabaseConnection, id: Uuid) -> Result<(), sea_orm::DbErr> { + let _ = awd_event_gameboxes::Entity::delete_by_id(id) + .exec(db) + .await?; + Ok(()) +} diff --git a/apps/api/src/modules/event/awd_team/repo/gamebox_lib_repo.rs b/apps/api/src/modules/event/awd_team/repo/gamebox_lib_repo.rs new file mode 100644 index 00000000..e17f3804 --- /dev/null +++ b/apps/api/src/modules/event/awd_team/repo/gamebox_lib_repo.rs @@ -0,0 +1,192 @@ +//! GameBox library repository — 全局 GameBox 长期身份 + 不可变 Revision。 +//! +//! GameBox = AWD 题目的长期身份;GameBoxRevision = 不可变部署版本(§6)。 +//! 编辑 GameBox = 创建 Revision N+1,绝不修改已有 Revision。 +//! spec_digest 相同(canonical spec 未变)时不创建新 Revision(§36)。 + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, +}; +use uuid::Uuid; + +use crate::entity::{gamebox_revisions, gameboxes}; + +pub async fn find_gamebox_by_id( + db: &DatabaseConnection, + id: Uuid, +) -> Result, sea_orm::DbErr> { + gameboxes::Entity::find_by_id(id).one(db).await +} + +pub async fn find_gamebox_by_safe_name( + db: &DatabaseConnection, + safe_name: &str, +) -> Result, sea_orm::DbErr> { + gameboxes::Entity::find() + .filter(gameboxes::Column::SafeName.eq(safe_name)) + .one(db) + .await +} + +pub async fn list_gameboxes( + db: &DatabaseConnection, + include_hidden: bool, +) -> Result, sea_orm::DbErr> { + let mut q = gameboxes::Entity::find().order_by_asc(gameboxes::Column::Name); + if !include_hidden { + q = q.filter(gameboxes::Column::Hidden.eq(false)); + } + q.all(db).await +} + +pub async fn create_gamebox( + db: &DatabaseConnection, + name: String, + safe_name: String, + category: String, + description: String, + hidden: bool, +) -> Result { + let now = chrono::Utc::now().into(); + gameboxes::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name), + safe_name: Set(safe_name), + category: Set(category), + description: Set(description), + hidden: Set(hidden), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(db) + .await +} + +/// 归档(hide)GameBox:被赛事引用时禁止 hard delete(§55),一律 hide/archive。 +pub async fn set_gamebox_hidden( + db: &DatabaseConnection, + id: Uuid, + hidden: bool, +) -> Result, sea_orm::DbErr> { + let cur = match find_gamebox_by_id(db, id).await? { + Some(m) => m, + None => return Ok(None), + }; + gameboxes::ActiveModel { + id: Set(id), + hidden: Set(hidden), + updated_at: Set(chrono::Utc::now().into()), + ..Default::default() + } + .update(db) + .await?; + let _ = cur; + Ok(Some(())) +} + +// ── Revisions ── + +pub async fn find_revisions_by_gamebox( + db: &DatabaseConnection, + gamebox_id: Uuid, +) -> Result, sea_orm::DbErr> { + gamebox_revisions::Entity::find() + .filter(gamebox_revisions::Column::GameboxId.eq(gamebox_id)) + .order_by_desc(gamebox_revisions::Column::RevisionNumber) + .all(db) + .await +} + +pub async fn find_revision_by_id( + db: &DatabaseConnection, + id: Uuid, +) -> Result, sea_orm::DbErr> { + gamebox_revisions::Entity::find_by_id(id).one(db).await +} + +/// 创建 Revision N+1(immutable:创建后永不变更)。 +/// 若 canonical spec_digest 与 latest 相同 → 返回 None(不重复建 revision,§36)。 +pub struct NewRevision { + pub source_toml: String, + pub spec_json: serde_json::Value, + pub spec_digest: String, + pub image_ref: String, + pub image_digest: Option, + pub username: String, + pub default_cpu_millis: i64, + pub default_memory_bytes: i64, + pub default_pids_limit: i64, + pub healthcheck_json: Option, + pub judge_script_name: Option, + pub judge_script_content: Option, + pub judge_args_json: Option, + pub default_judge_timeout_secs: Option, + pub default_judge_retry_interval_secs: Option, +} + +pub async fn create_revision( + db: &DatabaseConnection, + gamebox_id: Uuid, + rev: NewRevision, +) -> Result, sea_orm::DbErr> { + let latest = find_revisions_by_gamebox(db, gamebox_id).await?; + if let Some(l) = latest.first() { + if l.spec_digest == rev.spec_digest { + // canonical spec 未变化 → 不创建新 revision(§36) + return Ok(None); + } + } + let next_number = latest.first().map(|l| l.revision_number + 1).unwrap_or(1); + gamebox_revisions::ActiveModel { + id: Set(Uuid::new_v4()), + gamebox_id: Set(gamebox_id), + revision_number: Set(next_number), + source_toml: Set(rev.source_toml), + spec_schema_version: Set(1), + spec_json: Set(rev.spec_json), + spec_digest: Set(rev.spec_digest), + image_ref: Set(rev.image_ref), + image_digest: Set(rev.image_digest), + username: Set(rev.username), + default_cpu_millis: Set(rev.default_cpu_millis), + default_memory_bytes: Set(rev.default_memory_bytes), + default_pids_limit: Set(rev.default_pids_limit), + healthcheck_json: Set(rev.healthcheck_json), + judge_script_name: Set(rev.judge_script_name), + judge_script_content: Set(rev.judge_script_content), + judge_args_json: Set(rev.judge_args_json), + default_judge_timeout_secs: Set(rev.default_judge_timeout_secs), + default_judge_retry_interval_secs: Set(rev.default_judge_retry_interval_secs), + created_at: Set(chrono::Utc::now().into()), + ..Default::default() + } + .insert(db) + .await + .map(Some) +} + +/// GameBox + latest revision 视图(列表 API 用)。 +pub struct GameBoxWithRevision { + pub gamebox: gameboxes::Model, + pub latest_revision: Option, +} + +pub async fn list_gameboxes_with_revisions( + db: &DatabaseConnection, + include_hidden: bool, +) -> Result, sea_orm::DbErr> { + let mut out = Vec::new(); + for gb in list_gameboxes(db, include_hidden).await? { + let latest = find_revisions_by_gamebox(db, gb.id) + .await? + .into_iter() + .next(); + out.push(GameBoxWithRevision { + gamebox: gb, + latest_revision: latest, + }); + } + Ok(out) +} diff --git a/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs b/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs index 176c3a7d..7388778a 100644 --- a/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/gamebox_repo.rs @@ -1,4 +1,8 @@ -//! GameBox template and instance repository. +//! GameBox instance repository — logical runtime instances (AWD 域). +//! +//! Instance 是「某场 Event 中一个 EventGameBox 为某个 Team 创建的稳定逻辑靶机」: +//! Docker Container 只是它的 current runtime realization(runtime_generation + 1)。 +//! 全局 GameBox identity / Revision / EventGameBox 见 gamebox_lib_repo / event_gamebox_repo。 use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, @@ -6,21 +10,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::entity::{ - awd_gamebox_instances, awd_gamebox_templates, sea_orm_active_enums::GameboxStatus, -}; - -// ── Templates ── - -pub async fn find_templates_by_event( - db: &DatabaseConnection, - event_id: Uuid, -) -> Result, sea_orm::DbErr> { - awd_gamebox_templates::Entity::find() - .filter(awd_gamebox_templates::Column::EventId.eq(event_id)) - .all(db) - .await -} +use crate::entity::{awd_gamebox_instances, sea_orm_active_enums::GameboxStatus}; // ── Instances ── @@ -66,6 +56,21 @@ pub async fn find_instance_by_id( awd_gamebox_instances::Entity::find_by_id(id).one(db).await } +/// 查询某 EventGameBox × Team 的逻辑实例(幂等部署/对账用)。 +pub async fn find_instance_by_event_gamebox_team( + db: &DatabaseConnection, + event_id: Uuid, + event_gamebox_id: Uuid, + team_id: Uuid, +) -> Result, sea_orm::DbErr> { + awd_gamebox_instances::Entity::find() + .filter(awd_gamebox_instances::Column::EventId.eq(event_id)) + .filter(awd_gamebox_instances::Column::EventGameboxId.eq(event_gamebox_id)) + .filter(awd_gamebox_instances::Column::TeamId.eq(team_id)) + .one(db) + .await +} + /// 查询队伍网络分配(ban conntrack 清理用)。 pub async fn find_team_network( db: &DatabaseConnection, @@ -93,6 +98,7 @@ pub async fn update_instance_status( Ok(()) } +/// 更新 current container(Reset/Deploy 成功后调用)。不改变 logical identity。 pub async fn set_instance_container( db: &DatabaseConnection, id: Uuid, @@ -101,10 +107,32 @@ pub async fn set_instance_container( ) -> Result<(), sea_orm::DbErr> { let mut active: awd_gamebox_instances::ActiveModel = awd_gamebox_instances::ActiveModel { id: Set(id), - container_id: Set(Some(container_id.to_string())), + current_container_id: Set(Some(container_id.to_string())), status: Set(status), ..Default::default() }; active.update(db).await?; Ok(()) } + +/// Reset 成功后:替换 current container + runtime_generation + 1(§17/§20/§24)。 +/// logical identity(id / event_gamebox_id / team_id / gamebox_ip)不变。 +pub async fn bump_runtime_generation( + db: &DatabaseConnection, + id: Uuid, + container_id: &str, +) -> Result { + let inst = find_instance_by_id(db, id) + .await? + .ok_or_else(|| sea_orm::DbErr::RecordNotFound(format!("instance {id} not found")))?; + let next_gen = inst.runtime_generation + 1; + let mut active: awd_gamebox_instances::ActiveModel = awd_gamebox_instances::ActiveModel { + id: Set(id), + current_container_id: Set(Some(container_id.to_string())), + runtime_generation: Set(next_gen), + status: Set(GameboxStatus::Ready), + ..Default::default() + }; + active.update(db).await?; + Ok(next_gen) +} diff --git a/apps/api/src/modules/event/awd_team/repo/mod.rs b/apps/api/src/modules/event/awd_team/repo/mod.rs index 5ceb443b..052f564b 100644 --- a/apps/api/src/modules/event/awd_team/repo/mod.rs +++ b/apps/api/src/modules/event/awd_team/repo/mod.rs @@ -4,8 +4,10 @@ //! raw query construction out of handlers and services. pub mod ban_repo; +pub mod event_gamebox_repo; pub mod event_repo; pub mod flag_repo; +pub mod gamebox_lib_repo; pub mod gamebox_repo; pub mod judge_repo; pub mod round_repo; diff --git a/apps/api/src/modules/event/awd_team/repo/score_repo.rs b/apps/api/src/modules/event/awd_team/repo/score_repo.rs index eaab0f25..9b409534 100644 --- a/apps/api/src/modules/event/awd_team/repo/score_repo.rs +++ b/apps/api/src/modules/event/awd_team/repo/score_repo.rs @@ -18,7 +18,7 @@ pub async fn create_score_event( idempotency_key: &str, related_team_id: Option, gamebox_instance_id: Option, - gamebox_template_id: Option, + event_gamebox_id: Option, reason: Option<&str>, ) -> Result { let model = awd_score_events::ActiveModel { @@ -31,7 +31,7 @@ pub async fn create_score_event( idempotency_key: Set(idempotency_key.to_string()), related_team_id: Set(related_team_id), gamebox_instance_id: Set(gamebox_instance_id), - gamebox_template_id: Set(gamebox_template_id), + event_gamebox_id: Set(event_gamebox_id), reason: Set(reason.map(|s| s.to_string())), ..Default::default() }; @@ -57,7 +57,7 @@ pub async fn create_score_event_if_absent( idempotency_key: &str, related_team_id: Option, gamebox_instance_id: Option, - gamebox_template_id: Option, + event_gamebox_id: Option, reason: Option<&str>, ) -> Result { let model = awd_score_events::ActiveModel { @@ -70,7 +70,7 @@ pub async fn create_score_event_if_absent( idempotency_key: Set(idempotency_key.to_string()), related_team_id: Set(related_team_id), gamebox_instance_id: Set(gamebox_instance_id), - gamebox_template_id: Set(gamebox_template_id), + event_gamebox_id: Set(event_gamebox_id), reason: Set(reason.map(|s| s.to_string())), ..Default::default() }; diff --git a/apps/api/src/modules/event/awd_team/service/archive_service.rs b/apps/api/src/modules/event/awd_team/service/archive_service.rs index 7d472ffc..89671e5d 100644 --- a/apps/api/src/modules/event/awd_team/service/archive_service.rs +++ b/apps/api/src/modules/event/awd_team/service/archive_service.rs @@ -7,7 +7,7 @@ use tracing::{info, warn}; use uuid::Uuid; use crate::entity::{ - awd_events, awd_gamebox_instances, awd_wireguard_peers, + awd_gamebox_instances, awd_wireguard_peers, sea_orm_active_enums::{AwdEventStatus, WgPeerStatus}, }; use crate::modules::event::awd_team::{ @@ -52,7 +52,7 @@ pub async fn archive_event( for instance in &instances { let target = instance - .container_id + .current_container_id .as_deref() .unwrap_or(instance.container_name.as_str()); if let Err(e) = containers.stop_container(target).await { diff --git a/apps/api/src/modules/event/awd_team/service/deploy_service.rs b/apps/api/src/modules/event/awd_team/service/deploy_service.rs index 18634a7f..684797bd 100644 --- a/apps/api/src/modules/event/awd_team/service/deploy_service.rs +++ b/apps/api/src/modules/event/awd_team/service/deploy_service.rs @@ -12,22 +12,21 @@ use uuid::Uuid; use crate::core::config::AwdStaticConfig; use crate::entity::{ - awd_events, awd_gamebox_instances, awd_gamebox_templates, awd_runtime_resources, - awd_team_networks, event_teams, - sea_orm_active_enums::{AwdEventStatus, AwdPhase, GameboxStatus}, + awd_events, awd_gamebox_instances, awd_runtime_resources, awd_team_networks, event_teams, + sea_orm_active_enums::{AwdEventStatus, GameboxStatus}, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, crypto::{AwdCrypto, EncryptedBlob}, - domain::{AwdEventStatusExt, Ipv4Cidr}, + domain::{AwdEventStatusExt, Ipv4Cidr, instance_ip_for_offset}, infrastructure::{ firewall::FirewallRuntime, network::{AwdNetworkRuntime, WireGuardDesiredState}, }, - repo::event_repo, - service::firewall_service, + repo::{event_gamebox_repo, event_repo, gamebox_repo}, + service::{firewall_service, gamebox_service}, }; -use fcmc::{AwdContainerRuntime, EventNetworkSpec, GameBoxSpec, InfrastructureContainerSpec}; +use fcmc::{AwdContainerRuntime, EventNetworkSpec, InfrastructureContainerSpec}; /// Orchestrate full deployment of an AWD event. /// @@ -371,12 +370,6 @@ async fn ensure_teams_and_gameboxes( .subnets(24) .ok_or_else(|| AwdError::Validation("wireguard_cidr must be broader than /24".into()))?; - let templates = awd_gamebox_templates::Entity::find() - .filter(awd_gamebox_templates::Column::EventId.eq(event_id)) - .all(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - // ── P1-14:队伍子网分配 + awd_team_networks 插入事务化 ── // 单个 DB 事务内完成「查询已有行 → 无则分配 /24 并插入」,commit 后统一生效; // 任一步失败整体 rollback,不留半截分配状态。已有 awd_team_networks 行的队伍 @@ -423,7 +416,6 @@ async fn ensure_teams_and_gameboxes( ssh_password_ciphertext: Set(blob.ciphertext), ssh_password_nonce: Set(blob.nonce), key_version: Set(awd_event.key_version), - next_gamebox_host: Set(2), next_wireguard_host: Set(2), ..Default::default() } @@ -441,35 +433,36 @@ async fn ensure_teams_and_gameboxes( .map_err(|e| AwdError::Database(e.to_string()))?; // ── GameBox 实例行 + 容器创建(事务外:Docker 调用不占用事务连接)── - for (team, team_network) in team_networks { - // Decrypt SSH password for container create - let password = { - let blob = EncryptedBlob { - ciphertext: team_network.ssh_password_ciphertext.clone(), - nonce: team_network.ssh_password_nonce.clone(), - key_version: team_network.key_version, - }; - let aad = AwdCrypto::build_aad(event_id, "ssh_password"); - let bytes = crypto - .decrypt(&blob, &aad) - .map_err(|e| AwdError::Crypto(e.to_string()))?; - String::from_utf8(bytes).map_err(|e| AwdError::Crypto(e.to_string()))? - }; - - let team_net_parsed = Ipv4Cidr::parse(&team_network.gamebox_subnet) - .map_err(|e| AwdError::Validation(e.to_string()))?; + // 确定性 IP:instance_ip = team.gamebox_subnet + event_gamebox.host_offset(§13/§66), + // 与模板枚举顺序无关;新增队伍/新增 GameBox 不影响已分配 IP。 + let event_gameboxes = event_gamebox_repo::find_event_gameboxes_by_event(db, event_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; - for (ti, template) in templates.iter().enumerate() { - let existing = awd_gamebox_instances::Entity::find() - .filter(awd_gamebox_instances::Column::EventId.eq(event_id)) - .filter(awd_gamebox_instances::Column::TemplateId.eq(template.id)) - .filter(awd_gamebox_instances::Column::TeamId.eq(team.id)) - .one(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + for (team, team_network) in team_networks { + // Decrypt SSH password(team-level 凭据,§22.1) + let password = + gamebox_service::decrypt_team_ssh_password(crypto, event_id, &team_network).await?; + + for eg in &event_gameboxes { + // 单一 resolver:Revision 默认值 + Event 覆盖(§49/§50) + let resolved = gamebox_service::resolve_event_gamebox_spec(db, eg.id).await?; + + let gamebox_ip = instance_ip_for_offset(&team_network.gamebox_subnet, eg.host_offset) + .ok_or_else(|| { + AwdError::Network(format!( + "no host IP for subnet {} offset {}", + team_network.gamebox_subnet, eg.host_offset + )) + })?; + + let existing = + gamebox_repo::find_instance_by_event_gamebox_team(db, event_id, eg.id, team.id) + .await + .map_err(|e| AwdError::Database(e.to_string()))?; - let (instance_id, container_name, gamebox_ip) = if let Some(inst) = existing { - if inst.container_id.is_some() + let (instance_id, container_name) = if let Some(inst) = existing { + if inst.current_container_id.is_some() && matches!( inst.status, GameboxStatus::Ready | GameboxStatus::Running | GameboxStatus::Creating @@ -477,75 +470,60 @@ async fn ensure_teams_and_gameboxes( { continue; } - (inst.id, inst.container_name, inst.gamebox_ip) + (inst.id, inst.container_name) } else { - let host_n = (ti as u32) + 1; // 1st usable host index in nth_host (0=first usable) - let ip = team_net_parsed - .nth_host(host_n) - .ok_or_else(|| AwdError::Network("no host IP in team subnet".into()))? - .to_string(); let container_name = format!( "fctf-{}-t{}-team{}", &event_id.to_string()[..8], - &template.id.to_string()[..4], + &eg.id.to_string()[..4], &team.id.to_string()[..4] ); let id = Uuid::new_v4(); awd_gamebox_instances::ActiveModel { id: Set(id), event_id: Set(event_id), - template_id: Set(template.id), + event_gamebox_id: Set(eg.id), team_id: Set(team.id), status: Set(GameboxStatus::Pending), container_name: Set(container_name.clone()), - gamebox_ip: Set(ip.clone()), - docker_network_id: Set(Some(docker_network_id.to_string())), + gamebox_ip: Set(gamebox_ip.clone()), + runtime_generation: Set(1), health_status: Set("unknown".to_string()), ..Default::default() } .insert(db) .await .map_err(|e| AwdError::Database(e.to_string()))?; - (id, container_name, ip) + (id, container_name) }; - // Mark creating then create container + // Mark creating then create container(runtime_generation=1 首次部署) gamebox_set_status(db, instance_id, GameboxStatus::Creating).await?; + let spec = gamebox_service::build_gamebox_runtime_spec( + &resolved, + awd_event, + instance_id, + eg.id, + team.id, + &container_name, + &gamebox_ip, + docker_network_name, + password.clone(), + 1, + )?; + let handle = containers - .create_gamebox(GameBoxSpec { - event_id, - team_id: team.id, - template_id: template.id, - instance_id, - container_name: container_name.clone(), - image_ref: template.image_ref.clone(), - network_name: docker_network_name.to_string(), - fixed_ip: gamebox_ip, - username: template.username.clone(), - password: password.clone(), - cpu_millis: template.cpu_millis, - memory_bytes: template.memory_bytes, - pids_limit: template.pids_limit, - healthcheck: None, - extra_hosts: vec![ - format!("flagserver:{}", awd_event.flagserver_ip), - format!("judgeserver:{}", awd_event.judgeserver_ip), - ], - labels: std::collections::HashMap::new(), - }) + .create_gamebox(spec) .await - .map_err(|e| { - // Best-effort status - AwdError::Docker(e.to_string()) - }); + .map_err(|e| AwdError::Docker(e.to_string())); match handle { Ok(h) => { let mut active: awd_gamebox_instances::ActiveModel = awd_gamebox_instances::ActiveModel { id: Set(instance_id), - container_id: Set(Some(h.container_id)), + current_container_id: Set(Some(h.container_id)), status: Set(GameboxStatus::Ready), health_status: Set("unknown".to_string()), ..Default::default() @@ -811,7 +789,7 @@ mod tests { id: Uuid::from_u128(id), event_id: Uuid::from_u128(id + 1), status: AwdEventStatus::Draft, - phase: AwdPhase::Hardening, + phase: crate::entity::sea_orm_active_enums::AwdPhase::Hardening, gamebox_cidr: gamebox_cidr.to_string(), wireguard_cidr: wireguard_cidr.to_string(), wireguard_interface_name: format!("wg-{id}"), @@ -868,7 +846,6 @@ mod tests { ssh_password_ciphertext: vec![], ssh_password_nonce: vec![], key_version: 1, - next_gamebox_host: 2, next_wireguard_host: 2, status: "allocated".to_string(), created_at: now.clone(), diff --git a/apps/api/src/modules/event/awd_team/service/gamebox_service.rs b/apps/api/src/modules/event/awd_team/service/gamebox_service.rs index 446debdd..af9ae6af 100644 --- a/apps/api/src/modules/event/awd_team/service/gamebox_service.rs +++ b/apps/api/src/modules/event/awd_team/service/gamebox_service.rs @@ -1,97 +1,457 @@ -//! GameBox lifecycle service. +//! GameBox library service + 单一 runtime spec resolver(§49/§50)。 +//! +//! 本服务承载: +//! 1. 全局 GameBox 库:创建 GameBox(自动 Revision 1)、编辑(→ Revision N+1,§36)、 +//! safe_name 校验/去重、spec_digest 幂等(canonical spec 未变不建新 revision)。 +//! 2. `resolve_event_gamebox_spec` —— Deploy / Reset / Recovery / Precheck 唯一共享的 +//! effective config 解析入口(Revision 默认值 + EventGameBox 允许覆盖)。 +//! 3. `build_gamebox_runtime_spec` —— 从 resolved spec 组装 fcmc::GameBoxSpec +//! (image@digest、labels 绑定 logical identity、healthcheck 生效)。 -use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection}; +use std::collections::BTreeMap; + +use sea_orm::DatabaseConnection; +use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::entity::sea_orm_active_enums::{AwdEventStatus, GameboxStatus}; +use crate::entity::{ + awd_event_gameboxes, awd_events, awd_team_networks, gamebox_revisions, gameboxes, +}; use crate::modules::event::awd_team::{ AwdError, AwdResult, - domain::{AwdEventStatusExt, GameboxStatusExt}, - repo::{ban_repo, event_repo, gamebox_repo}, + crypto::AwdCrypto, + domain::{slugify, validate_safe_name}, + repo::{event_gamebox_repo, gamebox_lib_repo}, }; -/// Reset a GameBox instance. -/// Validates permissions and deducts reset penalty if applicable. -pub async fn reset_gamebox( +// --------------------------------------------------------------------------- +// GameBox 库:配置与 canonical spec +// --------------------------------------------------------------------------- + +/// 管理员编辑 GameBox 时提供的运行时配置(= Revision 内容)。 +#[derive(Debug, Clone, Default)] +pub struct GameBoxConfig { + pub source_toml: String, + pub image_ref: String, + pub image_digest: Option, + pub username: String, + pub cpu_millis: i64, + pub memory_bytes: i64, + pub pids_limit: i64, + pub healthcheck: Option, + pub judge_script_name: Option, + pub judge_script_content: Option, + pub judge_args_json: Option, + pub judge_timeout_secs: Option, + pub judge_retry_interval_secs: Option, +} + +/// canonical spec:BTreeMap 保证键序确定 → JSON 序列化确定 → digest 稳定(§7)。 +pub fn canonical_spec(config: &GameBoxConfig) -> serde_json::Value { + let mut m = BTreeMap::new(); + m.insert("image_ref".to_string(), serde_json::json!(config.image_ref)); + if let Some(d) = &config.image_digest { + m.insert("image_digest".to_string(), serde_json::json!(d)); + } + m.insert("username".to_string(), serde_json::json!(config.username)); + m.insert( + "cpu_millis".to_string(), + serde_json::json!(config.cpu_millis), + ); + m.insert( + "memory_bytes".to_string(), + serde_json::json!(config.memory_bytes), + ); + m.insert( + "pids_limit".to_string(), + serde_json::json!(config.pids_limit), + ); + if let Some(h) = &config.healthcheck { + m.insert("healthcheck".to_string(), h.clone()); + } + if let Some(n) = &config.judge_script_name { + m.insert("judge_script_name".to_string(), serde_json::json!(n)); + } + if let Some(c) = &config.judge_script_content { + m.insert("judge_script_content".to_string(), serde_json::json!(c)); + } + if let Some(a) = &config.judge_args_json { + m.insert("judge_args".to_string(), a.clone()); + } + if let Some(t) = config.judge_timeout_secs { + m.insert("judge_timeout_secs".to_string(), serde_json::json!(t)); + } + if let Some(t) = config.judge_retry_interval_secs { + m.insert( + "judge_retry_interval_secs".to_string(), + serde_json::json!(t), + ); + } + serde_json::Value::Object(m.into_iter().collect()) +} + +pub fn spec_digest(spec: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(spec.to_string().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +pub fn validate_config(config: &GameBoxConfig) -> AwdResult<()> { + if config.image_ref.trim().is_empty() { + return Err(AwdError::Validation("image_ref 不能为空".into())); + } + if config.username.trim().is_empty() { + return Err(AwdError::Validation("username 不能为空".into())); + } + if config.cpu_millis <= 0 || config.memory_bytes <= 0 || config.pids_limit <= 0 { + return Err(AwdError::Validation("资源限制必须为正数".into())); + } + Ok(()) +} + +/// 创建 GameBox identity + Revision 1(新题入库)。 +pub async fn create_gamebox_with_revision( db: &DatabaseConnection, - event_id: Uuid, - instance_id: Uuid, - team_id: Uuid, - _requested_by: Uuid, - is_free_reset: bool, -) -> AwdResult<()> { - // 1. Verify event is running - let awd_event = event_repo::find_by_event_id(db, event_id) + name: String, + safe_name: String, + category: String, + description: String, + hidden: bool, + config: GameBoxConfig, +) -> AwdResult<(gameboxes::Model, gamebox_revisions::Model)> { + validate_safe_name(&safe_name).map_err(AwdError::Validation)?; + validate_config(&config)?; + + if gamebox_lib_repo::find_gamebox_by_safe_name(db, &safe_name) .await .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; - - if !awd_event.status.is_active() { - return Err(AwdError::Forbidden("Event is not running".into())); + .is_some() + { + return Err(AwdError::Conflict(format!("safe_name 已存在: {safe_name}"))); } - // 2. Verify team not banned - let ban = ban_repo::find_active_ban(db, event_id, team_id) + let spec = canonical_spec(&config); + let digest = spec_digest(&spec); + let gb = gamebox_lib_repo::create_gamebox(db, name, safe_name, category, description, hidden) .await .map_err(|e| AwdError::Database(e.to_string()))?; - if ban.is_some() { - return Err(AwdError::Forbidden("Team is banned".into())); - } - // 3. Verify instance belongs to team - let instance = gamebox_repo::find_instance_by_id(db, instance_id) + let rev = gamebox_lib_repo::create_revision( + db, + gb.id, + gamebox_lib_repo::NewRevision { + source_toml: config.source_toml, + spec_json: spec, + spec_digest: digest, + image_ref: config.image_ref, + image_digest: config.image_digest, + username: config.username, + default_cpu_millis: config.cpu_millis, + default_memory_bytes: config.memory_bytes, + default_pids_limit: config.pids_limit, + healthcheck_json: config.healthcheck, + judge_script_name: config.judge_script_name, + judge_script_content: config.judge_script_content, + judge_args_json: config.judge_args_json, + default_judge_timeout_secs: config.judge_timeout_secs, + default_judge_retry_interval_secs: config.judge_retry_interval_secs, + }, + ) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::Internal("revision 1 creation unexpectedly skipped".into()))?; + + Ok((gb, rev)) +} + +/// 编辑 GameBox:canonical spec 变化才创建 Revision N+1(§36);已 pin 的赛事不受影响。 +/// 返回 None 表示 spec 未变化(未创建新 revision)。 +pub async fn edit_gamebox_revision( + db: &DatabaseConnection, + gamebox_id: Uuid, + config: GameBoxConfig, +) -> AwdResult> { + validate_config(&config)?; + if gamebox_lib_repo::find_gamebox_by_id(db, gamebox_id) .await .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("GameBox instance not found".into()))?; - - if instance.team_id != team_id { - return Err(AwdError::Forbidden( - "This GameBox does not belong to your team".into(), - )); + .is_none() + { + return Err(AwdError::NotFound("GameBox not found".into())); } - // 4. Mark as resetting - gamebox_repo::update_instance_status(db, instance_id, GameboxStatus::Resetting) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; + let spec = canonical_spec(&config); + let digest = spec_digest(&spec); + gamebox_lib_repo::create_revision( + db, + gamebox_id, + gamebox_lib_repo::NewRevision { + source_toml: config.source_toml, + spec_json: spec, + spec_digest: digest, + image_ref: config.image_ref, + image_digest: config.image_digest, + username: config.username, + default_cpu_millis: config.cpu_millis, + default_memory_bytes: config.memory_bytes, + default_pids_limit: config.pids_limit, + healthcheck_json: config.healthcheck, + judge_script_name: config.judge_script_name, + judge_script_content: config.judge_script_content, + judge_args_json: config.judge_args_json, + default_judge_timeout_secs: config.judge_timeout_secs, + default_judge_retry_interval_secs: config.judge_retry_interval_secs, + }, + ) + .await + .map_err(|e| AwdError::Database(e.to_string())) +} - // 5. Set reset protection - let protection_until = - chrono::Utc::now() + chrono::Duration::seconds(awd_event.reset_protection_secs as i64); - - let mut active: crate::entity::awd_gamebox_instances::ActiveModel = - crate::entity::awd_gamebox_instances::ActiveModel { - id: Set(instance_id), - reset_protection_until: Set(Some(protection_until.into())), - ..Default::default() - }; - active - .update(db) +/// safe_name 生成 + 去重(展示名 → 唯一 safe_name)。 +pub async fn unique_safe_name(db: &DatabaseConnection, display_name: &str) -> AwdResult { + let base = slugify(display_name); + let base = if base.is_empty() { + "gamebox".to_string() + } else { + base + }; + let mut candidate = base.clone(); + let mut i = 1; + while gamebox_lib_repo::find_gamebox_by_safe_name(db, &candidate) .await - .map_err(|e| AwdError::Database(e.to_string()))?; + .map_err(|e| AwdError::Database(e.to_string()))? + .is_some() + { + candidate = format!("{base}-{i}"); + i += 1; + if i > 1000 { + return Err(AwdError::Internal("safe_name 去重溢出".into())); + } + } + Ok(candidate) +} - // Note: Actual Docker container reset is handled by fcmc AwdContainerRuntime. - // After Docker reset completes, the instance status should be set to Ready. +// --------------------------------------------------------------------------- +// 单一 resolver(§49/§50) +// --------------------------------------------------------------------------- - Ok(()) +/// EventGameBox + pin Revision + GameBox identity 的 effective runtime spec。 +/// 所有 Deploy / Reset / Recovery / Precheck 必须经它解析,禁止各自拼配置。 +#[derive(Debug, Clone)] +pub struct ResolvedGameBoxRuntimeSpec { + pub event_gamebox: awd_event_gameboxes::Model, + pub revision: gamebox_revisions::Model, + pub gamebox: gameboxes::Model, + pub effective_cpu_millis: i64, + pub effective_memory_bytes: i64, + pub effective_pids_limit: i64, + pub effective_healthcheck_json: Option, + pub effective_judge_timeout_secs: Option, + pub effective_judge_retry_interval_secs: Option, } -/// Mark a GameBox instance as ready after successful container creation. -pub async fn mark_gamebox_ready( +/// image@digest(digest 已 pin 时),否则退回 image_ref(§8)。 +pub fn pinned_image_ref(image_ref: &str, image_digest: &Option) -> String { + match image_digest { + Some(d) if !d.is_empty() => format!("{image_ref}@{d}"), + _ => image_ref.to_string(), + } +} + +impl ResolvedGameBoxRuntimeSpec { + pub fn effective_image_ref(&self) -> String { + pinned_image_ref(&self.revision.image_ref, &self.revision.image_digest) + } + + pub fn effective_healthcheck(&self) -> Result, AwdError> { + match &self.effective_healthcheck_json { + None => Ok(None), + Some(v) => serde_json::from_value(v.clone()) + .map(Some) + .map_err(|e| AwdError::Validation(format!("healthcheck JSON 非法: {e}"))), + } + } +} + +/// 从 EventGameBox 解析 effective runtime spec(Revision 默认值 + 赛事覆盖)。 +pub async fn resolve_event_gamebox_spec( db: &DatabaseConnection, - instance_id: Uuid, - container_id: &str, -) -> AwdResult<()> { - gamebox_repo::set_instance_container(db, instance_id, container_id, GameboxStatus::Ready) + event_gamebox_id: Uuid, +) -> AwdResult { + let eg = event_gamebox_repo::find_event_gamebox_by_id(db, event_gamebox_id) .await - .map_err(|e| AwdError::Database(e.to_string()))?; - Ok(()) + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("EventGameBox not found".into()))?; + let revision = event_gamebox_repo::find_pinned_revision(db, &eg) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("pinned GameBoxRevision not found".into()))?; + let gamebox = event_gamebox_repo::find_gamebox_identity(db, eg.gamebox_id) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("GameBox identity not found".into()))?; + + Ok(ResolvedGameBoxRuntimeSpec { + effective_cpu_millis: eg.cpu_millis, + effective_memory_bytes: eg.memory_bytes, + effective_pids_limit: eg.pids_limit, + effective_healthcheck_json: eg + .healthcheck_override_json + .clone() + .or_else(|| revision.healthcheck_json.clone()), + effective_judge_timeout_secs: eg + .judge_timeout_secs + .or(revision.default_judge_timeout_secs), + effective_judge_retry_interval_secs: eg + .judge_retry_interval_secs + .or(revision.default_judge_retry_interval_secs), + event_gamebox: eg, + revision, + gamebox, + }) } -/// Mark a GameBox instance as failed after container creation failure. -pub async fn mark_gamebox_failed(db: &DatabaseConnection, instance_id: Uuid) -> AwdResult<()> { - gamebox_repo::update_instance_status(db, instance_id, GameboxStatus::StartFailed) - .await - .map_err(|e| AwdError::Database(e.to_string()))?; - Ok(()) +// --------------------------------------------------------------------------- +// 共享 fcmc spec 构建(Deploy / Reset 同一路径,§50) +// --------------------------------------------------------------------------- + +/// 从 resolved spec 组装 fcmc::GameBoxSpec。纯函数(DB/加密已由调用方完成)。 +/// Deploy(新建 instance)与 Reset(复用 instance)共用此路径(§50)。 +#[allow(clippy::too_many_arguments)] +pub fn build_gamebox_runtime_spec( + resolved: &ResolvedGameBoxRuntimeSpec, + awd_event: &awd_events::Model, + instance_id: Uuid, + event_gamebox_id: Uuid, + team_id: Uuid, + container_name: &str, + gamebox_ip: &str, + network_name: &str, + password: String, + runtime_generation: i64, +) -> AwdResult { + let healthcheck = resolved.effective_healthcheck()?; + Ok(fcmc::GameBoxSpec { + event_id: awd_event.event_id, + team_id, + event_gamebox_id, + instance_id, + runtime_generation, + container_name: container_name.to_string(), + image_ref: resolved.effective_image_ref(), + network_name: network_name.to_string(), + fixed_ip: gamebox_ip.to_string(), + username: resolved.revision.username.clone(), + password, + cpu_millis: resolved.effective_cpu_millis, + memory_bytes: resolved.effective_memory_bytes, + pids_limit: resolved.effective_pids_limit, + healthcheck, + extra_hosts: vec![ + format!("flagserver:{}", awd_event.flagserver_ip), + format!("judgeserver:{}", awd_event.judgeserver_ip), + ], + labels: std::collections::HashMap::new(), // fcmc 内部按逻辑身份重打标签 + }) +} + +/// 解密队伍 SSH 密码(team-level 凭据,§22.1:产品契约为一队一个密码)。 +pub async fn decrypt_team_ssh_password( + crypto: &AwdCrypto, + event_id: Uuid, + team_net: &awd_team_networks::Model, +) -> AwdResult { + use crate::modules::event::awd_team::crypto::EncryptedBlob; + let blob = EncryptedBlob { + ciphertext: team_net.ssh_password_ciphertext.clone(), + nonce: team_net.ssh_password_nonce.clone(), + key_version: team_net.key_version, + }; + let aad = AwdCrypto::build_aad(event_id, "ssh_password"); + let bytes = crypto + .decrypt(&blob, &aad) + .map_err(|e| AwdError::Crypto(e.to_string()))?; + String::from_utf8(bytes).map_err(|e| AwdError::Crypto(e.to_string())) +} + +// --------------------------------------------------------------------------- +// 测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn base_config() -> GameBoxConfig { + GameBoxConfig { + source_toml: String::new(), + image_ref: "registry/easy-web:v1".into(), + image_digest: None, + username: "ctf".into(), + cpu_millis: 1000, + memory_bytes: 512 * 1024 * 1024, + pids_limit: 100, + healthcheck: None, + judge_script_name: None, + judge_script_content: None, + judge_args_json: None, + judge_timeout_secs: None, + judge_retry_interval_secs: None, + } + } + + #[test] + fn canonical_spec_is_deterministic() { + let c1 = canonical_spec(&base_config()); + let c2 = canonical_spec(&base_config()); + assert_eq!(c1, c2); + assert_eq!(spec_digest(&c1), spec_digest(&c2)); + } + + #[test] + fn spec_change_changes_digest() { + let a = base_config(); + let mut b = base_config(); + b.cpu_millis = 2000; + let da = spec_digest(&canonical_spec(&a)); + let db = spec_digest(&canonical_spec(&b)); + assert_ne!(da, db, "资源变化必须产生新 digest(新 revision)"); + } + + #[test] + fn field_order_does_not_change_digest() { + // §7:字段顺序/空格变化不产生无意义 revision —— canonical 序列化保证 + let a = base_config(); + let da = spec_digest(&canonical_spec(&a)); + let same = base_config(); + let db = spec_digest(&canonical_spec(&same)); + assert_eq!(da, db); + } + + #[test] + fn effective_image_ref_pins_digest() { + assert_eq!( + pinned_image_ref("registry/easy-web:v1", &Some("sha256:abc123".into())), + "registry/easy-web:v1@sha256:abc123" + ); + // digest 为空 → 退回 tag(§8.1 legacy 允许 NULL,生产前必须 pin) + assert_eq!( + pinned_image_ref("registry/easy-web:v1", &None), + "registry/easy-web:v1" + ); + assert_eq!( + pinned_image_ref("registry/easy-web:v1", &Some(String::new())), + "registry/easy-web:v1" + ); + } + + #[test] + fn validate_config_rejects_empty() { + let mut c = base_config(); + c.image_ref = "".into(); + assert!(validate_config(&c).is_err()); + let mut c2 = base_config(); + c2.memory_bytes = 0; + assert!(validate_config(&c2).is_err()); + } } diff --git a/apps/api/src/modules/event/awd_team/service/judge_service.rs b/apps/api/src/modules/event/awd_team/service/judge_service.rs index 160ce2b4..7a98a5af 100644 --- a/apps/api/src/modules/event/awd_team/service/judge_service.rs +++ b/apps/api/src/modules/event/awd_team/service/judge_service.rs @@ -10,14 +10,14 @@ use tracing::info; use uuid::Uuid; use crate::entity::{ - awd_gamebox_instances, awd_gamebox_templates, awd_judge_batches, awd_judge_tasks, - awd_team_bans, + awd_gamebox_instances, awd_judge_batches, awd_judge_tasks, awd_team_bans, sea_orm_active_enums::{BanStatus, GameboxStatus, JudgeTaskStatus}, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, domain::JudgeTaskStatusExt, - repo::{event_repo, gamebox_repo, judge_repo}, + repo::{event_gamebox_repo, event_repo, gamebox_repo, judge_repo}, + service::gamebox_service, }; #[derive(Debug, Serialize, PartialEq, Eq)] @@ -67,15 +67,15 @@ pub async fn create_batch( .map_err(|e| AwdError::Database(e.to_string()))? .ok_or_else(|| AwdError::NotFound("AWD event not found".into()))?; - // Get all templates - let templates = awd_gamebox_templates::Entity::find() - .filter(awd_gamebox_templates::Column::EventId.eq(event_id)) - .all(db) + // Get all EventGameBoxes(§27:Judge 目标是 EventGameBox,配置从 Revision 解析) + let event_gameboxes = event_gamebox_repo::find_event_gameboxes_by_event(db, event_id) .await .map_err(|e| AwdError::Database(e.to_string()))?; - if templates.is_empty() { - return Err(AwdError::Validation("No templates for event".into())); + if event_gameboxes.is_empty() { + return Err(AwdError::Validation( + "No gameboxes configured for event".into(), + )); } // Get all teams @@ -102,7 +102,7 @@ pub async fn create_batch( .map_err(|e| AwdError::Database(e.to_string()))?; // Create batch - let task_count = templates.len() * teams.len(); + let task_count = event_gameboxes.len() * teams.len(); let batch = judge_repo::create_batch(db, event_id, round_id, task_count as i32) .await .map_err(|e| AwdError::Database(e.to_string()))?; @@ -118,11 +118,11 @@ pub async fn create_batch( continue; } - for template in &templates { - // Find the instance for this team × template + for eg in &event_gameboxes { + // Find the instance for this team × EventGameBox let instance = instances .iter() - .find(|i| i.team_id == team.id && i.template_id == template.id); + .find(|i| i.team_id == team.id && i.event_gamebox_id == eg.id); let (instance_id, status) = match instance { Some(inst) => { @@ -144,7 +144,7 @@ pub async fn create_batch( event_id: Set(event_id), round_id: Set(round_id), gamebox_instance_id: Set(instance_id), - template_id: Set(template.id), + event_gamebox_id: Set(Some(eg.id)), team_id: Set(team.id), status: Set(status), max_attempts: Set(2), @@ -219,17 +219,18 @@ pub async fn dispatch_batch( return Ok(()); } - let template_ids: HashSet = tasks.iter().map(|task| task.template_id).collect(); + let event_gamebox_ids: HashSet = + tasks.iter().filter_map(|t| t.event_gamebox_id).collect(); let instance_ids: HashSet = tasks.iter().map(|task| task.gamebox_instance_id).collect(); - let templates = awd_gamebox_templates::Entity::find() - .filter(awd_gamebox_templates::Column::Id.is_in(template_ids)) - .all(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - .into_iter() - .map(|template| (template.id, template)) - .collect::>(); + // 每个 EventGameBox → 单一 resolver(pin Revision + Event 覆盖,§27/§49) + let mut resolved_specs = HashMap::new(); + for eg_id in &event_gamebox_ids { + resolved_specs.insert( + *eg_id, + gamebox_service::resolve_event_gamebox_spec(db, *eg_id).await?, + ); + } let instances = awd_gamebox_instances::Entity::find() .filter(awd_gamebox_instances::Column::Id.is_in(instance_ids)) @@ -242,9 +243,12 @@ pub async fn dispatch_batch( let mut dispatch_tasks = Vec::with_capacity(tasks.len()); for task in &tasks { - let template = templates.get(&task.template_id).ok_or_else(|| { - AwdError::NotFound(format!("Judge template {} not found", task.template_id)) - })?; + let eg_id = task + .event_gamebox_id + .ok_or_else(|| AwdError::NotFound("Judge task lacks event_gamebox_id".into()))?; + let resolved = resolved_specs + .get(&eg_id) + .ok_or_else(|| AwdError::NotFound(format!("EventGameBox {} not found", eg_id)))?; let instance = instances.get(&task.gamebox_instance_id).ok_or_else(|| { AwdError::NotFound(format!( "GameBox instance {} not found", @@ -252,24 +256,25 @@ pub async fn dispatch_batch( )) })?; - let script_content = template + let script_content = resolved + .revision .judge_script_content .as_deref() .filter(|content| !content.trim().is_empty()) .ok_or_else(|| { AwdError::Validation(format!( - "Template {} has no judge script content", - template.id + "Revision {} has no judge script content", + resolved.revision.id )) })?; - let script_args_json = serialize_script_args(template.judge_args_json.as_ref())?; - let timeout_secs = template - .judge_timeout_secs + let script_args_json = serialize_script_args(resolved.revision.judge_args_json.as_ref())?; + let timeout_secs = resolved + .effective_judge_timeout_secs .unwrap_or(awd_event.judge_default_timeout_secs); if timeout_secs <= 0 { return Err(AwdError::Validation(format!( - "Template {} has an invalid judge timeout", - template.id + "EventGameBox {} has an invalid judge timeout", + eg_id ))); } diff --git a/apps/api/src/modules/event/awd_team/service/precheck_service.rs b/apps/api/src/modules/event/awd_team/service/precheck_service.rs index cc73b29a..7ce9bd1c 100644 --- a/apps/api/src/modules/event/awd_team/service/precheck_service.rs +++ b/apps/api/src/modules/event/awd_team/service/precheck_service.rs @@ -165,19 +165,16 @@ pub async fn run_precheck( )); } - // ── Check 3: GameBox instances exist for all templates × teams ── - use crate::entity::awd_gamebox_templates; - let templates = awd_gamebox_templates::Entity::find() - .filter(awd_gamebox_templates::Column::EventId.eq(event_id)) - .all(db) + // ── Check 3: GameBox instances exist for all EventGameBoxes × teams ── + let event_gameboxes = + crate::modules::event::awd_team::repo::event_gamebox_repo::find_event_gameboxes_by_event( + db, event_id, + ) .await .map_err(|e| AwdError::Database(e.to_string()))?; - if templates.is_empty() { - errors.push(( - "templates".to_string(), - "No GameBox templates configured".into(), - )); + if event_gameboxes.is_empty() { + errors.push(("gameboxes".to_string(), "No EventGameBox configured".into())); } // ── Check 4: Docker network ID set ── @@ -431,10 +428,10 @@ async fn check_containers( .push(("gamebox".to_string(), "no GameBox instances".to_string())); } for inst in instances { - let Some(container_id) = inst.container_id.as_deref() else { + let Some(container_id) = inst.current_container_id.as_deref() else { report.errors.push(( "gamebox".to_string(), - format!("{}: no container_id in DB", inst.container_name), + format!("{}: no current_container_id in DB", inst.container_name), )); continue; }; diff --git a/apps/api/src/modules/event/awd_team/service/recovery_service.rs b/apps/api/src/modules/event/awd_team/service/recovery_service.rs index a0850dcf..59490fa3 100644 --- a/apps/api/src/modules/event/awd_team/service/recovery_service.rs +++ b/apps/api/src/modules/event/awd_team/service/recovery_service.rs @@ -1,8 +1,6 @@ //! Startup recovery — reconcile platform state with Docker after restart. -use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, -}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, QueryFilter}; use tracing::{error, info, warn}; use uuid::Uuid; @@ -94,11 +92,11 @@ async fn recover_event( for instance in &instances { match live_by_name.get(&instance.container_name) { Some(state) if state.running => { - if instance.container_id.as_deref() != Some(state.container_id.as_str()) { + if instance.current_container_id.as_deref() != Some(state.container_id.as_str()) { let mut active: awd_gamebox_instances::ActiveModel = awd_gamebox_instances::ActiveModel { id: Set(instance.id), - container_id: Set(Some(state.container_id.clone())), + current_container_id: Set(Some(state.container_id.clone())), status: Set(GameboxStatus::Ready), ..Default::default() }; diff --git a/apps/api/src/modules/event/awd_team/service/reset_service.rs b/apps/api/src/modules/event/awd_team/service/reset_service.rs index 7dc1602d..29b83ebd 100644 --- a/apps/api/src/modules/event/awd_team/service/reset_service.rs +++ b/apps/api/src/modules/event/awd_team/service/reset_service.rs @@ -12,21 +12,20 @@ //! Failed resets: mark as reset_failed, leave container state for admin inspection. use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, - PaginatorTrait, QueryFilter, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, }; use tracing::{error, info}; use uuid::Uuid; use crate::entity::{ - awd_events, awd_gamebox_instances, awd_gamebox_templates, awd_reset_records, awd_team_networks, + awd_gamebox_instances, awd_reset_records, awd_team_networks, sea_orm_active_enums::{GameboxStatus, ScoreEventType}, }; use crate::modules::event::awd_team::{ AwdError, AwdResult, domain::{AwdEventStatusExt, GameboxStatusExt}, repo::{ban_repo, event_repo, gamebox_repo, score_repo}, - service::score_service, + service::{gamebox_service, score_service}, }; /// 重置发起方(P4-1 显式化,废弃 admin 传 `Uuid::nil()` hack)。 @@ -159,68 +158,51 @@ pub async fn execute_reset( .await .map_err(|e| AwdError::Database(e.to_string()))?; - // 9. Docker reset: stop/remove + recreate with same IP / name - let template = awd_gamebox_templates::Entity::find_by_id(instance.template_id) - .one(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("GameBox template not found".into()))?; + // 9. Docker reset: stop/remove + recreate with same IP / credential / pinned revision + // + // §24/§25/§68:Reset 必须使用 Instance → EventGameBox → **pinned** revision, + // 即使全局 GameBox 已发布 Revision N+1,本赛事仍按 pin 的 Revision 重建。 + let resolved = + gamebox_service::resolve_event_gamebox_spec(db, instance.event_gamebox_id).await?; let network_name = awd_event .docker_network_name .clone() .unwrap_or_else(|| format!("fctf-awd-{}", &ctx.event_id.to_string()[..8])); - let password = { - use crate::modules::event::awd_team::crypto::{AwdCrypto, EncryptedBlob}; - let team_net = awd_team_networks::Entity::find() - .filter(awd_team_networks::Column::EventId.eq(ctx.event_id)) - .filter(awd_team_networks::Column::TeamId.eq(instance.team_id)) - .one(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - .ok_or_else(|| AwdError::NotFound("team network not found".into()))?; - let crypto = - AwdCrypto::from_config_secret().map_err(|e| AwdError::Crypto(e.to_string()))?; - let blob = EncryptedBlob { - ciphertext: team_net.ssh_password_ciphertext, - nonce: team_net.ssh_password_nonce, - key_version: team_net.key_version, - }; - let aad = AwdCrypto::build_aad(ctx.event_id, "ssh_password"); - let bytes = crypto - .decrypt(&blob, &aad) - .map_err(|e| AwdError::Crypto(e.to_string()))?; - String::from_utf8(bytes).map_err(|e| AwdError::Crypto(e.to_string()))? - }; + let crypto = crate::modules::event::awd_team::crypto::AwdCrypto::from_config_secret() + .map_err(|e| AwdError::Crypto(e.to_string()))?; + let team_net = awd_team_networks::Entity::find() + .filter(awd_team_networks::Column::EventId.eq(ctx.event_id)) + .filter(awd_team_networks::Column::TeamId.eq(instance.team_id)) + .one(db) + .await + .map_err(|e| AwdError::Database(e.to_string()))? + .ok_or_else(|| AwdError::NotFound("team network not found".into()))?; + let password = + gamebox_service::decrypt_team_ssh_password(&crypto, ctx.event_id, &team_net).await?; - let recreate_spec = fcmc::GameBoxSpec { - event_id: ctx.event_id, - team_id: instance.team_id, - template_id: instance.template_id, - instance_id: instance.id, - container_name: instance.container_name.clone(), - image_ref: template.image_ref.clone(), - network_name, - fixed_ip: instance.gamebox_ip.clone(), - username: template.username.clone(), + // logical identity 不变(id / event_gamebox_id / team_id / IP / credential), + // runtime_generation + 1(§20/§24) + let next_generation = instance.runtime_generation + 1; + let recreate_spec = gamebox_service::build_gamebox_runtime_spec( + &resolved, + &awd_event, + instance.id, + instance.event_gamebox_id, + instance.team_id, + &instance.container_name, + &instance.gamebox_ip, + &network_name, password, - cpu_millis: template.cpu_millis, - memory_bytes: template.memory_bytes, - pids_limit: template.pids_limit, - healthcheck: None, - extra_hosts: vec![ - format!("flagserver:{}", awd_event.flagserver_ip), - format!("judgeserver:{}", awd_event.judgeserver_ip), - ], - labels: std::collections::HashMap::new(), - }; + next_generation, + )?; match containers .reset_gamebox(fcmc::GameBoxResetSpec { event_id: ctx.event_id, team_id: instance.team_id, - template_id: instance.template_id, + event_gamebox_id: instance.event_gamebox_id, instance_id: instance.id, container_name: instance.container_name.clone(), recreate_spec, @@ -230,7 +212,8 @@ pub async fn execute_reset( Ok(handle) => { let mut inst: awd_gamebox_instances::ActiveModel = awd_gamebox_instances::ActiveModel { id: Set(ctx.instance_id), - container_id: Set(Some(handle.container_id)), + current_container_id: Set(Some(handle.container_id)), + runtime_generation: Set(next_generation), status: Set(GameboxStatus::Ready), ..Default::default() }; diff --git a/apps/api/src/modules/event/awd_team/service/round_service.rs b/apps/api/src/modules/event/awd_team/service/round_service.rs index 16bd3d28..38891b61 100644 --- a/apps/api/src/modules/event/awd_team/service/round_service.rs +++ b/apps/api/src/modules/event/awd_team/service/round_service.rs @@ -19,7 +19,7 @@ use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, - QueryOrder, TransactionTrait, + TransactionTrait, }; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -34,7 +34,6 @@ use crate::entity::{ use crate::infrastructure::realtime::EventPublisher; use crate::modules::event::awd_team::{ AwdError, AwdResult, - domain::AwdEventStatusExt, infrastructure::{firewall::FirewallRuntime, network::AwdNetworkRuntime}, repo::{event_repo, judge_repo, round_repo, score_repo}, service::{firewall_service, judge_service}, @@ -583,25 +582,27 @@ pub async fn score_judge_timeouts( .await .map_err(|e| AwdError::Database(e.to_string()))?; - // 收集涉及到的 template 分值 - use crate::entity::awd_gamebox_templates; - let mut template_points: std::collections::HashMap = - std::collections::HashMap::new(); + // 收集涉及到的 EventGameBox 分值(§23:计分作用域 = EventGameBox) + let mut eg_points: std::collections::HashMap = std::collections::HashMap::new(); for t in &timed_out { - if !template_points.contains_key(&t.template_id) { - if let Some(tpl) = awd_gamebox_templates::Entity::find_by_id(t.template_id) - .one(db) - .await - .map_err(|e| AwdError::Database(e.to_string()))? - { - template_points.insert(t.template_id, tpl.down_points); + if let Some(eg_id) = t.event_gamebox_id { + if !eg_points.contains_key(&eg_id) { + if let Ok(Some(eg)) = + crate::modules::event::awd_team::repo::event_gamebox_repo::find_event_gamebox_by_id(db, eg_id).await + { + eg_points.insert(eg_id, eg.down_points); + } } } } let mut scored = 0u64; for t in &timed_out { - let delta = -template_points.get(&t.template_id).copied().unwrap_or(0); + let eg_id = t.event_gamebox_id; + let delta = -eg_points + .get(&eg_id.unwrap_or_default()) + .copied() + .unwrap_or(0); let key = format!("judge-timeout:{}", t.id); match score_repo::create_score_event( db, @@ -613,7 +614,7 @@ pub async fn score_judge_timeouts( &key, None, Some(t.gamebox_instance_id), - Some(t.template_id), + eg_id, Some("judge deadline timeout"), ) .await diff --git a/apps/api/src/modules/event/awd_team/service/submission_service.rs b/apps/api/src/modules/event/awd_team/service/submission_service.rs index c62e495e..9183b8b8 100644 --- a/apps/api/src/modules/event/awd_team/service/submission_service.rs +++ b/apps/api/src/modules/event/awd_team/service/submission_service.rs @@ -3,8 +3,7 @@ //! All operations run in a single database transaction to ensure atomicity. //! If any step fails, the entire submission is rolled back. -use sea_orm::{ConnectionTrait, DatabaseConnection, TransactionTrait}; -use tracing::info; +use sea_orm::{DatabaseConnection, TransactionTrait}; use uuid::Uuid; use crate::modules::event::awd_team::{ @@ -42,7 +41,7 @@ pub async fn process_submission( break_points: i64, loss_points: i64, first_bonus: i64, - template_id: Uuid, + event_gamebox_id: Uuid, publisher: &dyn crate::infrastructure::realtime::EventPublisher, ) -> AwdResult { let result = db @@ -147,8 +146,10 @@ pub async fn process_submission( // 事务置为 aborted(PostgreSQL 语义),随后 COMMIT 静默变 ROLLBACK—— // 败者的攻击分 + 受害者损失会被一起丢掉。DO NOTHING 则把「bonus 已被 // 别人抢先」当作正常结果,不影响同一事务里已成功的 attack/loss 写入。 - let bonus_key = - IdempotencyKey::first_bonus(&event_id.to_string(), &template_id.to_string()); + let bonus_key = IdempotencyKey::first_bonus( + &event_id.to_string(), + &event_gamebox_id.to_string(), + ); let was_first_blood = score_repo::create_score_event_if_absent( tx, @@ -160,7 +161,7 @@ pub async fn process_submission( &bonus_key, None, None, - Some(template_id), + Some(event_gamebox_id), Some("first blood"), ) .await diff --git a/apps/api/src/modules/event/jeopardy/api/dto.rs b/apps/api/src/modules/event/jeopardy/api/dto.rs index 57da6969..9cb04ad9 100644 --- a/apps/api/src/modules/event/jeopardy/api/dto.rs +++ b/apps/api/src/modules/event/jeopardy/api/dto.rs @@ -15,7 +15,6 @@ pub struct InstancesDto { pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, pub destroy_at: DateTimeWithTimeZone, - pub gamebox_id: Option, } impl From for InstancesDto { @@ -32,7 +31,6 @@ impl From for InstancesDto { created_at: m.created_at, updated_at: m.updated_at, destroy_at: m.destroy_at, - gamebox_id: m.gamebox_id, } } } diff --git a/apps/api/src/modules/event/jeopardy/api/instances.rs b/apps/api/src/modules/event/jeopardy/api/instances.rs index 1fb5cfbc..7d299179 100644 --- a/apps/api/src/modules/event/jeopardy/api/instances.rs +++ b/apps/api/src/modules/event/jeopardy/api/instances.rs @@ -52,13 +52,6 @@ pub async fn get_instances( ) }), }, - FilterMapping { - key: "gamebox_id", - column: Box::new(|v| { - Condition::all() - .add(instances::Column::GameboxId.eq(Uuid::from_str(&v).unwrap_or(Uuid::nil()))) - }), - }, ]; let stmt = instances::Entity::find() diff --git a/apps/api/src/modules/platform/operations/runtime_instances.rs b/apps/api/src/modules/platform/operations/runtime_instances.rs index e9ee2c80..e303e386 100644 --- a/apps/api/src/modules/platform/operations/runtime_instances.rs +++ b/apps/api/src/modules/platform/operations/runtime_instances.rs @@ -53,13 +53,6 @@ pub async fn get_instances( ) }), }, - FilterMapping { - key: "gamebox_id", - column: Box::new(|v| { - Condition::all() - .add(instances::Column::GameboxId.eq(Uuid::from_str(&v).unwrap_or(Uuid::nil()))) - }), - }, FilterMapping { key: "user_id", column: Box::new(|v| { diff --git a/apps/api/src/sql/merged.sql b/apps/api/src/sql/merged.sql index ff5a9294..03f4b082 100644 --- a/apps/api/src/sql/merged.sql +++ b/apps/api/src/sql/merged.sql @@ -1,8 +1,8 @@ -- ================================================================================================ -- ================================================================================================ -- FloatCTF Merged Migrations --- Generated at: 2026-08-08 12:49:14 +0800 --- Migration count: 18 +-- Generated at: 2026-08-08 19:02:41 +0800 +-- Migration count: 22 -- ================================================================================================ -- ================================================================================================ @@ -1928,6 +1928,627 @@ COMMIT; -- ================================================================================================ +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808183835-awd-gamebox-domain-a.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808183835-awd-gamebox-domain-a +-- ================================================================================ +-- GameBox 领域模型重构 — Migration A:新增领域表与实例新列。 +-- +-- 目标模型(四层): +-- gameboxes(长期身份) +-- └─ gamebox_revisions(不可变部署版本) +-- └─ awd_event_gameboxes(赛事选择 + 计分/资源/可见性配置) +-- └─ awd_gamebox_instances(每队稳定逻辑靶机) +-- └─ Docker Container(可替换运行时,仅 runtime) +-- +-- 本迁移只做「新增」,不做数据回填(Migration B)与删除(Migration D)。 +-- 回填依赖本迁移创建的表/列,故严格按 A → B → C → D 顺序执行。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. gamebox_revisions:GameBox 的不可变部署版本 +-- ────────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "gamebox_revisions" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + "gamebox_id" UUID NOT NULL + REFERENCES "gameboxes" ("id") ON DELETE CASCADE, + + "revision_number" INTEGER NOT NULL, + + -- 源配置(用户/题目作者的 TOML),与正规化后的 spec_json 分开保存 + "source_toml" TEXT NOT NULL, + + "spec_schema_version" INTEGER NOT NULL DEFAULT 1, + -- FloatCTF 正规化后的实际配置(canonical JSON) + "spec_json" JSONB NOT NULL, + -- canonical spec_json 的 SHA-256(可稳定比较的 revision fingerprint) + "spec_digest" VARCHAR(64) NOT NULL, + + -- 镜像与 Digest Pinning(§8):digest 可为 NULL(legacy), + -- Deploy/Precheck 必须在进入生产比赛前 resolve/pin。 + "image_ref" VARCHAR(500) NOT NULL, + "image_digest" VARCHAR(200), + + "username" VARCHAR(100) NOT NULL, + + "default_cpu_millis" BIGINT NOT NULL, + "default_memory_bytes" BIGINT NOT NULL, + "default_pids_limit" BIGINT NOT NULL DEFAULT 100, + + "healthcheck_json" JSONB, + + -- Judge 配置属于 Revision(§9:如何判定 GameBox 是否正常是题目定义的一部分) + "judge_script_name" VARCHAR(200), + "judge_script_content" TEXT, + "judge_args_json" JSONB, + "default_judge_timeout_secs" INTEGER, + "default_judge_retry_interval_secs" INTEGER, + + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- 约束(§5 / §11):revision 不可变、每 GameBox 版本递增; + -- UNIQUE(id, gamebox_id) 供 awd_event_gameboxes 复合 FK 使用。 + CONSTRAINT "gamebox_revisions_gamebox_number_key" UNIQUE ("gamebox_id", "revision_number"), + CONSTRAINT "gamebox_revisions_gamebox_digest_key" UNIQUE ("gamebox_id", "spec_digest"), + CONSTRAINT "gamebox_revisions_id_gamebox_key" UNIQUE ("id", "gamebox_id") +); + +COMMENT ON TABLE "gamebox_revisions" IS 'GameBox 不可变部署版本:每编辑一次 GameBox 生成新 revision(revision_number +1),旧 revision 永不修改(审计/赛事 pin 保证)'; +COMMENT ON COLUMN "gamebox_revisions"."source_toml" IS '源配置 TOML(用户/题目作者输入)'; +COMMENT ON COLUMN "gamebox_revisions"."spec_json" IS 'FloatCTF 正规化后的实际配置(canonical JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."spec_digest" IS 'canonical spec_json 的 SHA-256,用于稳定比较 revision 是否变化'; +COMMENT ON COLUMN "gamebox_revisions"."image_digest" IS '镜像 digest(sha256:...),可为 NULL(legacy);生产前必须 pin'; +COMMENT ON COLUMN "gamebox_revisions"."default_cpu_millis" IS '默认 CPU 限制(毫核)'; +COMMENT ON COLUMN "gamebox_revisions"."default_memory_bytes" IS '默认内存限制(字节)'; +COMMENT ON COLUMN "gamebox_revisions"."default_pids_limit" IS '默认进程数限制'; +COMMENT ON COLUMN "gamebox_revisions"."healthcheck_json" IS '默认健康检查配置(JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."judge_script_name" IS '判题脚本文件名'; +COMMENT ON COLUMN "gamebox_revisions"."judge_script_content" IS '判题脚本内容'; +COMMENT ON COLUMN "gamebox_revisions"."judge_args_json" IS '判题脚本参数(JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."default_judge_timeout_secs" IS '默认判题超时(秒,赛事可覆盖)'; +COMMENT ON COLUMN "gamebox_revisions"."default_judge_retry_interval_secs" IS '默认判题重试间隔(秒,赛事可覆盖)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_event_gameboxes:某场 AWD 赛事选择的 GameBox Revision + 赛事计分配置 +-- ────────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "awd_event_gameboxes" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + "event_id" UUID NOT NULL + REFERENCES "events" ("id") ON DELETE CASCADE, + + "gamebox_id" UUID NOT NULL + REFERENCES "gameboxes" ("id") ON DELETE RESTRICT, + + -- pin 的具体 revision(§35:保存后必须 pin 为具体 UUID,禁止存 latest) + "gamebox_revision_id" UUID NOT NULL, + + -- 确定性 IP 分配的核心:instance_ip = team.gamebox_subnet + host_offset(§13/§38) + "host_offset" SMALLINT NOT NULL, + + "enabled" BOOLEAN NOT NULL DEFAULT TRUE, + "hidden" BOOLEAN NOT NULL DEFAULT FALSE, + + -- 赛事资源覆盖(Revision 默认值之上的覆盖层,§49 effective config) + "cpu_millis" BIGINT NOT NULL, + "memory_bytes" BIGINT NOT NULL, + "pids_limit" BIGINT NOT NULL DEFAULT 100, + "healthcheck_override_json" JSONB, + + -- 赛事运行策略覆盖(§9:timeout/retry 更像比赛策略,属 Event 而非题目) + "judge_timeout_secs" INTEGER, + "judge_retry_interval_secs" INTEGER, + + -- 计分属于 Event × GameBox(§4),全部 BIGINT(§52),首破拼写修正为 first_bonus + "break_points" BIGINT NOT NULL, + "loss_points" BIGINT NOT NULL, + "fix_points" BIGINT NOT NULL, + "down_points" BIGINT NOT NULL, + "first_bonus" BIGINT NOT NULL, + + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- §11:gamebox_id + revision_id 一致性由复合 FK 在 DB 层强制 + CONSTRAINT "awd_event_gameboxes_revision_fk" + FOREIGN KEY ("gamebox_revision_id", "gamebox_id") + REFERENCES "gamebox_revisions" ("id", "gamebox_id") + ON DELETE RESTRICT, + + -- §12:Event 内一个 GameBox 只能有一个选择 + CONSTRAINT "awd_event_gameboxes_event_gamebox_key" UNIQUE ("event_id", "gamebox_id"), + + -- §13:同赛事 host_offset 唯一 + CONSTRAINT "awd_event_gameboxes_event_offset_key" UNIQUE ("event_id", "host_offset"), + + -- §13:不占用 network/broadcast/gateway/infra 保留位(.1 = 网关,.255 = 广播) + CONSTRAINT "awd_event_gameboxes_host_offset_check" CHECK ("host_offset" BETWEEN 2 AND 254) +); + +COMMENT ON TABLE "awd_event_gameboxes" IS 'AWD 赛事 GameBox 选择:赛事采用的 GameBox Revision + 该场自己的计分/资源/可见性配置'; +COMMENT ON COLUMN "awd_event_gameboxes"."gamebox_id" IS 'GameBox 长期身份(RESTRICT:被赛事引用后禁止 hard delete)'; +COMMENT ON COLUMN "awd_event_gameboxes"."gamebox_revision_id" IS '赛事 pin 的不可变 Revision(保存后为具体 UUID,不存 latest)'; +COMMENT ON COLUMN "awd_event_gameboxes"."host_offset" IS '确定性 IP 分配偏移:instance_ip = team.gamebox_subnet + host_offset(2..254,禁改部署后的偏移)'; +COMMENT ON COLUMN "awd_event_gameboxes"."enabled" IS '是否启用(停用后不再部署/判题)'; +COMMENT ON COLUMN "awd_event_gameboxes"."hidden" IS '对玩家是否隐藏'; +COMMENT ON COLUMN "awd_event_gameboxes"."cpu_millis" IS '赛事 CPU 限制(毫核)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."memory_bytes" IS '赛事内存限制(字节)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."pids_limit" IS '赛事进程数限制覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."healthcheck_override_json" IS '健康检查覆盖(JSON)'; +COMMENT ON COLUMN "awd_event_gameboxes"."judge_timeout_secs" IS '判题超时(秒)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."judge_retry_interval_secs" IS '判题重试间隔(秒)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."break_points" IS '被攻破时攻击方得分'; +COMMENT ON COLUMN "awd_event_gameboxes"."loss_points" IS '被攻破时防守方失分'; +COMMENT ON COLUMN "awd_event_gameboxes"."fix_points" IS '修复得分'; +COMMENT ON COLUMN "awd_event_gameboxes"."down_points" IS '宕机扣分'; +COMMENT ON COLUMN "awd_event_gameboxes"."first_bonus" IS '首破奖励'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. awd_gamebox_instances:新增领域列(回填在 Migration B) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID, + ADD COLUMN IF NOT EXISTS "runtime_generation" BIGINT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS "current_container_id" VARCHAR(64); + +COMMENT ON COLUMN "awd_gamebox_instances"."event_gamebox_id" IS '所属赛事 GameBox 选择(逻辑靶机定义;Migration B 回填后加 NOT NULL + FK)'; +COMMENT ON COLUMN "awd_gamebox_instances"."runtime_generation" IS '运行时代数:首次部署=1,Reset 成功替换容器 +1(容器只是当前 runtime realization)'; +COMMENT ON COLUMN "awd_gamebox_instances"."current_container_id" IS '当前 Docker 容器 ID(可替换的运行时资源;对应旧 container_id,改名强调非逻辑身份)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. awd_judge_tasks / awd_score_events:新增 event_gamebox_id 列(回填在 B) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID; + +ALTER TABLE "awd_score_events" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID; + +COMMENT ON COLUMN "awd_judge_tasks"."event_gamebox_id" IS '判题目标(EventGameBox 维度;Judge 配置从 EventGameBox → Revision 解析)'; +COMMENT ON COLUMN "awd_score_events"."event_gamebox_id" IS '计分作用域(EventGameBox 维度;first-blood 等按 EventGameBox 独立)'; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808183835-awd-gamebox-domain-a.sql +-- ================================================================================================ +-- ================================================================================================ + + +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808183914-awd-gamebox-domain-b.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808183914-awd-gamebox-domain-b +-- ================================================================================ +-- GameBox 领域模型重构 — Migration B:数据回填。 +-- +-- 安全原则(§40-43): +-- - 每条 legacy awd_gamebox_templates → 自己的 GameBox identity + Revision 1 + EventGameBox +-- (禁止按 name/safe_name 猜测合并,宁可保守产生重复 GameBox) +-- - host_offset 优先从已存在 instance IP 反推(host 字节), +-- 同 template 在不同 Team 偏移不一致 → RAISE(MIGRATION CONFLICT,禁止静默选择) +-- - 未部署的 template → 按稳定排序分配可用 offset(2..254,跳过已占用) +-- - 每个旧 gameboxes 全局行 → GameBox + Revision 1(source_toml 保存,spec 最小正规化) +-- - legacy image 无 digest → image_digest = NULL(§8.1),生产前必须 resolve/pin +-- +-- 本迁移在空库上零操作,但保持对任何环境的正确性与可验证性。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. 回填 legacy awd_gamebox_templates → GameBox + Revision 1 + EventGameBox +-- ────────────────────────────────────────────────────────────────────────────── + +-- 临时映射表:legacy template_id → 新 gamebox_id / event_gamebox_id(后续回填 judge/score 用) +CREATE TEMP TABLE IF NOT EXISTS _gb_mapping ( + legacy_template_id UUID PRIMARY KEY, + gamebox_id UUID NOT NULL, + event_gamebox_id UUID NOT NULL +) ON COMMIT DROP; + +DO $$ +DECLARE + t RECORD; + gb_id UUID; + rev_id UUID; + eg_id UUID; + safe_base TEXT; + safe_name_val TEXT; + offset_candidates INT[]; + first_offset INT; + used_offsets INT[]; + taken BOOLEAN; + cand INT; + spec_json JSONB; + digest TEXT; + legacy_count INT; +BEGIN + -- 逐条处理 legacy template(稳定排序:created_at → id,保证确定性) + FOR t IN + SELECT * FROM awd_gamebox_templates + ORDER BY created_at ASC, id ASC + LOOP + -- a. 创建 GameBox identity(不与其他 gamebox 同名;安全名从 name 生成 + 冲突去重) + safe_base := lower(regexp_replace(t.name, '[^a-zA-Z0-9_-]+', '-', 'g')); + safe_base := regexp_replace(btrim(safe_base, '-'), '-+', '-', 'g'); + IF safe_base = '' THEN safe_base := 'gamebox'; END IF; + safe_name_val := safe_base; + WHILE EXISTS (SELECT 1 FROM gameboxes WHERE safe_name = safe_name_val) LOOP + safe_name_val := safe_base || '-' || substr(md5(t.id::text), 1, 6); + END LOOP; + + gb_id := gen_random_uuid(); + INSERT INTO gameboxes (id, name, safe_name, category, description, hidden, created_at, updated_at) + VALUES (gb_id, t.name, safe_name_val, 'other', '从 legacy AWD template 迁移(GameBox 领域重构 Migration B)', TRUE, now(), now()); + + -- b. 创建 Revision 1:spec_json = 正规化配置(image/username/资源/judge),digest = sha256(canonical) + spec_json := jsonb_build_object( + 'image_ref', t.image_ref, + 'username', t.username, + 'cpu_millis', t.cpu_millis, + 'memory_bytes', t.memory_bytes, + 'pids_limit', t.pids_limit, + 'meta_json', COALESCE(t.meta_json, '{}'::jsonb), + 'healthcheck', t.healthcheck_override_json, + 'judge_script_name', t.judge_script_name, + 'judge_script_content', t.judge_script_content, + 'judge_args', t.judge_args_json + ); + digest := encode(sha256(spec_json::text::bytea), 'hex'); + rev_id := gen_random_uuid(); + INSERT INTO gamebox_revisions ( + id, gamebox_id, revision_number, source_toml, spec_schema_version, spec_json, spec_digest, + image_ref, image_digest, username, default_cpu_millis, default_memory_bytes, default_pids_limit, + healthcheck_json, judge_script_name, judge_script_content, judge_args_json, + default_judge_timeout_secs, default_judge_retry_interval_secs, created_at + ) VALUES ( + rev_id, gb_id, 1, '', 1, spec_json, digest, + t.image_ref, NULL, t.username, t.cpu_millis, t.memory_bytes, t.pids_limit, + t.healthcheck_override_json, t.judge_script_name, t.judge_script_content, t.judge_args_json, + t.judge_timeout_secs, t.judge_retry_interval_secs, now() + ); + + -- c. host_offset:优先从已存在 instance IP 反推(host 字节) + offset_candidates := ARRAY( + SELECT DISTINCT (host(split_part(inst.gamebox_ip, '/', 1)::inet))::int + FROM awd_gamebox_instances inst + WHERE inst.template_id = t.id + ); + IF cardinality(offset_candidates) > 1 THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: legacy template % has inconsistent host offsets across teams: %(禁止静默选择,需人工处理)', t.id, offset_candidates; + ELSIF cardinality(offset_candidates) = 1 THEN + first_offset := offset_candidates[1]; + IF first_offset < 2 OR first_offset > 254 THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: legacy template % host offset % out of range 2..254', t.id, first_offset; + END IF; + ELSE + -- 未部署 → 分配该事件下一个可用 offset(2..254,跳过已占用) + used_offsets := ARRAY( + SELECT eg.host_offset FROM awd_event_gameboxes eg + WHERE eg.event_id = t.event_id + ); + first_offset := NULL; + FOR cand IN 2..254 LOOP + taken := cand = ANY(used_offsets); + IF NOT taken THEN + first_offset := cand; + EXIT; + END IF; + END LOOP; + IF first_offset IS NULL THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: no free host_offset (2..254) for legacy template %', t.id; + END IF; + END IF; + + -- d. 创建 EventGameBox(计分配置从 legacy template 迁移) + eg_id := gen_random_uuid(); + INSERT INTO awd_event_gameboxes ( + id, event_id, gamebox_id, gamebox_revision_id, host_offset, + enabled, hidden, cpu_millis, memory_bytes, pids_limit, healthcheck_override_json, + judge_timeout_secs, judge_retry_interval_secs, + break_points, loss_points, fix_points, down_points, first_bonus, + created_at, updated_at + ) VALUES ( + eg_id, t.event_id, gb_id, rev_id, first_offset, + TRUE, FALSE, t.cpu_millis, t.memory_bytes, t.pids_limit, t.healthcheck_override_json, + t.judge_timeout_secs, t.judge_retry_interval_secs, + t.break_points, t.loss_points, t.fix_points, t.down_points, t.first_bonus, + now(), now() + ); + + -- e. 关联 legacy instance → 新 EventGameBox + UPDATE awd_gamebox_instances + SET event_gamebox_id = eg_id + WHERE template_id = t.id; + + -- f. 记录映射(judge/score 回填用) + INSERT INTO _gb_mapping (legacy_template_id, gamebox_id, event_gamebox_id) + VALUES (t.id, gb_id, eg_id); + END LOOP; + + SELECT count(*) INTO legacy_count FROM awd_gamebox_templates; + RAISE NOTICE 'GameBox backfill: % legacy templates migrated (empty in dev)', legacy_count; +END $$; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. 回填 legacy gameboxes(全局库)→ Revision 1 +-- (§41:source_toml 保存原始 TOML;旧计分字段不进入 Revision,不凭空制造赛事计分配置) +-- ────────────────────────────────────────────────────────────────────────────── +DO $$ +DECLARE + g RECORD; + spec_json JSONB; + digest TEXT; +BEGIN + FOR g IN SELECT * FROM gameboxes ORDER BY created_at ASC, id ASC LOOP + -- 已有 revision 的行跳过(幂等) + IF EXISTS (SELECT 1 FROM gamebox_revisions WHERE gamebox_id = g.id) THEN + CONTINUE; + END IF; + spec_json := jsonb_build_object( + 'legacy', TRUE, + 'source_toml', COALESCE(g.toml_str, ''), + 'username', g.username + ); + digest := encode(sha256(spec_json::text::bytea), 'hex'); + INSERT INTO gamebox_revisions ( + id, gamebox_id, revision_number, source_toml, spec_schema_version, spec_json, spec_digest, + image_ref, image_digest, username, default_cpu_millis, default_memory_bytes, default_pids_limit, + healthcheck_json, judge_script_name, judge_script_content, judge_args_json, + default_judge_timeout_secs, default_judge_retry_interval_secs, created_at + ) VALUES ( + gen_random_uuid(), g.id, 1, COALESCE(g.toml_str, ''), 1, spec_json, digest, + '', NULL, COALESCE(g.username, 'ctf'), 1000, 536870912, 100, + NULL, NULL, NULL, NULL, NULL, NULL, now() + ); + END LOOP; +END $$; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. 回填 awd_judge_tasks.event_gamebox_id(经 instance 关联) +-- ────────────────────────────────────────────────────────────────────────────── +UPDATE awd_judge_tasks jt +SET event_gamebox_id = inst.event_gamebox_id +FROM awd_gamebox_instances inst +WHERE jt.gamebox_instance_id = inst.id + AND jt.event_gamebox_id IS NULL; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. 回填 awd_score_events.event_gamebox_id +-- instance 维度:经 awd_gamebox_instances 关联; +-- template 维度(如 first-bonus,无 instance):经 legacy 映射表关联。 +-- ────────────────────────────────────────────────────────────────────────────── +UPDATE awd_score_events se +SET event_gamebox_id = inst.event_gamebox_id +FROM awd_gamebox_instances inst +WHERE se.gamebox_instance_id = inst.id + AND se.event_gamebox_id IS NULL; + +UPDATE awd_score_events se +SET event_gamebox_id = m.event_gamebox_id +FROM _gb_mapping m +WHERE se.gamebox_template_id = m.legacy_template_id + AND se.event_gamebox_id IS NULL; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 5. 可验证性断言(§61)——任何一条不满足即回滚整个迁移 +-- ────────────────────────────────────────────────────────────────────────────── +DO $$ +DECLARE + template_cnt INT; eg_cnt INT; orphan_inst INT; orphan_link INT; dup_offset INT; dup_inst INT; +BEGIN + SELECT count(*) INTO template_cnt FROM awd_gamebox_templates; + SELECT count(*) INTO eg_cnt FROM awd_event_gameboxes; + IF template_cnt <> eg_cnt THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: legacy templates % <> event_gameboxes %', template_cnt, eg_cnt; + END IF; + + -- 每个 legacy instance 都有 event_gamebox_id + SELECT count(*) INTO orphan_inst FROM awd_gamebox_instances WHERE event_gamebox_id IS NULL; + IF orphan_inst > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: % instances lack event_gamebox_id', orphan_inst; + END IF; + + -- 每个 EventGameBox 都有 GameBoxRevision(FK 已保证)且无孤儿 revision link(复合 FK 已保证) + + -- 同 Event host_offset 无重复(UNIQUE 已保证,双保险) + SELECT count(*) INTO dup_offset FROM ( + SELECT event_id, host_offset FROM awd_event_gameboxes GROUP BY 1,2 HAVING count(*) > 1 + ) d; + IF dup_offset > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: duplicate host_offset'; + END IF; + + -- 同 Event + Team + EventGameBox 只有一个 logical Instance + SELECT count(*) INTO dup_inst FROM ( + SELECT event_id, team_id, event_gamebox_id FROM awd_gamebox_instances + GROUP BY 1,2,3 HAVING count(*) > 1 + ) d; + IF dup_inst > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: duplicate logical instances'; + END IF; +END $$; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808183914-awd-gamebox-domain-b.sql +-- ================================================================================================ +-- ================================================================================================ + + +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808183946-awd-gamebox-domain-c.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808183946-awd-gamebox-domain-c +-- ================================================================================ +-- GameBox 领域模型重构 — Migration C:回填后的约束收紧。 +-- +-- 仅在 Migration B 回填完成后执行(依赖所有旧行已获得 event_gamebox_id)。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. awd_gamebox_instances:event_gamebox_id 强制 + FK + 新 UNIQUE +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + ALTER COLUMN "event_gamebox_id" SET NOT NULL; + +ALTER TABLE "awd_gamebox_instances" + ADD CONSTRAINT "awd_gamebox_instances_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE RESTRICT; + +-- 旧 UNIQUE(event_id, template_id, team_id) 被新 UNIQUE(event_id, event_gamebox_id, team_id) 取代 +ALTER TABLE "awd_gamebox_instances" + DROP CONSTRAINT IF EXISTS "awd_gamebox_instances_event_id_template_id_team_id_key"; + +ALTER TABLE "awd_gamebox_instances" + ADD CONSTRAINT "awd_gamebox_instances_event_gamebox_team_key" + UNIQUE ("event_id", "event_gamebox_id", "team_id"); + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_judge_tasks / awd_score_events:FK(SET NULL 保历史,§57) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + ADD CONSTRAINT "awd_judge_tasks_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE SET NULL; + +ALTER TABLE "awd_score_events" + ADD CONSTRAINT "awd_score_events_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE SET NULL; + +COMMENT ON COLUMN "awd_judge_tasks"."event_gamebox_id" IS '判题目标 EventGameBox(SET NULL:EventGameBox 删除后保留历史行)'; +COMMENT ON COLUMN "awd_score_events"."event_gamebox_id" IS '计分作用域 EventGameBox(SET NULL:EventGameBox 删除后保留历史行)'; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808183946-awd-gamebox-domain-c.sql +-- ================================================================================================ +-- ================================================================================================ + + +-- ================================================================================================ +-- ================================================================================================ +-- BEGIN MIGRATION: 20260808183947-awd-gamebox-domain-d.sql +-- ================================================================================================ +-- ================================================================================================ + +-- ================================================================================ +-- Migration: 20260808183947-awd-gamebox-domain-d +-- ================================================================================ +-- GameBox 领域模型重构 — Migration D:删除 legacy 双轨 schema。 +-- +-- 依赖 A/B/C 全部落库且业务代码已切换到新模型后执行(本文件随 A/B/C 一起 +-- 进入 merged.sql,但语义上属于最终清理:旧表/旧列/旧拼写不再存在于 schema)。 +-- +-- 删除清单(§59): +-- - awd_gamebox_templates(被 gameboxes + gamebox_revisions + awd_event_gameboxes 取代) +-- - event_gameboxes(旧 Jeopardy-GameBox 关联,无业务调用者) +-- - instances.gamebox_id(旧 GameBox runtime 关联,仅剩 read filter) +-- - gameboxes.toml_str / username / break_point / fix_point / down_point / first_bouns +-- - awd_team_networks.next_gamebox_host(host_offset 取代) +-- - awd_gamebox_instances.template_id / docker_network_id / container_id(→ current_container_id) +-- - awd_judge_tasks.template_id / awd_score_events.gamebox_template_id(→ event_gamebox_id) +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. awd_gamebox_instances:删旧列 + container_id 改名 + gamebox_ip 转 INET +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + DROP COLUMN IF EXISTS "template_id", + DROP COLUMN IF EXISTS "docker_network_id", + DROP COLUMN IF EXISTS "container_id"; + +-- 注:gamebox_ip 保持 VARCHAR(15),不做 INET 转换。 +-- §23 逃生口:SeaORM 1.1.20 将 INET 映射为 String,INSERT/比较时按 TEXT 绑定, +-- PostgreSQL 拒绝 text→inet(无隐式转换),实体生成产物无法携带类型覆盖。 +-- 因此 INET 迁移列为独立后续项(schema 类型层面改造),本次不改。 +COMMENT ON COLUMN "awd_gamebox_instances"."gamebox_ip" IS '靶机内网 IP(= team.gamebox_subnet + event_gamebox.host_offset,确定性分配)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_team_networks:删除 next_gamebox_host(§14,host_offset 取代) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_team_networks" + DROP COLUMN IF EXISTS "next_gamebox_host"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. gameboxes:删除 legacy runtime/计分列;name 不强制全局唯一(§53,展示名) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "gameboxes" + DROP COLUMN IF EXISTS "toml_str", + DROP COLUMN IF EXISTS "username", + DROP COLUMN IF EXISTS "break_point", + DROP COLUMN IF EXISTS "fix_point", + DROP COLUMN IF EXISTS "down_point", + DROP COLUMN IF EXISTS "first_bouns"; + +ALTER TABLE "gameboxes" + DROP CONSTRAINT IF EXISTS "gameboxes_name_key"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. awd_judge_tasks / awd_score_events:删旧 template 引用列 +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + DROP COLUMN IF EXISTS "template_id"; + +ALTER TABLE "awd_score_events" + DROP COLUMN IF EXISTS "gamebox_template_id"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 5. 删除 legacy 表 +-- ────────────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS "awd_gamebox_templates"; +DROP TABLE IF EXISTS "event_gameboxes"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 6. instances.gamebox_id(Jeopardy 通用实例不再支持 GameBox,§33) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "instances" + DROP COLUMN IF EXISTS "gamebox_id"; + +COMMIT; + + +-- ================================================================================================ +-- ================================================================================================ +-- END MIGRATION: 20260808183947-awd-gamebox-domain-d.sql +-- ================================================================================================ +-- ================================================================================================ + + -- ================================================================================================ -- ================================================================================================ -- End of FloatCTF Migrations diff --git a/apps/api/src/sql/migrations/20260808183835-awd-gamebox-domain-a.sql b/apps/api/src/sql/migrations/20260808183835-awd-gamebox-domain-a.sql new file mode 100644 index 00000000..b10e6819 --- /dev/null +++ b/apps/api/src/sql/migrations/20260808183835-awd-gamebox-domain-a.sql @@ -0,0 +1,182 @@ +-- ================================================================================ +-- Migration: 20260808183835-awd-gamebox-domain-a +-- ================================================================================ +-- GameBox 领域模型重构 — Migration A:新增领域表与实例新列。 +-- +-- 目标模型(四层): +-- gameboxes(长期身份) +-- └─ gamebox_revisions(不可变部署版本) +-- └─ awd_event_gameboxes(赛事选择 + 计分/资源/可见性配置) +-- └─ awd_gamebox_instances(每队稳定逻辑靶机) +-- └─ Docker Container(可替换运行时,仅 runtime) +-- +-- 本迁移只做「新增」,不做数据回填(Migration B)与删除(Migration D)。 +-- 回填依赖本迁移创建的表/列,故严格按 A → B → C → D 顺序执行。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. gamebox_revisions:GameBox 的不可变部署版本 +-- ────────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "gamebox_revisions" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + "gamebox_id" UUID NOT NULL + REFERENCES "gameboxes" ("id") ON DELETE CASCADE, + + "revision_number" INTEGER NOT NULL, + + -- 源配置(用户/题目作者的 TOML),与正规化后的 spec_json 分开保存 + "source_toml" TEXT NOT NULL, + + "spec_schema_version" INTEGER NOT NULL DEFAULT 1, + -- FloatCTF 正规化后的实际配置(canonical JSON) + "spec_json" JSONB NOT NULL, + -- canonical spec_json 的 SHA-256(可稳定比较的 revision fingerprint) + "spec_digest" VARCHAR(64) NOT NULL, + + -- 镜像与 Digest Pinning(§8):digest 可为 NULL(legacy), + -- Deploy/Precheck 必须在进入生产比赛前 resolve/pin。 + "image_ref" VARCHAR(500) NOT NULL, + "image_digest" VARCHAR(200), + + "username" VARCHAR(100) NOT NULL, + + "default_cpu_millis" BIGINT NOT NULL, + "default_memory_bytes" BIGINT NOT NULL, + "default_pids_limit" BIGINT NOT NULL DEFAULT 100, + + "healthcheck_json" JSONB, + + -- Judge 配置属于 Revision(§9:如何判定 GameBox 是否正常是题目定义的一部分) + "judge_script_name" VARCHAR(200), + "judge_script_content" TEXT, + "judge_args_json" JSONB, + "default_judge_timeout_secs" INTEGER, + "default_judge_retry_interval_secs" INTEGER, + + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- 约束(§5 / §11):revision 不可变、每 GameBox 版本递增; + -- UNIQUE(id, gamebox_id) 供 awd_event_gameboxes 复合 FK 使用。 + CONSTRAINT "gamebox_revisions_gamebox_number_key" UNIQUE ("gamebox_id", "revision_number"), + CONSTRAINT "gamebox_revisions_gamebox_digest_key" UNIQUE ("gamebox_id", "spec_digest"), + CONSTRAINT "gamebox_revisions_id_gamebox_key" UNIQUE ("id", "gamebox_id") +); + +COMMENT ON TABLE "gamebox_revisions" IS 'GameBox 不可变部署版本:每编辑一次 GameBox 生成新 revision(revision_number +1),旧 revision 永不修改(审计/赛事 pin 保证)'; +COMMENT ON COLUMN "gamebox_revisions"."source_toml" IS '源配置 TOML(用户/题目作者输入)'; +COMMENT ON COLUMN "gamebox_revisions"."spec_json" IS 'FloatCTF 正规化后的实际配置(canonical JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."spec_digest" IS 'canonical spec_json 的 SHA-256,用于稳定比较 revision 是否变化'; +COMMENT ON COLUMN "gamebox_revisions"."image_digest" IS '镜像 digest(sha256:...),可为 NULL(legacy);生产前必须 pin'; +COMMENT ON COLUMN "gamebox_revisions"."default_cpu_millis" IS '默认 CPU 限制(毫核)'; +COMMENT ON COLUMN "gamebox_revisions"."default_memory_bytes" IS '默认内存限制(字节)'; +COMMENT ON COLUMN "gamebox_revisions"."default_pids_limit" IS '默认进程数限制'; +COMMENT ON COLUMN "gamebox_revisions"."healthcheck_json" IS '默认健康检查配置(JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."judge_script_name" IS '判题脚本文件名'; +COMMENT ON COLUMN "gamebox_revisions"."judge_script_content" IS '判题脚本内容'; +COMMENT ON COLUMN "gamebox_revisions"."judge_args_json" IS '判题脚本参数(JSON)'; +COMMENT ON COLUMN "gamebox_revisions"."default_judge_timeout_secs" IS '默认判题超时(秒,赛事可覆盖)'; +COMMENT ON COLUMN "gamebox_revisions"."default_judge_retry_interval_secs" IS '默认判题重试间隔(秒,赛事可覆盖)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_event_gameboxes:某场 AWD 赛事选择的 GameBox Revision + 赛事计分配置 +-- ────────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "awd_event_gameboxes" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + "event_id" UUID NOT NULL + REFERENCES "events" ("id") ON DELETE CASCADE, + + "gamebox_id" UUID NOT NULL + REFERENCES "gameboxes" ("id") ON DELETE RESTRICT, + + -- pin 的具体 revision(§35:保存后必须 pin 为具体 UUID,禁止存 latest) + "gamebox_revision_id" UUID NOT NULL, + + -- 确定性 IP 分配的核心:instance_ip = team.gamebox_subnet + host_offset(§13/§38) + "host_offset" SMALLINT NOT NULL, + + "enabled" BOOLEAN NOT NULL DEFAULT TRUE, + "hidden" BOOLEAN NOT NULL DEFAULT FALSE, + + -- 赛事资源覆盖(Revision 默认值之上的覆盖层,§49 effective config) + "cpu_millis" BIGINT NOT NULL, + "memory_bytes" BIGINT NOT NULL, + "pids_limit" BIGINT NOT NULL DEFAULT 100, + "healthcheck_override_json" JSONB, + + -- 赛事运行策略覆盖(§9:timeout/retry 更像比赛策略,属 Event 而非题目) + "judge_timeout_secs" INTEGER, + "judge_retry_interval_secs" INTEGER, + + -- 计分属于 Event × GameBox(§4),全部 BIGINT(§52),首破拼写修正为 first_bonus + "break_points" BIGINT NOT NULL, + "loss_points" BIGINT NOT NULL, + "fix_points" BIGINT NOT NULL, + "down_points" BIGINT NOT NULL, + "first_bonus" BIGINT NOT NULL, + + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- §11:gamebox_id + revision_id 一致性由复合 FK 在 DB 层强制 + CONSTRAINT "awd_event_gameboxes_revision_fk" + FOREIGN KEY ("gamebox_revision_id", "gamebox_id") + REFERENCES "gamebox_revisions" ("id", "gamebox_id") + ON DELETE RESTRICT, + + -- §12:Event 内一个 GameBox 只能有一个选择 + CONSTRAINT "awd_event_gameboxes_event_gamebox_key" UNIQUE ("event_id", "gamebox_id"), + + -- §13:同赛事 host_offset 唯一 + CONSTRAINT "awd_event_gameboxes_event_offset_key" UNIQUE ("event_id", "host_offset"), + + -- §13:不占用 network/broadcast/gateway/infra 保留位(.1 = 网关,.255 = 广播) + CONSTRAINT "awd_event_gameboxes_host_offset_check" CHECK ("host_offset" BETWEEN 2 AND 254) +); + +COMMENT ON TABLE "awd_event_gameboxes" IS 'AWD 赛事 GameBox 选择:赛事采用的 GameBox Revision + 该场自己的计分/资源/可见性配置'; +COMMENT ON COLUMN "awd_event_gameboxes"."gamebox_id" IS 'GameBox 长期身份(RESTRICT:被赛事引用后禁止 hard delete)'; +COMMENT ON COLUMN "awd_event_gameboxes"."gamebox_revision_id" IS '赛事 pin 的不可变 Revision(保存后为具体 UUID,不存 latest)'; +COMMENT ON COLUMN "awd_event_gameboxes"."host_offset" IS '确定性 IP 分配偏移:instance_ip = team.gamebox_subnet + host_offset(2..254,禁改部署后的偏移)'; +COMMENT ON COLUMN "awd_event_gameboxes"."enabled" IS '是否启用(停用后不再部署/判题)'; +COMMENT ON COLUMN "awd_event_gameboxes"."hidden" IS '对玩家是否隐藏'; +COMMENT ON COLUMN "awd_event_gameboxes"."cpu_millis" IS '赛事 CPU 限制(毫核)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."memory_bytes" IS '赛事内存限制(字节)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."pids_limit" IS '赛事进程数限制覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."healthcheck_override_json" IS '健康检查覆盖(JSON)'; +COMMENT ON COLUMN "awd_event_gameboxes"."judge_timeout_secs" IS '判题超时(秒)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."judge_retry_interval_secs" IS '判题重试间隔(秒)覆盖'; +COMMENT ON COLUMN "awd_event_gameboxes"."break_points" IS '被攻破时攻击方得分'; +COMMENT ON COLUMN "awd_event_gameboxes"."loss_points" IS '被攻破时防守方失分'; +COMMENT ON COLUMN "awd_event_gameboxes"."fix_points" IS '修复得分'; +COMMENT ON COLUMN "awd_event_gameboxes"."down_points" IS '宕机扣分'; +COMMENT ON COLUMN "awd_event_gameboxes"."first_bonus" IS '首破奖励'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. awd_gamebox_instances:新增领域列(回填在 Migration B) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID, + ADD COLUMN IF NOT EXISTS "runtime_generation" BIGINT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS "current_container_id" VARCHAR(64); + +COMMENT ON COLUMN "awd_gamebox_instances"."event_gamebox_id" IS '所属赛事 GameBox 选择(逻辑靶机定义;Migration B 回填后加 NOT NULL + FK)'; +COMMENT ON COLUMN "awd_gamebox_instances"."runtime_generation" IS '运行时代数:首次部署=1,Reset 成功替换容器 +1(容器只是当前 runtime realization)'; +COMMENT ON COLUMN "awd_gamebox_instances"."current_container_id" IS '当前 Docker 容器 ID(可替换的运行时资源;对应旧 container_id,改名强调非逻辑身份)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. awd_judge_tasks / awd_score_events:新增 event_gamebox_id 列(回填在 B) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID; + +ALTER TABLE "awd_score_events" + ADD COLUMN IF NOT EXISTS "event_gamebox_id" UUID; + +COMMENT ON COLUMN "awd_judge_tasks"."event_gamebox_id" IS '判题目标(EventGameBox 维度;Judge 配置从 EventGameBox → Revision 解析)'; +COMMENT ON COLUMN "awd_score_events"."event_gamebox_id" IS '计分作用域(EventGameBox 维度;first-blood 等按 EventGameBox 独立)'; + +COMMIT; diff --git a/apps/api/src/sql/migrations/20260808183914-awd-gamebox-domain-b.sql b/apps/api/src/sql/migrations/20260808183914-awd-gamebox-domain-b.sql new file mode 100644 index 00000000..f1fc417b --- /dev/null +++ b/apps/api/src/sql/migrations/20260808183914-awd-gamebox-domain-b.sql @@ -0,0 +1,254 @@ +-- ================================================================================ +-- Migration: 20260808183914-awd-gamebox-domain-b +-- ================================================================================ +-- GameBox 领域模型重构 — Migration B:数据回填。 +-- +-- 安全原则(§40-43): +-- - 每条 legacy awd_gamebox_templates → 自己的 GameBox identity + Revision 1 + EventGameBox +-- (禁止按 name/safe_name 猜测合并,宁可保守产生重复 GameBox) +-- - host_offset 优先从已存在 instance IP 反推(host 字节), +-- 同 template 在不同 Team 偏移不一致 → RAISE(MIGRATION CONFLICT,禁止静默选择) +-- - 未部署的 template → 按稳定排序分配可用 offset(2..254,跳过已占用) +-- - 每个旧 gameboxes 全局行 → GameBox + Revision 1(source_toml 保存,spec 最小正规化) +-- - legacy image 无 digest → image_digest = NULL(§8.1),生产前必须 resolve/pin +-- +-- 本迁移在空库上零操作,但保持对任何环境的正确性与可验证性。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. 回填 legacy awd_gamebox_templates → GameBox + Revision 1 + EventGameBox +-- ────────────────────────────────────────────────────────────────────────────── + +-- 临时映射表:legacy template_id → 新 gamebox_id / event_gamebox_id(后续回填 judge/score 用) +CREATE TEMP TABLE IF NOT EXISTS _gb_mapping ( + legacy_template_id UUID PRIMARY KEY, + gamebox_id UUID NOT NULL, + event_gamebox_id UUID NOT NULL +) ON COMMIT DROP; + +DO $$ +DECLARE + t RECORD; + gb_id UUID; + rev_id UUID; + eg_id UUID; + safe_base TEXT; + safe_name_val TEXT; + offset_candidates INT[]; + first_offset INT; + used_offsets INT[]; + taken BOOLEAN; + cand INT; + spec_json JSONB; + digest TEXT; + legacy_count INT; +BEGIN + -- 逐条处理 legacy template(稳定排序:created_at → id,保证确定性) + FOR t IN + SELECT * FROM awd_gamebox_templates + ORDER BY created_at ASC, id ASC + LOOP + -- a. 创建 GameBox identity(不与其他 gamebox 同名;安全名从 name 生成 + 冲突去重) + safe_base := lower(regexp_replace(t.name, '[^a-zA-Z0-9_-]+', '-', 'g')); + safe_base := regexp_replace(btrim(safe_base, '-'), '-+', '-', 'g'); + IF safe_base = '' THEN safe_base := 'gamebox'; END IF; + safe_name_val := safe_base; + WHILE EXISTS (SELECT 1 FROM gameboxes WHERE safe_name = safe_name_val) LOOP + safe_name_val := safe_base || '-' || substr(md5(t.id::text), 1, 6); + END LOOP; + + gb_id := gen_random_uuid(); + INSERT INTO gameboxes (id, name, safe_name, category, description, hidden, created_at, updated_at) + VALUES (gb_id, t.name, safe_name_val, 'other', '从 legacy AWD template 迁移(GameBox 领域重构 Migration B)', TRUE, now(), now()); + + -- b. 创建 Revision 1:spec_json = 正规化配置(image/username/资源/judge),digest = sha256(canonical) + spec_json := jsonb_build_object( + 'image_ref', t.image_ref, + 'username', t.username, + 'cpu_millis', t.cpu_millis, + 'memory_bytes', t.memory_bytes, + 'pids_limit', t.pids_limit, + 'meta_json', COALESCE(t.meta_json, '{}'::jsonb), + 'healthcheck', t.healthcheck_override_json, + 'judge_script_name', t.judge_script_name, + 'judge_script_content', t.judge_script_content, + 'judge_args', t.judge_args_json + ); + digest := encode(sha256(spec_json::text::bytea), 'hex'); + rev_id := gen_random_uuid(); + INSERT INTO gamebox_revisions ( + id, gamebox_id, revision_number, source_toml, spec_schema_version, spec_json, spec_digest, + image_ref, image_digest, username, default_cpu_millis, default_memory_bytes, default_pids_limit, + healthcheck_json, judge_script_name, judge_script_content, judge_args_json, + default_judge_timeout_secs, default_judge_retry_interval_secs, created_at + ) VALUES ( + rev_id, gb_id, 1, '', 1, spec_json, digest, + t.image_ref, NULL, t.username, t.cpu_millis, t.memory_bytes, t.pids_limit, + t.healthcheck_override_json, t.judge_script_name, t.judge_script_content, t.judge_args_json, + t.judge_timeout_secs, t.judge_retry_interval_secs, now() + ); + + -- c. host_offset:优先从已存在 instance IP 反推(host 字节) + offset_candidates := ARRAY( + SELECT DISTINCT (host(split_part(inst.gamebox_ip, '/', 1)::inet))::int + FROM awd_gamebox_instances inst + WHERE inst.template_id = t.id + ); + IF cardinality(offset_candidates) > 1 THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: legacy template % has inconsistent host offsets across teams: %(禁止静默选择,需人工处理)', t.id, offset_candidates; + ELSIF cardinality(offset_candidates) = 1 THEN + first_offset := offset_candidates[1]; + IF first_offset < 2 OR first_offset > 254 THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: legacy template % host offset % out of range 2..254', t.id, first_offset; + END IF; + ELSE + -- 未部署 → 分配该事件下一个可用 offset(2..254,跳过已占用) + used_offsets := ARRAY( + SELECT eg.host_offset FROM awd_event_gameboxes eg + WHERE eg.event_id = t.event_id + ); + first_offset := NULL; + FOR cand IN 2..254 LOOP + taken := cand = ANY(used_offsets); + IF NOT taken THEN + first_offset := cand; + EXIT; + END IF; + END LOOP; + IF first_offset IS NULL THEN + RAISE EXCEPTION 'MIGRATION CONFLICT: no free host_offset (2..254) for legacy template %', t.id; + END IF; + END IF; + + -- d. 创建 EventGameBox(计分配置从 legacy template 迁移) + eg_id := gen_random_uuid(); + INSERT INTO awd_event_gameboxes ( + id, event_id, gamebox_id, gamebox_revision_id, host_offset, + enabled, hidden, cpu_millis, memory_bytes, pids_limit, healthcheck_override_json, + judge_timeout_secs, judge_retry_interval_secs, + break_points, loss_points, fix_points, down_points, first_bonus, + created_at, updated_at + ) VALUES ( + eg_id, t.event_id, gb_id, rev_id, first_offset, + TRUE, FALSE, t.cpu_millis, t.memory_bytes, t.pids_limit, t.healthcheck_override_json, + t.judge_timeout_secs, t.judge_retry_interval_secs, + t.break_points, t.loss_points, t.fix_points, t.down_points, t.first_bonus, + now(), now() + ); + + -- e. 关联 legacy instance → 新 EventGameBox + UPDATE awd_gamebox_instances + SET event_gamebox_id = eg_id + WHERE template_id = t.id; + + -- f. 记录映射(judge/score 回填用) + INSERT INTO _gb_mapping (legacy_template_id, gamebox_id, event_gamebox_id) + VALUES (t.id, gb_id, eg_id); + END LOOP; + + SELECT count(*) INTO legacy_count FROM awd_gamebox_templates; + RAISE NOTICE 'GameBox backfill: % legacy templates migrated (empty in dev)', legacy_count; +END $$; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. 回填 legacy gameboxes(全局库)→ Revision 1 +-- (§41:source_toml 保存原始 TOML;旧计分字段不进入 Revision,不凭空制造赛事计分配置) +-- ────────────────────────────────────────────────────────────────────────────── +DO $$ +DECLARE + g RECORD; + spec_json JSONB; + digest TEXT; +BEGIN + FOR g IN SELECT * FROM gameboxes ORDER BY created_at ASC, id ASC LOOP + -- 已有 revision 的行跳过(幂等) + IF EXISTS (SELECT 1 FROM gamebox_revisions WHERE gamebox_id = g.id) THEN + CONTINUE; + END IF; + spec_json := jsonb_build_object( + 'legacy', TRUE, + 'source_toml', COALESCE(g.toml_str, ''), + 'username', g.username + ); + digest := encode(sha256(spec_json::text::bytea), 'hex'); + INSERT INTO gamebox_revisions ( + id, gamebox_id, revision_number, source_toml, spec_schema_version, spec_json, spec_digest, + image_ref, image_digest, username, default_cpu_millis, default_memory_bytes, default_pids_limit, + healthcheck_json, judge_script_name, judge_script_content, judge_args_json, + default_judge_timeout_secs, default_judge_retry_interval_secs, created_at + ) VALUES ( + gen_random_uuid(), g.id, 1, COALESCE(g.toml_str, ''), 1, spec_json, digest, + '', NULL, COALESCE(g.username, 'ctf'), 1000, 536870912, 100, + NULL, NULL, NULL, NULL, NULL, NULL, now() + ); + END LOOP; +END $$; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. 回填 awd_judge_tasks.event_gamebox_id(经 instance 关联) +-- ────────────────────────────────────────────────────────────────────────────── +UPDATE awd_judge_tasks jt +SET event_gamebox_id = inst.event_gamebox_id +FROM awd_gamebox_instances inst +WHERE jt.gamebox_instance_id = inst.id + AND jt.event_gamebox_id IS NULL; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. 回填 awd_score_events.event_gamebox_id +-- instance 维度:经 awd_gamebox_instances 关联; +-- template 维度(如 first-bonus,无 instance):经 legacy 映射表关联。 +-- ────────────────────────────────────────────────────────────────────────────── +UPDATE awd_score_events se +SET event_gamebox_id = inst.event_gamebox_id +FROM awd_gamebox_instances inst +WHERE se.gamebox_instance_id = inst.id + AND se.event_gamebox_id IS NULL; + +UPDATE awd_score_events se +SET event_gamebox_id = m.event_gamebox_id +FROM _gb_mapping m +WHERE se.gamebox_template_id = m.legacy_template_id + AND se.event_gamebox_id IS NULL; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 5. 可验证性断言(§61)——任何一条不满足即回滚整个迁移 +-- ────────────────────────────────────────────────────────────────────────────── +DO $$ +DECLARE + template_cnt INT; eg_cnt INT; orphan_inst INT; orphan_link INT; dup_offset INT; dup_inst INT; +BEGIN + SELECT count(*) INTO template_cnt FROM awd_gamebox_templates; + SELECT count(*) INTO eg_cnt FROM awd_event_gameboxes; + IF template_cnt <> eg_cnt THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: legacy templates % <> event_gameboxes %', template_cnt, eg_cnt; + END IF; + + -- 每个 legacy instance 都有 event_gamebox_id + SELECT count(*) INTO orphan_inst FROM awd_gamebox_instances WHERE event_gamebox_id IS NULL; + IF orphan_inst > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: % instances lack event_gamebox_id', orphan_inst; + END IF; + + -- 每个 EventGameBox 都有 GameBoxRevision(FK 已保证)且无孤儿 revision link(复合 FK 已保证) + + -- 同 Event host_offset 无重复(UNIQUE 已保证,双保险) + SELECT count(*) INTO dup_offset FROM ( + SELECT event_id, host_offset FROM awd_event_gameboxes GROUP BY 1,2 HAVING count(*) > 1 + ) d; + IF dup_offset > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: duplicate host_offset'; + END IF; + + -- 同 Event + Team + EventGameBox 只有一个 logical Instance + SELECT count(*) INTO dup_inst FROM ( + SELECT event_id, team_id, event_gamebox_id FROM awd_gamebox_instances + GROUP BY 1,2,3 HAVING count(*) > 1 + ) d; + IF dup_inst > 0 THEN + RAISE EXCEPTION 'MIGRATION VERIFY FAIL: duplicate logical instances'; + END IF; +END $$; + +COMMIT; diff --git a/apps/api/src/sql/migrations/20260808183946-awd-gamebox-domain-c.sql b/apps/api/src/sql/migrations/20260808183946-awd-gamebox-domain-c.sql new file mode 100644 index 00000000..3012d3f3 --- /dev/null +++ b/apps/api/src/sql/migrations/20260808183946-awd-gamebox-domain-c.sql @@ -0,0 +1,49 @@ +-- ================================================================================ +-- Migration: 20260808183946-awd-gamebox-domain-c +-- ================================================================================ +-- GameBox 领域模型重构 — Migration C:回填后的约束收紧。 +-- +-- 仅在 Migration B 回填完成后执行(依赖所有旧行已获得 event_gamebox_id)。 +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. awd_gamebox_instances:event_gamebox_id 强制 + FK + 新 UNIQUE +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + ALTER COLUMN "event_gamebox_id" SET NOT NULL; + +ALTER TABLE "awd_gamebox_instances" + ADD CONSTRAINT "awd_gamebox_instances_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE RESTRICT; + +-- 旧 UNIQUE(event_id, template_id, team_id) 被新 UNIQUE(event_id, event_gamebox_id, team_id) 取代 +ALTER TABLE "awd_gamebox_instances" + DROP CONSTRAINT IF EXISTS "awd_gamebox_instances_event_id_template_id_team_id_key"; + +ALTER TABLE "awd_gamebox_instances" + ADD CONSTRAINT "awd_gamebox_instances_event_gamebox_team_key" + UNIQUE ("event_id", "event_gamebox_id", "team_id"); + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_judge_tasks / awd_score_events:FK(SET NULL 保历史,§57) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + ADD CONSTRAINT "awd_judge_tasks_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE SET NULL; + +ALTER TABLE "awd_score_events" + ADD CONSTRAINT "awd_score_events_event_gamebox_fk" + FOREIGN KEY ("event_gamebox_id") + REFERENCES "awd_event_gameboxes" ("id") + ON DELETE SET NULL; + +COMMENT ON COLUMN "awd_judge_tasks"."event_gamebox_id" IS '判题目标 EventGameBox(SET NULL:EventGameBox 删除后保留历史行)'; +COMMENT ON COLUMN "awd_score_events"."event_gamebox_id" IS '计分作用域 EventGameBox(SET NULL:EventGameBox 删除后保留历史行)'; + +COMMIT; diff --git a/apps/api/src/sql/migrations/20260808183947-awd-gamebox-domain-d.sql b/apps/api/src/sql/migrations/20260808183947-awd-gamebox-domain-d.sql new file mode 100644 index 00000000..7e5c5851 --- /dev/null +++ b/apps/api/src/sql/migrations/20260808183947-awd-gamebox-domain-d.sql @@ -0,0 +1,76 @@ +-- ================================================================================ +-- Migration: 20260808183947-awd-gamebox-domain-d +-- ================================================================================ +-- GameBox 领域模型重构 — Migration D:删除 legacy 双轨 schema。 +-- +-- 依赖 A/B/C 全部落库且业务代码已切换到新模型后执行(本文件随 A/B/C 一起 +-- 进入 merged.sql,但语义上属于最终清理:旧表/旧列/旧拼写不再存在于 schema)。 +-- +-- 删除清单(§59): +-- - awd_gamebox_templates(被 gameboxes + gamebox_revisions + awd_event_gameboxes 取代) +-- - event_gameboxes(旧 Jeopardy-GameBox 关联,无业务调用者) +-- - instances.gamebox_id(旧 GameBox runtime 关联,仅剩 read filter) +-- - gameboxes.toml_str / username / break_point / fix_point / down_point / first_bouns +-- - awd_team_networks.next_gamebox_host(host_offset 取代) +-- - awd_gamebox_instances.template_id / docker_network_id / container_id(→ current_container_id) +-- - awd_judge_tasks.template_id / awd_score_events.gamebox_template_id(→ event_gamebox_id) +-- ================================================================================ + +BEGIN; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 1. awd_gamebox_instances:删旧列 + container_id 改名 + gamebox_ip 转 INET +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_gamebox_instances" + DROP COLUMN IF EXISTS "template_id", + DROP COLUMN IF EXISTS "docker_network_id", + DROP COLUMN IF EXISTS "container_id"; + +-- 注:gamebox_ip 保持 VARCHAR(15),不做 INET 转换。 +-- §23 逃生口:SeaORM 1.1.20 将 INET 映射为 String,INSERT/比较时按 TEXT 绑定, +-- PostgreSQL 拒绝 text→inet(无隐式转换),实体生成产物无法携带类型覆盖。 +-- 因此 INET 迁移列为独立后续项(schema 类型层面改造),本次不改。 +COMMENT ON COLUMN "awd_gamebox_instances"."gamebox_ip" IS '靶机内网 IP(= team.gamebox_subnet + event_gamebox.host_offset,确定性分配)'; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 2. awd_team_networks:删除 next_gamebox_host(§14,host_offset 取代) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_team_networks" + DROP COLUMN IF EXISTS "next_gamebox_host"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 3. gameboxes:删除 legacy runtime/计分列;name 不强制全局唯一(§53,展示名) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "gameboxes" + DROP COLUMN IF EXISTS "toml_str", + DROP COLUMN IF EXISTS "username", + DROP COLUMN IF EXISTS "break_point", + DROP COLUMN IF EXISTS "fix_point", + DROP COLUMN IF EXISTS "down_point", + DROP COLUMN IF EXISTS "first_bouns"; + +ALTER TABLE "gameboxes" + DROP CONSTRAINT IF EXISTS "gameboxes_name_key"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 4. awd_judge_tasks / awd_score_events:删旧 template 引用列 +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "awd_judge_tasks" + DROP COLUMN IF EXISTS "template_id"; + +ALTER TABLE "awd_score_events" + DROP COLUMN IF EXISTS "gamebox_template_id"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 5. 删除 legacy 表 +-- ────────────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS "awd_gamebox_templates"; +DROP TABLE IF EXISTS "event_gameboxes"; + +-- ────────────────────────────────────────────────────────────────────────────── +-- 6. instances.gamebox_id(Jeopardy 通用实例不再支持 GameBox,§33) +-- ────────────────────────────────────────────────────────────────────────────── +ALTER TABLE "instances" + DROP COLUMN IF EXISTS "gamebox_id"; + +COMMIT; diff --git a/apps/api/tests/awd_scenarios.rs b/apps/api/tests/awd_scenarios.rs index b391a0a6..17773026 100644 --- a/apps/api/tests/awd_scenarios.rs +++ b/apps/api/tests/awd_scenarios.rs @@ -99,6 +99,19 @@ async fn cleanup_event(db: &sea_orm::DatabaseConnection, event_id: Uuid) { .filter(awd_rounds::Column::EventId.eq(event_id)) .exec(db) .await; + let _ = floatctf::entity::awd_gamebox_instances::Entity::delete_many() + .filter(floatctf::entity::awd_gamebox_instances::Column::EventId.eq(event_id)) + .exec(db) + .await; + let _ = floatctf::entity::awd_event_gameboxes::Entity::delete_many() + .filter(floatctf::entity::awd_event_gameboxes::Column::EventId.eq(event_id)) + .exec(db) + .await; + // 全局 gameboxes 清理(safe_name 前缀 gb-;级联删除 revisions) + let _ = floatctf::entity::gameboxes::Entity::delete_many() + .filter(floatctf::entity::gameboxes::Column::SafeName.like("gb-%")) + .exec(db) + .await; let _ = awd_events::Entity::delete_many() .filter(awd_events::Column::EventId.eq(event_id)) .exec(db) @@ -382,12 +395,13 @@ async fn seed_submission_fixture( Uuid, // attacker_team_id Uuid, // victim_team_id Uuid, // instance_id - Uuid, // template_id + Uuid, // event_gamebox_id Uuid, // user_id Uuid, // flag_issue_id ) { use floatctf::entity::{ - awd_flag_issues, awd_gamebox_instances, awd_gamebox_templates, awd_rounds, event_teams, + awd_event_gameboxes, awd_flag_issues, awd_gamebox_instances, awd_rounds, event_teams, + gamebox_revisions, gameboxes, sea_orm_active_enums::{AwdPhase, GameboxStatus, RoundStatus}, users, }; @@ -441,18 +455,68 @@ async fn seed_submission_fixture( .await .expect("insert victim team"); - // Template - let template_id = Uuid::new_v4(); - awd_gamebox_templates::ActiveModel { - id: Set(template_id), - event_id: Set(event_id), - name: Set(format!("tmpl-{tag}")), + // GameBox identity + Revision 1 + EventGameBox(新四层模型) + // safe_name 随机后缀,避免跨 run 残留冲突(gameboxes 是全局表,events 级联清不到) + let gamebox_id = Uuid::new_v4(); + let gb_suffix = &gamebox_id.to_string().replace('-', "")[..8]; + gameboxes::ActiveModel { + id: Set(gamebox_id), + name: Set(format!("gb-{tag}")), + safe_name: Set(format!("gb-{tag}-{gb_suffix}")), + category: Set("other".into()), + description: Set(String::new()), + hidden: Set(false), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert gamebox"); + + let revision_id = Uuid::new_v4(); + gamebox_revisions::ActiveModel { + id: Set(revision_id), + gamebox_id: Set(gamebox_id), + revision_number: Set(1), + source_toml: Set(String::new()), + spec_schema_version: Set(1), + spec_json: Set(serde_json::json!({})), + spec_digest: Set(format!("digest-{tag}")), image_ref: Set("fctf/test:latest".into()), + image_digest: Set(None), username: Set("root".into()), - meta_json: Set(serde_json::json!({})), + default_cpu_millis: Set(1000), + default_memory_bytes: Set(512 * 1024 * 1024), + default_pids_limit: Set(256), + healthcheck_json: Set(None), + judge_script_name: Set(None), + judge_script_content: Set(None), + judge_args_json: Set(None), + default_judge_timeout_secs: Set(None), + default_judge_retry_interval_secs: Set(None), + created_at: Set(now.into()), + ..Default::default() + } + .insert(db) + .await + .expect("insert revision"); + + let event_gamebox_id = Uuid::new_v4(); + awd_event_gameboxes::ActiveModel { + id: Set(event_gamebox_id), + event_id: Set(event_id), + gamebox_id: Set(gamebox_id), + gamebox_revision_id: Set(revision_id), + host_offset: Set(10), + enabled: Set(true), + hidden: Set(false), cpu_millis: Set(1000), memory_bytes: Set(512 * 1024 * 1024), pids_limit: Set(256), + healthcheck_override_json: Set(None), + judge_timeout_secs: Set(None), + judge_retry_interval_secs: Set(None), break_points: Set(100), loss_points: Set(50), fix_points: Set(80), @@ -464,18 +528,20 @@ async fn seed_submission_fixture( } .insert(db) .await - .expect("insert template"); + .expect("insert event_gamebox"); - // Instance + // Instance(logical identity;container 只是 runtime) let instance_id = Uuid::new_v4(); awd_gamebox_instances::ActiveModel { id: Set(instance_id), event_id: Set(event_id), - template_id: Set(template_id), + event_gamebox_id: Set(event_gamebox_id), team_id: Set(victim_team_id), status: Set(GameboxStatus::Ready), container_name: Set(format!("fctf-gb-{}-{tag}", &event_id.to_string()[..8])), gamebox_ip: Set("10.42.1.10".into()), + runtime_generation: Set(1), + current_container_id: Set(Some(format!("cid-{tag}"))), health_status: Set("healthy".into()), created_at: Set(now.into()), updated_at: Set(now.into()), @@ -523,7 +589,7 @@ async fn seed_submission_fixture( attacker_team_id, victim_team_id, instance_id, - template_id, + event_gamebox_id, user_id, flag_issue_id, ) diff --git a/apps/web/src/entity/awd_event_gameboxes.ts b/apps/web/src/entity/awd_event_gameboxes.ts new file mode 100644 index 00000000..4660b33d --- /dev/null +++ b/apps/web/src/entity/awd_event_gameboxes.ts @@ -0,0 +1,22 @@ +export type AwdEventGameboxes = { + id: string; + event_id: string; + gamebox_id: string; + gamebox_revision_id: string; + host_offset: string; + enabled: boolean; + hidden: boolean; + cpu_millis: number; + memory_bytes: number; + pids_limit: number; + healthcheck_override_json?: string; + judge_timeout_secs?: string; + judge_retry_interval_secs?: string; + break_points: number; + loss_points: number; + fix_points: number; + down_points: number; + first_bonus: number; + created_at: string; + updated_at: string; +}; diff --git a/apps/web/src/entity/awd_gamebox_instances.ts b/apps/web/src/entity/awd_gamebox_instances.ts index 416e10f6..a1db8bb1 100644 --- a/apps/web/src/entity/awd_gamebox_instances.ts +++ b/apps/web/src/entity/awd_gamebox_instances.ts @@ -3,17 +3,17 @@ import type { GameboxStatus } from './sea_orm_active_enums'; export type AwdGameboxInstances = { id: string; event_id: string; - template_id: string; team_id: string; status: GameboxStatus; - container_id?: string; container_name: string; gamebox_ip: string; - docker_network_id?: string; health_status: string; reset_protection_until?: string; last_health_check_at?: string; created_at: string; updated_at: string; deleted_at?: string; + event_gamebox_id: string; + runtime_generation: number; + current_container_id?: string; }; diff --git a/apps/web/src/entity/awd_judge_tasks.ts b/apps/web/src/entity/awd_judge_tasks.ts index 238f8338..cd195d58 100644 --- a/apps/web/src/entity/awd_judge_tasks.ts +++ b/apps/web/src/entity/awd_judge_tasks.ts @@ -6,7 +6,6 @@ export type AwdJudgeTasks = { event_id: string; round_id: string; gamebox_instance_id: string; - template_id: string; team_id: string; status: JudgeTaskStatus; attempt_count: number; @@ -20,4 +19,5 @@ export type AwdJudgeTasks = { duration_ms?: string; callback_idempotency_key?: string; created_at: string; + event_gamebox_id?: string; }; diff --git a/apps/web/src/entity/awd_score_events.ts b/apps/web/src/entity/awd_score_events.ts index 64ba1713..074bf190 100644 --- a/apps/web/src/entity/awd_score_events.ts +++ b/apps/web/src/entity/awd_score_events.ts @@ -10,10 +10,10 @@ export type AwdScoreEvents = { idempotency_key: string; related_team_id?: string; gamebox_instance_id?: string; - gamebox_template_id?: string; reference_id?: string; reason?: string; metadata_json: string; created_by?: string; created_at: string; + event_gamebox_id?: string; }; diff --git a/apps/web/src/entity/awd_team_networks.ts b/apps/web/src/entity/awd_team_networks.ts index 8ab1bdbf..af3ac0d1 100644 --- a/apps/web/src/entity/awd_team_networks.ts +++ b/apps/web/src/entity/awd_team_networks.ts @@ -7,7 +7,6 @@ export type AwdTeamNetworks = { ssh_password_ciphertext: string; ssh_password_nonce: string; key_version: number; - next_gamebox_host: number; next_wireguard_host: number; status: string; created_at: string; diff --git a/apps/web/src/entity/gamebox_revisions.ts b/apps/web/src/entity/gamebox_revisions.ts new file mode 100644 index 00000000..a80ed9ea --- /dev/null +++ b/apps/web/src/entity/gamebox_revisions.ts @@ -0,0 +1,22 @@ +export type GameboxRevisions = { + id: string; + gamebox_id: string; + revision_number: number; + source_toml: string; + spec_schema_version: number; + spec_json: string; + spec_digest: string; + image_ref: string; + image_digest?: string; + username: string; + default_cpu_millis: number; + default_memory_bytes: number; + default_pids_limit: number; + healthcheck_json?: string; + judge_script_name?: string; + judge_script_content?: string; + judge_args_json?: string; + default_judge_timeout_secs?: string; + default_judge_retry_interval_secs?: string; + created_at: string; +}; diff --git a/apps/web/src/entity/gameboxes.ts b/apps/web/src/entity/gameboxes.ts index ec094927..2ef43243 100644 --- a/apps/web/src/entity/gameboxes.ts +++ b/apps/web/src/entity/gameboxes.ts @@ -5,12 +5,6 @@ export type Gameboxes = { category: string; description: string; hidden: boolean; - toml_str: string; - username: string; - break_point: number; - fix_point: number; - down_point: number; - first_bouns: number; created_at: string; updated_at: string; }; diff --git a/apps/web/src/entity/index.ts b/apps/web/src/entity/index.ts index 104f4927..95ee6ddf 100644 --- a/apps/web/src/entity/index.ts +++ b/apps/web/src/entity/index.ts @@ -1,10 +1,10 @@ export * from "./sea_orm_active_enums"; export * from "./announcements"; +export * from "./awd_event_gameboxes"; export * from "./awd_events"; export * from "./awd_flag_issues"; export * from "./awd_flag_submissions"; export * from "./awd_gamebox_instances"; -export * from "./awd_gamebox_templates"; export * from "./awd_internal_token_rotations"; export * from "./awd_judge_batches"; export * from "./awd_judge_tasks"; @@ -28,7 +28,6 @@ export * from "./discussions"; export * from "./event_announcements"; export * from "./event_challenge_solves"; export * from "./event_challenges"; -export * from "./event_gameboxes"; export * from "./event_instances"; export * from "./event_logs"; export * from "./event_team_members"; @@ -36,6 +35,7 @@ export * from "./event_teams"; export * from "./event_users"; export * from "./event_writeup"; export * from "./events"; +export * from "./gamebox_revisions"; export * from "./gameboxes"; export * from "./instances"; export * from "./logs"; diff --git a/apps/web/src/entity/instances.ts b/apps/web/src/entity/instances.ts index dfe94ae7..87244eae 100644 --- a/apps/web/src/entity/instances.ts +++ b/apps/web/src/entity/instances.ts @@ -7,7 +7,6 @@ export type Instances = { flag: string; content?: string; challenge_id?: string; - gamebox_id?: string; user_id: string; identifier: string; created_at: string; diff --git a/crates/fcmc/src/application/awd.rs b/crates/fcmc/src/application/awd.rs index 03786f68..b6669fe9 100644 --- a/crates/fcmc/src/application/awd.rs +++ b/crates/fcmc/src/application/awd.rs @@ -58,8 +58,9 @@ impl AwdApp { &self, event_id: Uuid, team_id: Uuid, - template_id: Uuid, + event_gamebox_id: Uuid, instance_id: Uuid, + runtime_generation: i64, meta: &GameBoxMeta, container_name: String, network_name: String, @@ -69,8 +70,9 @@ impl AwdApp { let spec = GameBoxSpec { event_id, team_id, - template_id, + event_gamebox_id, instance_id, + runtime_generation, container_name, image_ref: meta.gamebox.image_tag.clone(), network_name, @@ -86,7 +88,8 @@ impl AwdApp { event_id, team_id, instance_id, - template_id, + event_gamebox_id, + runtime_generation, "gamebox", ), }; @@ -105,7 +108,7 @@ impl AwdApp { &self, event_id: Uuid, team_id: Uuid, - template_id: Uuid, + event_gamebox_id: Uuid, instance_id: Uuid, container_name: String, recreate_spec: GameBoxSpec, @@ -115,7 +118,7 @@ impl AwdApp { .reset_gamebox(GameBoxResetSpec { event_id, team_id, - template_id, + event_gamebox_id, instance_id, container_name, recreate_spec, diff --git a/crates/fcmc/src/runtime/awd.rs b/crates/fcmc/src/runtime/awd.rs index 702705ef..1a2cff27 100644 --- a/crates/fcmc/src/runtime/awd.rs +++ b/crates/fcmc/src/runtime/awd.rs @@ -44,8 +44,11 @@ pub struct InfrastructureContainerSpec { pub struct GameBoxSpec { pub event_id: Uuid, pub team_id: Uuid, - pub template_id: Uuid, + /// EventGameBox(逻辑靶机定义);不再使用旧 template 概念。 + pub event_gamebox_id: Uuid, pub instance_id: Uuid, + /// 当前 runtime generation(首次=1,Reset 后 +1;仅标签/审计,不参与逻辑身份)。 + pub runtime_generation: i64, pub container_name: String, pub image_ref: String, pub network_name: String, @@ -64,7 +67,7 @@ pub struct GameBoxSpec { pub struct GameBoxResetSpec { pub event_id: Uuid, pub team_id: Uuid, - pub template_id: Uuid, + pub event_gamebox_id: Uuid, pub instance_id: Uuid, pub container_name: String, pub recreate_spec: GameBoxSpec, @@ -111,7 +114,8 @@ pub fn awd_labels( event_id: Uuid, team_id: Uuid, instance_id: Uuid, - template_id: Uuid, + event_gamebox_id: Uuid, + runtime_generation: i64, resource_kind: &str, ) -> HashMap { let mut labels = HashMap::new(); @@ -122,8 +126,12 @@ pub fn awd_labels( instance_id.to_string(), ); labels.insert( - "awd.gamebox_template_id".to_string(), - template_id.to_string(), + "awd.event_gamebox_id".to_string(), + event_gamebox_id.to_string(), + ); + labels.insert( + "awd.runtime_generation".to_string(), + runtime_generation.to_string(), ); labels.insert("awd.resource_kind".to_string(), resource_kind.to_string()); labels @@ -199,6 +207,7 @@ impl AwdContainerRuntime for DockerRuntime { Uuid::nil(), Uuid::nil(), Uuid::nil(), + 0, "infrastructure", ); @@ -245,7 +254,8 @@ impl AwdContainerRuntime for DockerRuntime { spec.event_id, spec.team_id, spec.instance_id, - spec.template_id, + spec.event_gamebox_id, + spec.runtime_generation, "gamebox", ); diff --git a/crates/fcmc/tests/docker_runtime.rs b/crates/fcmc/tests/docker_runtime.rs index d0c72464..c3e3a65e 100644 --- a/crates/fcmc/tests/docker_runtime.rs +++ b/crates/fcmc/tests/docker_runtime.rs @@ -88,6 +88,7 @@ async fn docker_gamebox_create_and_start() { uuid::Uuid::new_v4(), uuid::Uuid::new_v4(), uuid::Uuid::new_v4(), + 1, "gamebox", ), network_name: None, diff --git a/crates/fcmc/tests/metadata_test.rs b/crates/fcmc/tests/metadata_test.rs index 44f9499c..1b50735b 100644 --- a/crates/fcmc/tests/metadata_test.rs +++ b/crates/fcmc/tests/metadata_test.rs @@ -317,6 +317,7 @@ fn awd_labels_content() { uuid::Uuid::nil(), uuid::Uuid::nil(), uuid::Uuid::nil(), + 0, "gamebox", ); assert_eq!( From f6587de9417b0d8fd8e0e4e0f750f9c697d67a36 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 19:06:25 +0800 Subject: [PATCH 034/486] =?UTF-8?q?feat(awd):=20GameBox=20=E5=BA=93?= =?UTF-8?q?=E4=B8=8E=E8=B5=9B=E4=BA=8B=20GameBox=20=E7=AE=A1=E7=90=86=20AP?= =?UTF-8?q?I=EF=BC=88=C2=A746=20=E6=9C=AF=E8=AF=AD=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/admin/awd/gameboxes:全局 GameBox 库(create 自动 Revision 1、edit → N+1 digest 去重、hide 归档;safe_name 校验/自动生成) - /api/admin/events/{id}/awd/gameboxes:赛事选择(add pin latest/指定 revision、 host_offset 自动分配或显式 2..254、PATCH 计分/资源/判题覆盖、DELETE 移除) - 增删改一律 touch_configuration(§37) - §12 UNIQUE(event_id,gamebox_id) 冲突校验;§11 revision 归属校验; §38 已有实例禁止移除;§64 handler 只做薄转发 - websocket 事件字段 template_id → event_gamebox_id --- .../api/src/modules/event/awd_team/api/dto.rs | 228 +++++++++ .../event/awd_team/api/gamebox_admin.rs | 477 ++++++++++++++++++ .../api/src/modules/event/awd_team/api/mod.rs | 12 +- .../src/modules/event/awd_team/websocket.rs | 8 +- 4 files changed, 720 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/event/awd_team/api/gamebox_admin.rs diff --git a/apps/api/src/modules/event/awd_team/api/dto.rs b/apps/api/src/modules/event/awd_team/api/dto.rs index 9ef86559..dcf90a5b 100644 --- a/apps/api/src/modules/event/awd_team/api/dto.rs +++ b/apps/api/src/modules/event/awd_team/api/dto.rs @@ -176,3 +176,231 @@ pub struct JudgeBatchDto { pub status: String, pub created_at: Option, } + +// ════════════════════════════════════════════════════════════════════════════ +// GameBox 领域(§46 术语统一:gamebox / gamebox_revision / event_gamebox / gamebox_instance) +// ════════════════════════════════════════════════════════════════════════════ + +/// 管理员编辑 GameBox 时的运行时配置(= Revision 内容)。 +#[derive(Debug, Deserialize)] +pub struct GameBoxConfigPayload { + #[serde(default)] + pub source_toml: String, + pub image_ref: String, + #[serde(default)] + pub image_digest: Option, + #[serde(default = "default_gb_username")] + pub username: String, + #[serde(default = "default_gb_cpu")] + pub cpu_millis: i64, + #[serde(default = "default_gb_memory")] + pub memory_bytes: i64, + #[serde(default = "default_gb_pids")] + pub pids_limit: i64, + #[serde(default)] + pub healthcheck: Option, + #[serde(default)] + pub judge_script_name: Option, + #[serde(default)] + pub judge_script_content: Option, + #[serde(default)] + pub judge_args: Option, + #[serde(default)] + pub judge_timeout_secs: Option, + #[serde(default)] + pub judge_retry_interval_secs: Option, +} + +fn default_gb_username() -> String { + "ctf".into() +} +fn default_gb_cpu() -> i64 { + 1000 +} +fn default_gb_memory() -> i64 { + 512 * 1024 * 1024 +} +fn default_gb_pids() -> i64 { + 100 +} + +impl GameBoxConfigPayload { + pub fn into_config( + self, + ) -> crate::modules::event::awd_team::service::gamebox_service::GameBoxConfig { + crate::modules::event::awd_team::service::gamebox_service::GameBoxConfig { + source_toml: self.source_toml, + image_ref: self.image_ref, + image_digest: self.image_digest, + username: self.username, + cpu_millis: self.cpu_millis, + memory_bytes: self.memory_bytes, + pids_limit: self.pids_limit, + healthcheck: self.healthcheck, + judge_script_name: self.judge_script_name, + judge_script_content: self.judge_script_content, + judge_args_json: self.judge_args, + judge_timeout_secs: self.judge_timeout_secs, + judge_retry_interval_secs: self.judge_retry_interval_secs, + } + } +} + +/// POST /api/admin/awd/gameboxes +#[derive(Debug, Deserialize)] +pub struct CreateGameBoxRequest { + pub name: String, + /// 可选;缺省由 name slug 生成(自动去重)。 + #[serde(default)] + pub safe_name: Option, + #[serde(default = "default_category")] + pub category: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub hidden: bool, + pub config: GameBoxConfigPayload, +} + +fn default_category() -> String { + "other".into() +} + +/// POST /api/admin/awd/gameboxes/{gamebox_id}/revisions(编辑 → Revision N+1) +#[derive(Debug, Deserialize)] +pub struct EditGameBoxRevisionRequest { + pub config: GameBoxConfigPayload, +} + +/// POST /api/admin/events/{event_id}/awd/gameboxes(赛事选择) +#[derive(Debug, Deserialize)] +pub struct AddEventGameBoxRequest { + pub gamebox_id: Uuid, + /// 可选:pin 的具体 revision;缺省使用 latest。 + #[serde(default)] + pub revision_id: Option, + /// 可选:确定性 IP 偏移(2..254);缺省自动分配未占用值。 + #[serde(default)] + pub host_offset: Option, + #[serde(default)] + pub hidden: bool, + #[serde(default = "default_break_points")] + pub break_points: i64, + #[serde(default = "default_loss_points")] + pub loss_points: i64, + #[serde(default = "default_fix_points")] + pub fix_points: i64, + #[serde(default = "default_down_points")] + pub down_points: i64, + #[serde(default = "default_first_bonus")] + pub first_bonus: i64, +} + +fn default_break_points() -> i64 { + 100 +} +fn default_loss_points() -> i64 { + 100 +} +fn default_fix_points() -> i64 { + 100 +} +fn default_down_points() -> i64 { + 200 +} +fn default_first_bonus() -> i64 { + 20 +} + +/// PATCH /api/admin/events/{event_id}/awd/gameboxes/{event_gamebox_id} +#[derive(Debug, Deserialize)] +pub struct UpdateEventGameBoxRequest { + #[serde(default)] + pub revision_id: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub hidden: Option, + #[serde(default)] + pub cpu_millis: Option, + #[serde(default)] + pub memory_bytes: Option, + #[serde(default)] + pub pids_limit: Option, + #[serde(default)] + pub judge_timeout_secs: Option>, + #[serde(default)] + pub judge_retry_interval_secs: Option>, + #[serde(default)] + pub break_points: Option, + #[serde(default)] + pub loss_points: Option, + #[serde(default)] + pub fix_points: Option, + #[serde(default)] + pub down_points: Option, + #[serde(default)] + pub first_bonus: Option, +} + +// ── 响应 DTO ── + +#[derive(Debug, Serialize)] +pub struct GameBoxRevisionDto { + pub id: Uuid, + pub revision_number: i32, + pub image_ref: String, + pub image_digest: Option, + pub username: String, + pub spec_digest: String, + pub created_at: chrono::DateTime, +} + +impl From<&crate::entity::gamebox_revisions::Model> for GameBoxRevisionDto { + fn from(r: &crate::entity::gamebox_revisions::Model) -> Self { + Self { + id: r.id, + revision_number: r.revision_number, + image_ref: r.image_ref.clone(), + image_digest: r.image_digest.clone(), + username: r.username.clone(), + spec_digest: r.spec_digest.clone(), + created_at: r.created_at, + } + } +} + +#[derive(Debug, Serialize)] +pub struct GameBoxLibraryDto { + pub id: Uuid, + pub name: String, + pub safe_name: String, + pub category: String, + pub description: String, + pub hidden: bool, + pub latest_revision: Option, +} + +#[derive(Debug, Serialize)] +pub struct EventGameBoxDto { + pub id: Uuid, + pub gamebox_id: Uuid, + pub gamebox_name: String, + pub gamebox_safe_name: String, + pub revision_id: Uuid, + pub revision_number: i32, + pub host_offset: i16, + pub enabled: bool, + pub hidden: bool, + pub cpu_millis: i64, + pub memory_bytes: i64, + pub pids_limit: i64, + pub judge_timeout_secs: Option, + pub judge_retry_interval_secs: Option, + pub break_points: i64, + pub loss_points: i64, + pub fix_points: i64, + pub down_points: i64, + pub first_bonus: i64, + pub created_at: chrono::DateTime, +} diff --git a/apps/api/src/modules/event/awd_team/api/gamebox_admin.rs b/apps/api/src/modules/event/awd_team/api/gamebox_admin.rs new file mode 100644 index 00000000..7abf1a9b --- /dev/null +++ b/apps/api/src/modules/event/awd_team/api/gamebox_admin.rs @@ -0,0 +1,477 @@ +//! GameBox library + EventGameBox admin handlers(§46 术语统一)。 +//! +//! - `/api/admin/awd/gameboxes`:全局 GameBox 库(identity + immutable Revision) +//! - `/api/admin/events/{event_id}/awd/gameboxes`:赛事选择(pin revision + 计分/资源配置) +//! +//! Handler 只做 parse/auth/service 调用(§64),复杂逻辑一律在 service/repo。 + +use actix_web::web; +use sea_orm::{DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter}; +use uuid::Uuid; + +use crate::{ + api::{AppError, UniResponse, UniResult, extractor::auth::SuperAdminJwtGuard, prelude::*}, + modules::event::awd_team::{ + domain::instance_ip_for_offset, + repo::{event_gamebox_repo, event_repo, gamebox_lib_repo}, + service::gamebox_service, + }, +}; + +use super::dto::*; + +// ──────────────────────────────────────────────────────────────────────────── +// GameBox 库(全局 identity + Revision) +// ──────────────────────────────────────────────────────────────────────────── + +/// GET /api/admin/awd/gameboxes +#[get("/awd/gameboxes")] +pub async fn list_gamebox_library( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, +) -> UniResult> { + let items = gamebox_lib_repo::list_gameboxes_with_revisions(ctx.db.get_ref(), true) + .await + .map_err(AppError::from)?; + let dto: Vec = items + .into_iter() + .map(|g| GameBoxLibraryDto { + id: g.gamebox.id, + name: g.gamebox.name, + safe_name: g.gamebox.safe_name, + category: g.gamebox.category, + description: g.gamebox.description, + hidden: g.gamebox.hidden, + latest_revision: g.latest_revision.as_ref().map(GameBoxRevisionDto::from), + }) + .collect(); + UniResponse::ok(dto.into()).into() +} + +/// POST /api/admin/awd/gameboxes —— 创建 GameBox(自动 Revision 1) +#[post("/awd/gameboxes")] +pub async fn create_gamebox( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + body: web::Json, +) -> UniResult { + let req = body.into_inner(); + let safe_name = match req.safe_name { + Some(s) => { + crate::modules::event::awd_team::domain::validate_safe_name(&s) + .map_err(|e| AppError::Validation(e))?; + s + } + None => gamebox_service::unique_safe_name(ctx.db.get_ref(), &req.name) + .await + .map_err(AppError::from)?, + }; + let (gb, rev) = gamebox_service::create_gamebox_with_revision( + ctx.db.get_ref(), + req.name, + safe_name, + req.category, + req.description, + req.hidden, + req.config.into_config(), + ) + .await + .map_err(AppError::from)?; + UniResponse::ok( + GameBoxLibraryDto { + id: gb.id, + name: gb.name, + safe_name: gb.safe_name, + category: gb.category, + description: gb.description, + hidden: gb.hidden, + latest_revision: Some(GameBoxRevisionDto::from(&rev)), + } + .into(), + ) + .into() +} + +/// POST /api/admin/awd/gameboxes/{gamebox_id}/revisions —— 编辑(→ Revision N+1) +/// spec 未变化时返回 200 + latest_revision 不变(§36 不创建重复 revision)。 +#[post("/awd/gameboxes/{gamebox_id}/revisions")] +pub async fn edit_gamebox_revision( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path, + body: web::Json, +) -> UniResult { + let gamebox_id = path.into_inner(); + let req = body.into_inner(); + let created = gamebox_service::edit_gamebox_revision( + ctx.db.get_ref(), + gamebox_id, + req.config.into_config(), + ) + .await + .map_err(AppError::from)?; + + let gb = gamebox_lib_repo::find_gamebox_by_id(ctx.db.get_ref(), gamebox_id) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::NotFound("GameBox not found".into()))?; + let latest = match created { + Some(r) => Some(r), + None => gamebox_lib_repo::find_revisions_by_gamebox(ctx.db.get_ref(), gamebox_id) + .await + .map_err(AppError::from)? + .into_iter() + .next(), + }; + UniResponse::ok( + GameBoxLibraryDto { + id: gb.id, + name: gb.name, + safe_name: gb.safe_name, + category: gb.category, + description: gb.description, + hidden: gb.hidden, + latest_revision: latest.as_ref().map(GameBoxRevisionDto::from), + } + .into(), + ) + .into() +} + +/// POST /api/admin/awd/gameboxes/{gamebox_id}/hide —— 归档(§55:被赛事引用禁止 hard delete) +#[post("/awd/gameboxes/{gamebox_id}/hide")] +pub async fn hide_gamebox( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path, +) -> UniResult<()> { + let gamebox_id = path.into_inner(); + gamebox_lib_repo::set_gamebox_hidden(ctx.db.get_ref(), gamebox_id, true) + .await + .map_err(AppError::from)?; + UniResponse::ok_none().into() +} + +// ──────────────────────────────────────────────────────────────────────────── +// 赛事 GameBox 选择(EventGameBox) +// ──────────────────────────────────────────────────────────────────────────── + +/// GET /api/admin/events/{event_id}/awd/gameboxes +#[get("/events/{event_id}/awd/gameboxes")] +pub async fn list_event_gameboxes( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path, +) -> UniResult> { + let event_id = path.into_inner(); + let details = event_gamebox_repo::find_event_gameboxes_detail(ctx.db.get_ref(), event_id) + .await + .map_err(AppError::from)?; + let dto: Vec = details + .into_iter() + .map(|d| EventGameBoxDto { + id: d.event_gamebox.id, + gamebox_id: d.gamebox.id, + gamebox_name: d.gamebox.name, + gamebox_safe_name: d.gamebox.safe_name, + revision_id: d.revision.id, + revision_number: d.revision.revision_number, + host_offset: d.event_gamebox.host_offset, + enabled: d.event_gamebox.enabled, + hidden: d.event_gamebox.hidden, + cpu_millis: d.event_gamebox.cpu_millis, + memory_bytes: d.event_gamebox.memory_bytes, + pids_limit: d.event_gamebox.pids_limit, + judge_timeout_secs: d.event_gamebox.judge_timeout_secs, + judge_retry_interval_secs: d.event_gamebox.judge_retry_interval_secs, + break_points: d.event_gamebox.break_points, + loss_points: d.event_gamebox.loss_points, + fix_points: d.event_gamebox.fix_points, + down_points: d.event_gamebox.down_points, + first_bonus: d.event_gamebox.first_bonus, + created_at: d.event_gamebox.created_at, + }) + .collect(); + UniResponse::ok(dto.into()).into() +} + +/// POST /api/admin/events/{event_id}/awd/gameboxes —— 赛事选择 GameBox +/// (默认 pin latest revision;host_offset 缺省自动分配;完成后 touch_configuration §37) +#[post("/events/{event_id}/awd/gameboxes")] +pub async fn add_event_gamebox( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path, + body: web::Json, +) -> UniResult { + let event_id = path.into_inner(); + let req = body.into_inner(); + let db: &DatabaseConnection = ctx.db.get_ref(); + + // 校验 GameBox 存在 + let gb = gamebox_lib_repo::find_gamebox_by_id(db, req.gamebox_id) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::NotFound("GameBox not found".into()))?; + + // §12:Event 内一个 GameBox 只能有一个选择 + if event_gamebox_repo::find_event_gamebox(db, event_id, req.gamebox_id) + .await + .map_err(AppError::from)? + .is_some() + { + return Err(AppError::Conflict(format!( + "GameBox already selected for this event: {}", + gb.name + )) + .into()); + } + + // pin revision:显式指定或 latest(§35:保存后必须 pin 具体 UUID) + let revision = match req.revision_id { + Some(rid) => gamebox_lib_repo::find_revision_by_id(db, rid) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::NotFound("Revision not found".into()))?, + None => gamebox_lib_repo::find_revisions_by_gamebox(db, req.gamebox_id) + .await + .map_err(AppError::from)? + .into_iter() + .next() + .ok_or_else(|| { + AppError::NotFound("GameBox has no revision; create one first".into()) + })?, + }; + if revision.gamebox_id != req.gamebox_id { + return Err(AppError::Conflict("revision 不属于该 GameBox(§11 一致性)".into()).into()); + } + + // host_offset:显式或自动分配(2..254 未占用) + let host_offset = match req.host_offset { + Some(o) => { + if !(2..=254).contains(&o) { + return Err(AppError::Validation("host_offset 必须在 2..254".into()).into()); + } + o + } + None => { + let existing = event_gamebox_repo::find_event_gameboxes_by_event(db, event_id) + .await + .map_err(AppError::from)?; + let used: Vec = existing.iter().map(|e| e.host_offset).collect(); + let mut free: Option = None; + for cand in 2..=254i16 { + if !used.contains(&cand) { + free = Some(cand); + break; + } + } + free.ok_or_else(|| AppError::Conflict("no free host_offset (2..254) for event".into()))? + } + }; + + let eg = event_gamebox_repo::create_event_gamebox( + db, + event_id, + req.gamebox_id, + revision.id, + host_offset, + true, + req.hidden, + revision.default_cpu_millis, + revision.default_memory_bytes, + revision.default_pids_limit, + None, + revision.default_judge_timeout_secs, + revision.default_judge_retry_interval_secs, + req.break_points, + req.loss_points, + req.fix_points, + req.down_points, + req.first_bonus, + ) + .await + .map_err(AppError::from)?; + + // §37:EventGameBox add → configuration_generation += 1 + touch_event_configuration(db, event_id).await?; + + UniResponse::ok( + EventGameBoxDto { + id: eg.id, + gamebox_id: eg.gamebox_id, + gamebox_name: gb.name, + gamebox_safe_name: gb.safe_name, + revision_id: revision.id, + revision_number: revision.revision_number, + host_offset: eg.host_offset, + enabled: eg.enabled, + hidden: eg.hidden, + cpu_millis: eg.cpu_millis, + memory_bytes: eg.memory_bytes, + pids_limit: eg.pids_limit, + judge_timeout_secs: eg.judge_timeout_secs, + judge_retry_interval_secs: eg.judge_retry_interval_secs, + break_points: eg.break_points, + loss_points: eg.loss_points, + fix_points: eg.fix_points, + down_points: eg.down_points, + first_bonus: eg.first_bonus, + created_at: eg.created_at, + } + .into(), + ) + .into() +} + +/// PATCH /api/admin/events/{event_id}/awd/gameboxes/{event_gamebox_id} +/// (计分/资源/判题覆盖/pin revision 变更;已部署的 host_offset 禁改 §38;§37 touch_configuration) +#[patch("/events/{event_id}/awd/gameboxes/{event_gamebox_id}")] +pub async fn update_event_gamebox( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path<(Uuid, Uuid)>, + body: web::Json, +) -> UniResult { + let (_event_id, event_gamebox_id) = path.into_inner(); + let req = body.into_inner(); + let db: &DatabaseConnection = ctx.db.get_ref(); + + let eg = event_gamebox_repo::find_event_gamebox_by_id(db, event_gamebox_id) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::NotFound("EventGameBox not found".into()))?; + + // revision 变更必须属于同一 GameBox(§11) + if let Some(rid) = req.revision_id { + let rev = gamebox_lib_repo::find_revision_by_id(db, rid) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::NotFound("Revision not found".into()))?; + if rev.gamebox_id != eg.gamebox_id { + return Err( + AppError::Conflict("revision 不属于该 GameBox(§11 一致性)".into()).into(), + ); + } + } + + event_gamebox_repo::update_event_gamebox( + db, + event_gamebox_id, + event_gamebox_repo::EventGameBoxPatch { + gamebox_revision_id: req.revision_id, + enabled: req.enabled, + hidden: req.hidden, + cpu_millis: req.cpu_millis, + memory_bytes: req.memory_bytes, + pids_limit: req.pids_limit, + healthcheck_override_json: None, + judge_timeout_secs: req.judge_timeout_secs, + judge_retry_interval_secs: req.judge_retry_interval_secs, + break_points: req.break_points, + loss_points: req.loss_points, + fix_points: req.fix_points, + down_points: req.down_points, + first_bonus: req.first_bonus, + }, + ) + .await + .map_err(AppError::from)?; + + touch_event_configuration(db, eg.event_id).await?; + + let d = event_gamebox_repo::find_event_gameboxes_detail(db, eg.event_id) + .await + .map_err(AppError::from)? + .into_iter() + .find(|d| d.event_gamebox.id == event_gamebox_id) + .ok_or_else(|| AppError::NotFound("EventGameBox not found".into()))?; + UniResponse::ok( + EventGameBoxDto { + id: d.event_gamebox.id, + gamebox_id: d.gamebox.id, + gamebox_name: d.gamebox.name, + gamebox_safe_name: d.gamebox.safe_name, + revision_id: d.revision.id, + revision_number: d.revision.revision_number, + host_offset: d.event_gamebox.host_offset, + enabled: d.event_gamebox.enabled, + hidden: d.event_gamebox.hidden, + cpu_millis: d.event_gamebox.cpu_millis, + memory_bytes: d.event_gamebox.memory_bytes, + pids_limit: d.event_gamebox.pids_limit, + judge_timeout_secs: d.event_gamebox.judge_timeout_secs, + judge_retry_interval_secs: d.event_gamebox.judge_retry_interval_secs, + break_points: d.event_gamebox.break_points, + loss_points: d.event_gamebox.loss_points, + fix_points: d.event_gamebox.fix_points, + down_points: d.event_gamebox.down_points, + first_bonus: d.event_gamebox.first_bonus, + created_at: d.event_gamebox.created_at, + } + .into(), + ) + .into() +} + +/// DELETE /api/admin/events/{event_id}/awd/gameboxes/{event_gamebox_id} +/// (移除赛事选择;被 Instance 引用时 DB RESTRICT 拒绝;§37 touch_configuration) +#[delete("/events/{event_id}/awd/gameboxes/{event_gamebox_id}")] +pub async fn delete_event_gamebox( + _admin: SuperAdminJwtGuard, + ctx: ReqCtx, + path: web::Path<(Uuid, Uuid)>, +) -> UniResult<()> { + let (event_id, event_gamebox_id) = path.into_inner(); + let db: &DatabaseConnection = ctx.db.get_ref(); + + let eg = event_gamebox_repo::find_event_gamebox_by_id(db, event_gamebox_id) + .await + .map_err(AppError::from)?; + let eg = match eg { + Some(eg) => eg, + None => return UniResponse::ok_none().into(), + }; + if eg.event_id != event_id { + return Err(AppError::NotFound("EventGameBox not found".into()).into()); + } + + // §38:已部署(有 instance)的 EventGameBox 禁止直接移除 —— 视为 destructive + let instance_count = crate::entity::awd_gamebox_instances::Entity::find() + .filter(crate::entity::awd_gamebox_instances::Column::EventGameboxId.eq(event_gamebox_id)) + .count(db) + .await + .map_err(AppError::from)?; + if instance_count > 0 { + return Err(AppError::Conflict( + "EventGameBox 已有实例,禁止移除(先处理实例或视为 destructive 重配置)".into(), + ) + .into()); + } + + event_gamebox_repo::delete_event_gamebox(db, event_gamebox_id) + .await + .map_err(AppError::from)?; + touch_event_configuration(db, event_id).await?; + UniResponse::ok_none().into() +} + +// ──────────────────────────────────────────────────────────────────────────── +// 辅助 +// ──────────────────────────────────────────────────────────────────────────── + +/// §37:EventGameBox 增删改 → configuration_generation += 1(Verified 失效) +async fn touch_event_configuration( + db: &DatabaseConnection, + event_id: Uuid, +) -> Result<(), AppError> { + event_repo::touch_configuration(db, event_id) + .await + .map_err(|e| AppError::Database(e.to_string())) +} + +/// IP 确定性校验工具(供 Precheck 使用;handler 层不直接算 IP,§64) +#[allow(dead_code)] +pub(crate) fn debug_instance_ip(subnet: &str, host_offset: i16) -> Option { + instance_ip_for_offset(subnet, host_offset) +} diff --git a/apps/api/src/modules/event/awd_team/api/mod.rs b/apps/api/src/modules/event/awd_team/api/mod.rs index 86d0b1a6..fab13883 100644 --- a/apps/api/src/modules/event/awd_team/api/mod.rs +++ b/apps/api/src/modules/event/awd_team/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod auth; pub mod dto; +pub mod gamebox_admin; pub mod internal; pub mod player; @@ -28,7 +29,16 @@ pub fn admin_routes(cfg: &mut web::ServiceConfig) { .service(admin::archive_event) .service(admin::rotate_tokens) .service(admin::update_network) - .service(admin::get_event_scores); + .service(admin::get_event_scores) + // GameBox 库 + 赛事 GameBox 管理(§46 术语:gamebox / revision / event_gamebox) + .service(gamebox_admin::list_gamebox_library) + .service(gamebox_admin::create_gamebox) + .service(gamebox_admin::edit_gamebox_revision) + .service(gamebox_admin::hide_gamebox) + .service(gamebox_admin::list_event_gameboxes) + .service(gamebox_admin::add_event_gamebox) + .service(gamebox_admin::update_event_gamebox) + .service(gamebox_admin::delete_event_gamebox); } /// Register AWD player routes under `/api`. diff --git a/apps/api/src/modules/event/awd_team/websocket.rs b/apps/api/src/modules/event/awd_team/websocket.rs index cbeb6568..159ac303 100644 --- a/apps/api/src/modules/event/awd_team/websocket.rs +++ b/apps/api/src/modules/event/awd_team/websocket.rs @@ -66,7 +66,7 @@ pub fn attack_success( event_id: Uuid, attacker_team_id: Uuid, victim_team_id: Uuid, - template_id: Uuid, + event_gamebox_id: Uuid, points: i64, ) -> AwdEvent { AwdEvent::new( @@ -75,7 +75,7 @@ pub fn attack_success( serde_json::json!({ "attacker_team_id": attacker_team_id, "victim_team_id": victim_team_id, - "template_id": template_id, + "event_gamebox_id": event_gamebox_id, "points": points, }), ) @@ -98,7 +98,7 @@ pub fn score_changed(event_id: Uuid, team_id: Uuid, new_total: i64, delta: i64) pub fn judge_result( event_id: Uuid, team_id: Uuid, - template_id: Uuid, + event_gamebox_id: Uuid, status: &str, duration_ms: Option, ) -> AwdEvent { @@ -107,7 +107,7 @@ pub fn judge_result( event_id, serde_json::json!({ "team_id": team_id, - "template_id": template_id, + "event_gamebox_id": event_gamebox_id, "status": status, "duration_ms": duration_ms, }), From 9fda0ee0cbf32cf8ab6e7d70032987bcc3d431db Mon Sep 17 00:00:00 2001 From: fb0sh Date: Sat, 8 Aug 2026 19:11:45 +0800 Subject: [PATCH 035/486] =?UTF-8?q?feat(web):=20AWD=20GameBox=20=E5=BA=93?= =?UTF-8?q?=E4=B8=8E=E8=B5=9B=E4=BA=8B=20GameBox=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=88=C2=A747=20=E6=9C=AF=E8=AF=AD?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新管理页 /admin/awd/gameboxes:全局 GameBox 库(创建 Revision 1、 编辑 → Revision N+1、hide 归档、digest pin 提示) - 赛事页新增 GameBoxes tab:EventGameBox 列表(host_offset/计分/资源)、 add(pin latest revision + 计分)、remove - api/awd.ts:AwdGameBox.template_id → event_gamebox_id + 8 个新 admin 函数 --- apps/web/src/api/awd.ts | 154 ++++++++- apps/web/src/routes.tsx | 5 + apps/web/src/routes/admin/awd/gameboxes.tsx | 297 ++++++++++++++++++ .../routes/admin/events/awd.$id/gameboxes.tsx | 232 ++++++++++++++ .../src/routes/admin/events/awd.$id/route.tsx | 6 + 5 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/routes/admin/awd/gameboxes.tsx create mode 100644 apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx diff --git a/apps/web/src/api/awd.ts b/apps/web/src/api/awd.ts index 04ef6f97..34dd1f88 100644 --- a/apps/web/src/api/awd.ts +++ b/apps/web/src/api/awd.ts @@ -8,13 +8,72 @@ import { type UniResponse, admin_api, service_api } from "@/api/axios"; export type AwdGameBox = { id: string; team_id: string; - template_id: string; + event_gamebox_id: string; status: string; gamebox_ip: string; container_name: string; health_status: string; }; +export type GameBoxRevisionDto = { + id: string; + revision_number: number; + image_ref: string; + image_digest: string | null; + username: string; + spec_digest: string; + created_at: string; +}; + +export type GameBoxLibraryDto = { + id: string; + name: string; + safe_name: string; + category: string; + description: string; + hidden: boolean; + latest_revision: GameBoxRevisionDto | null; +}; + +export type EventGameBoxDto = { + id: string; + gamebox_id: string; + gamebox_name: string; + gamebox_safe_name: string; + revision_id: string; + revision_number: number; + host_offset: number; + enabled: boolean; + hidden: boolean; + cpu_millis: number; + memory_bytes: number; + pids_limit: number; + judge_timeout_secs: number | null; + judge_retry_interval_secs: number | null; + break_points: number; + loss_points: number; + fix_points: number; + down_points: number; + first_bonus: number; + created_at: string; +}; + +export type GameBoxConfigPayload = { + source_toml?: string; + image_ref: string; + image_digest?: string | null; + username?: string; + cpu_millis?: number; + memory_bytes?: number; + pids_limit?: number; + healthcheck?: Record | null; + judge_script_name?: string | null; + judge_script_content?: string | null; + judge_args?: Record | null; + judge_timeout_secs?: number | null; + judge_retry_interval_secs?: number | null; +}; + export type AwdScoreRow = { team_id: string; team_name: string; @@ -116,6 +175,99 @@ export const awdAdminApi = { ); return res.data; }, + // ── GameBox 库(全局 identity + immutable Revision,§46)── + listGameboxes: async (): Promise> => { + const res = await admin_api.get(`/awd/gameboxes`); + return res.data; + }, + createGamebox: async ( + body: { + name: string; + safe_name?: string; + category?: string; + description?: string; + hidden?: boolean; + config: GameBoxConfigPayload; + }, + ): Promise> => { + const res = await admin_api.post(`/awd/gameboxes`, body); + return res.data; + }, + editGameboxRevision: async ( + gameboxId: string, + body: { config: GameBoxConfigPayload }, + ): Promise> => { + const res = await admin_api.post( + `/awd/gameboxes/${gameboxId}/revisions`, + body, + ); + return res.data; + }, + hideGamebox: async (gameboxId: string): Promise> => { + const res = await admin_api.post(`/awd/gameboxes/${gameboxId}/hide`); + return res.data; + }, + // ── 赛事 GameBox 选择(EventGameBox)── + listEventGameboxes: async ( + eventId: string, + ): Promise> => { + const res = await admin_api.get(`/events/${eventId}/awd/gameboxes`); + return res.data; + }, + addEventGamebox: async ( + eventId: string, + body: { + gamebox_id: string; + revision_id?: string; + host_offset?: number; + hidden?: boolean; + break_points?: number; + loss_points?: number; + fix_points?: number; + down_points?: number; + first_bonus?: number; + }, + ): Promise> => { + const res = await admin_api.post( + `/events/${eventId}/awd/gameboxes`, + body, + ); + return res.data; + }, + updateEventGamebox: async ( + eventId: string, + eventGameboxId: string, + body: { + revision_id?: string; + enabled?: boolean; + hidden?: boolean; + cpu_millis?: number; + memory_bytes?: number; + pids_limit?: number; + judge_timeout_secs?: number | null; + judge_retry_interval_secs?: number | null; + break_points?: number; + loss_points?: number; + fix_points?: number; + down_points?: number; + first_bonus?: number; + }, + ): Promise> => { + const res = await admin_api.patch( + `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, + body, + ); + return res.data; + }, + removeEventGamebox: async ( + eventId: string, + eventGameboxId: string, + ): Promise> => { + const res = await admin_api.delete( + `/events/${eventId}/awd/gameboxes/${eventGameboxId}`, + ); + return res.data; + }, }; /** Player AWD endpoints (User JWT). */ diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index c944d009..69d246cb 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -56,6 +56,11 @@ export const admin_routes: NavRoute[] = [ path: "/admin/events", icon: , }, + { + label: "AWD GameBoxes", + path: "/admin/awd/gameboxes", + icon: , + }, { label: "Announcements", path: "/admin/announcements", diff --git a/apps/web/src/routes/admin/awd/gameboxes.tsx b/apps/web/src/routes/admin/awd/gameboxes.tsx new file mode 100644 index 00000000..1528b113 --- /dev/null +++ b/apps/web/src/routes/admin/awd/gameboxes.tsx @@ -0,0 +1,297 @@ +import { PencilIcon, PlusIcon } from "@primer/octicons-react"; +import { Button, Dialog, Spinner, TextInput } from "@primer/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; + +import { adminApi } from "@/api"; +import type { GameBoxConfigPayload, GameBoxLibraryDto } from "@/api/awd"; +import { useMsgBanner } from "@/components"; +import { AdminRouteGuard } from "../route"; + +export const Route = createFileRoute("/admin/awd/gameboxes")({ + component: RouteComponent, + loader: AdminRouteGuard, +}); + +const EMPTY_CONFIG = { + source_toml: "", + image_ref: "", + image_digest: null, + username: "ctf", + cpu_millis: 1000, + memory_bytes: 512 * 1024 * 1024, + pids_limit: 100, + healthcheck: null, + judge_script_name: null, + judge_script_content: null, + judge_args: null, + judge_timeout_secs: null, + judge_retry_interval_secs: null, +}; + +function RouteComponent() { + const queryClient = useQueryClient(); + const banner = useMsgBanner({}); + const [createOpen, setCreateOpen] = useState(false); + const [editId, setEditId] = useState(null); + + const q = useQuery({ + queryKey: ["admin-awd-gamebox-library"], + queryFn: () => adminApi.awd.listGameboxes(), + }); + const onDone = () => { + queryClient.invalidateQueries({ queryKey: ["admin-awd-gamebox-library"] }); + }; + + const hide = useMutation({ + mutationFn: (gameboxId: string) => adminApi.awd.hideGamebox(gameboxId), + onSuccess: onDone, + onError: banner.showErrorBanner, + }); + + const items = q.data?.data ?? []; + const editing = editId ? items.find((g) => g.id === editId) : null; + + return ( +
+ +
+

AWD GameBox Library(GameBox = AWD 题目长期身份)

+ +
+ {q.isLoading ? ( + + ) : ( + + + + + + + + + + + + + {items.map((g: GameBoxLibraryDto) => ( + + + + + + + + + ))} + +
Namesafe_nameLatest RevisionImageHidden
{g.name}{g.safe_name} + {g.latest_revision + ? `rev ${g.latest_revision.revision_number} · ${g.latest_revision.username}@${g.latest_revision.image_ref}` + : "(none)"} + + {g.latest_revision?.image_digest + ? g.latest_revision.image_ref.slice(0, 40) + : "digest 未 pin"} + {g.hidden ? "yes" : "no"} + + +
+ )} + {createOpen && ( + setCreateOpen(false)} + onDone={() => { + setCreateOpen(false); + onDone(); + }} + /> + )} + {editing && ( + setEditId(null)} + onDone={() => { + setEditId(null); + onDone(); + }} + /> + )} +
+ ); +} + +function GameBoxFormDialog({ + title, + gameboxId, + initialName, + onClose, + onDone, +}: { + title: string; + gameboxId?: string; + initialName?: string; + onClose: () => void; + onDone: () => void; +}) { + const banner = useMsgBanner({}); + const [name, setName] = useState(initialName ?? ""); + const [config, setConfig] = useState({ ...EMPTY_CONFIG }); + + const create = useMutation({ + mutationFn: (body: { + name: string; + config: GameBoxConfigPayload; + }) => adminApi.awd.createGamebox(body), + onSuccess: onDone, + onError: banner.showErrorBanner, + }); + const edit = useMutation({ + mutationFn: (body: { config: GameBoxConfigPayload }) => + adminApi.awd.editGameboxRevision(gameboxId!, body), + onSuccess: onDone, + onError: banner.showErrorBanner, + }); + + const submit = () => { + if (gameboxId) { + edit.mutate({ config }); + } else { + create.mutate({ name, config }); + } + }; + + const setC = (k: string, v: unknown) => + setConfig((c) => ({ ...c, [k]: v })); + + return ( + + +
+ {!gameboxId && ( + <> + + setName(e.target.value)} + /> + + )} + + setC("image_ref", e.target.value)} + placeholder="registry.example.com/easy-web:v1" + /> + + setC("image_digest", e.target.value || null)} + placeholder="sha256:(可选)" + /> + + setC("username", e.target.value)} + /> + +
+ setC("cpu_millis", Number(e.target.value))} + /> + setC("memory_bytes", Number(e.target.value))} + /> + setC("pids_limit", Number(e.target.value))} + /> +
+ + setC("judge_script_name", e.target.value || null)} + placeholder="script name" + /> +