impl App { pub fn start_dns_acl_updates(self: &Arc) { self.tunnels.spawn(crate::acl::dynamic::poll_loop( Arc::downgrade(self), self.shutdown.clone(), )); } pub fn start_geoip_updates(self: &Arc) { self.tunnels.spawn(crate::geoip::update_poll_loop( Arc::downgrade(self), self.shutdown.clone(), )); } pub fn start_update_check(self: &Arc, config: crate::update_check::Config) { self.software_update.configure(&config); self.tunnels.spawn(crate::update_check::poll_loop( self.software_update.clone(), config, self.shutdown.clone(), )); } /// Drain detached writers after the listeners and their handlers have stopped. pub async fn drain_updates(&self) { let _all_slots = self.update_slots.clone().acquire_many_owned(8).await; } /// The detached task completes persistence + publication even if the HTTP caller disconnects. pub async fn update(self: &Arc, update: Update) -> Result { self.update_as(update, "local".into()).await } pub async fn update_as( self: &Arc, update: Update, actor: String, ) -> Result { self.update_as_action(update, actor, None).await } pub async fn restore_as( self: &Arc, update: Update, actor: String, source_revision: u64, ) -> Result { self.update_as_action(update, actor, Some(source_revision)) .await } async fn update_as_action( self: &Arc, update: Update, actor: String, source_revision: Option, ) -> Result { let slot = self.update_slots.clone().try_acquire_owned().map_err(|_| { ApiError( StatusCode::TOO_MANY_REQUESTS, "configuration update queue is full".into(), ) })?; let expected_revision = update.expected_revision; let revision = expected_revision .checked_add(1) .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "revision overflow".into()))?; let policy = self.policy.clone(); let defaults = self.startup_listeners; let previous = self.config.load_full(); let previous_config = previous.envelope.config.clone(); let active_listeners = self.active_listeners.clone(); let observed = self.docker.snapshot().await; let desired_vhost_count = update.config.vhosts.len(); let desired_config = update.config; self.startup_locks .validate(&desired_config, self.policy.admin_addr) .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; crate::tls::validate_certificate_removals(&previous_config, &desired_config) .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; let (prepared, invalidation, slot) = tokio::task::spawn_blocking(move || { let effective = crate::docker::effective_config(desired_config, &observed)?; let mut prepared = config::compile_with_active( effective, revision, &policy, defaults, Some(&previous), &active_listeners, )?; prepared .envelope .config .vhosts .truncate(desired_vhost_count); let invalidation = cache_invalidation(&previous_config, &prepared.envelope.config); Ok::<_, anyhow::Error>((prepared, invalidation, slot)) }) .await .map_err(|_| { ApiError( StatusCode::INTERNAL_SERVER_ERROR, "configuration validation task failed".into(), ) })? .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?; let this = self.clone(); tokio::spawn(async move { let _slot = slot; let _lock = this.update_lock.lock().await; if this.config.load().envelope.revision != expected_revision { return Err(ApiError( StatusCode::CONFLICT, "configuration changed; reload before saving".into(), )); } let persisted = match source_revision { Some(source) => { this.store .replace_as_restore( expected_revision, &prepared.envelope.config, &actor, source, ) .await } None => { this.store .replace_as(expected_revision, &prepared.envelope.config, &actor) .await } }; match persisted { Ok(true) => {} Ok(false) => { return Err(ApiError( StatusCode::CONFLICT, "stored revision differs; external SQLite writes are unsupported".into(), )) } Err(error) => { tracing::error!(%error, "configuration persistence failed"); return Err(ApiError( StatusCode::INTERNAL_SERVER_ERROR, "configuration was not saved".into(), )); } } // No await between successful commit and publication. Cache identity is // intentionally decoupled from the configuration revision, so unrelated // control-plane saves do not invalidate hot/cold objects. let envelope = prepared.envelope.clone(); // After commit, reject late cache tickets from the old routing // snapshot before any scope purge. Existing unaffected objects stay. this.cache.set_control_revision(envelope.revision); match invalidation { CacheInvalidation::All(cache_revision) => { this.cache.purge(Some(cache_revision)); } CacheInvalidation::Scopes(scopes) => { for scope in scopes { this.cache.purge_scope(&scope); } } CacheInvalidation::None => {} } // A COLD entry is usable after restart only if its scope still has // the same origin/content policy. Publish the fingerprints after // the cache generation barriers but before the routing snapshot. this.cache.replace_scope_revisions(vhost_cache_revisions(&envelope.config)); this.cache.configure(envelope.config.cache.clone()); this.rate_limiter.reset(); this.logs.configure(envelope.config.logging.clone()); crate::logging::configure_error_log(envelope.config.error_log.clone()); this.config.store(Arc::new(prepared)); crate::acl::dynamic::config_changed(); tracing::info!(revision, "configuration applied"); Ok(envelope) }) .await .map_err(|_| { ApiError( StatusCode::INTERNAL_SERVER_ERROR, "configuration task failed".into(), ) })? } }