crates/gunnar-ui-server/src/console_setting.rs
//! **A console switch kept as a generic gunnar setting** (owner decision 2026-10-26, item 5:
//! "a switch in admin, account admin only, stored as a generic server setting, audited").
//!
//! Compiled only into a build that names `public-site` or `mcp`: a default (airgap) build has
//! neither feature, so it carries no switch, no route and none of this file.
//!
//! The switch is gunnar's generic setting (`Admin.GetSetting` / `Admin.SetSetting`) under a
//! name this console chooses ([`PUBLIC_SITE`], [`MCP`]); gunnar knows no setting name and stores
//! bytes. The value is `on` or `off`. Each switch's environment variable is its INITIAL value:
//! it holds until an administrator first flips the switch, and from then on the stored setting
//! wins, also across a console restart ([`follow`]).
//!
//! **The stored position is the authority, and the switch fails closed** (RA2-1): until gunnar
//! has answered what it stores, the switch is OFF whatever the environment says, and a value
//! that is neither `on` nor `off` is OFF too, said as an ALERT. The console reads the stored
//! position again every [`RECHECK`] for its whole life, so a flip made elsewhere (another
//! console, a direct `Admin.SetSetting`) is seen with no restart. Once read, a gunnar that does
//! not answer for a while leaves the last stored position in place.
//!
//! Who may flip it: an administrator of this console ([`may_flip`]); anyone else is refused
//! with 403. Every flip is audited twice: here, on the console's audit target, naming the
//! person ([`audit`]), and at gunnar, naming the console's principal and old → new.
use std::time::Duration;
use crate::app::AppState;
use crate::auth::middleware::Caller;
use crate::pb;
use crate::role::Role;
/// The public site's switch (`public-site` builds).
#[cfg(feature = "public-site")]
pub const PUBLIC_SITE: &str = "console.public-site";
/// The MCP connector's switch (`mcp` builds).
#[cfg(feature = "mcp")]
pub const MCP: &str = "console.mcp";
/// IndexNow's key (`public-site` builds, A32-16): a VALUE setting, the key's bytes; empty
/// stored is OFF. Its environment key is only the initial value, as a switch's is.
#[cfg(feature = "public-site")]
pub const INDEXNOW: &str = "console.public-site.indexnow";
// The rules, once for both consoles (RA2-15): the stored bytes, what gunnar holds, the
// fail-closed position, and the follower's steps.
pub use nordisk_console_switch::{effective, position, stored, stored_of, Stored, READ_DEADLINE, RECHECK};
/// Pure: **may this caller flip a console switch**: an administrator of this console.
pub fn may_flip(caller: Option<&Caller>) -> bool {
caller.is_some_and(|c| c.role().allows(Role::Admin))
}
/// Who flipped it, for the audit line: the session's person, or `admin-token`.
pub fn who(caller: Option<&Caller>) -> String {
caller.map(Caller::access_name).unwrap_or_else(|| "nobody".to_owned())
}
/// **What gunnar stores under `name`**.
pub async fn read(state: &AppState, name: &str) -> Result<Stored, String> {
let answer = state.gunnar.admin().get_setting(pb::GetSettingRequest { name: name.to_owned() }).await.map_err(|s| s.message().to_owned())?;
let a = answer.into_inner();
Ok(stored_of(a.present, &a.value))
}
/// **Store `on` under `name`** at gunnar: the position it replaced, `None` where none was.
pub async fn write(state: &AppState, name: &str, on: bool) -> Result<Option<bool>, String> {
let answer = state
.gunnar
.admin()
.set_setting(pb::SetSettingRequest { name: name.to_owned(), value: stored(on).to_vec() })
.await
.map_err(|s| s.message().to_owned())?;
let a = answer.into_inner();
Ok(if a.had { position(&a.previous) } else { None })
}
/// **What gunnar stores under a value setting's `name`**: nothing (`None`), or its bytes.
#[cfg(feature = "public-site")]
pub async fn read_value(state: &AppState, name: &str) -> Result<Option<Vec<u8>>, String> {
let answer = state.gunnar.admin().get_setting(pb::GetSettingRequest { name: name.to_owned() }).await.map_err(|s| s.message().to_owned())?;
let a = answer.into_inner();
Ok(a.present.then_some(a.value))
}
/// **Store `value` under a value setting's `name`** at gunnar: the bytes it replaced, `None`
/// where none were.
#[cfg(feature = "public-site")]
pub async fn write_value(state: &AppState, name: &str, value: &[u8]) -> Result<Option<Vec<u8>>, String> {
let answer = state
.gunnar
.admin()
.set_setting(pb::SetSettingRequest { name: name.to_owned(), value: value.to_vec() })
.await
.map_err(|s| s.message().to_owned())?;
let a = answer.into_inner();
Ok(a.had.then_some(a.previous))
}
/// The console's own audit line of a flip.
pub fn audit(name: &str, by: &str, before: Option<bool>, on: bool) {
let before = match before {
Some(true) => "on",
Some(false) => "off",
None => "(unset)",
};
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "console.switch", name, by, before, on, "a console switch was flipped");
}
/// How often the stored position is read again (tests shorten it).
static RECHECK_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(RECHECK.as_millis() as u64);
/// Tests only: read the stored position again every `every`.
#[doc(hidden)]
pub fn recheck_every(every: Duration) {
RECHECK_MS.store(u64::try_from(every.as_millis()).unwrap_or(u64::MAX).max(1), std::sync::atomic::Ordering::SeqCst);
}
fn recheck() -> Duration {
Duration::from_millis(RECHECK_MS.load(std::sync::atomic::Ordering::SeqCst))
}
/// **Follow the stored position of `name`** for the life of the console: the switch shows
/// [`effective`] of `initial` (the environment's) and what gunnar stores, through `apply`. The
/// switch must be constructed OFF: nothing turns it on before gunnar has answered.
pub fn follow(state: &AppState, name: &'static str, initial: bool, apply: impl Fn(bool) + Send + 'static) {
// A router built outside a runtime (a synchronous test) asks nothing, so its switch stays
// OFF.
let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
let state = state.clone();
runtime.spawn(follow_with(
name,
initial,
move || {
let state = state.clone();
async move { read(&state, name).await }
},
apply,
));
}
/// **Follow a value setting's stored bytes** for the life of the console (A32-16, IndexNow's
/// key), by the same rules as a switch: `show` turns what was last read (`None`: nothing yet;
/// `Some(None)`: nothing stored) into what the console applies, and `damaged` names bytes it
/// cannot use (said once as an ALERT).
#[cfg(feature = "public-site")]
pub fn follow_value<T>(state: &AppState, name: &'static str, damaged: fn(&Option<Vec<u8>>) -> bool, show: impl Fn(Option<&Option<Vec<u8>>>) -> T + Send + 'static, apply: impl Fn(T) + Send + 'static)
where
T: Clone + PartialEq + std::fmt::Debug + Send + 'static,
{
let Ok(runtime) = tokio::runtime::Handle::try_current() else { return };
let state = state.clone();
runtime.spawn(follow_stored(
name,
move || {
let state = state.clone();
async move { read_value(&state, name).await }
},
damaged,
show,
apply,
));
}
/// [`follow`] over any reader (the tests' own). Never returns.
pub async fn follow_with<R, F>(name: &'static str, initial: bool, read: R, apply: impl Fn(bool))
where
R: Fn() -> F,
F: std::future::Future<Output = Result<Stored, String>>,
{
follow_stored(name, read, |s| *s == Stored::Damaged, move |last| effective(initial, last.copied()), apply).await
}
/// **The one follower** of a stored setting, a switch's or a value's: read it every
/// [`RECHECK`] (each read within [`READ_DEADLINE`]), keep the last answer through a gunnar that
/// does not answer, and apply `show` of it whenever that changes. Never returns.
pub async fn follow_stored<S, T, R, F>(name: &'static str, read: R, damaged: impl Fn(&S) -> bool, show: impl Fn(Option<&S>) -> T, apply: impl Fn(T))
where
R: Fn() -> F,
F: std::future::Future<Output = Result<S, String>>,
S: Clone + PartialEq + std::fmt::Debug,
T: Clone + PartialEq + std::fmt::Debug,
{
let mut follow = nordisk_console_switch::Follow::<S>::default();
let mut shown: Option<T> = None;
loop {
let flips = flips_of(name);
let answer = match tokio::time::timeout(READ_DEADLINE, read()).await {
Ok(answer) => answer,
Err(_) => Err(format!("Gunnar did not answer within {READ_DEADLINE:?}")),
};
// A flip on this console while the read was on its way: what was read is older than it.
let older = flips != flips_of(name);
let step = follow.answered(answer, older, &damaged, recheck());
for said in &step.said {
use nordisk_console_switch::Said;
match said {
Said::Damaged => tracing::error!(name, "ALERT [console-switch-damaged] Gunnar stores a value this console cannot use for this setting (a switch's neither `on` nor `off`): it is OFF until an administrator sets it"),
Said::Unread(why) => tracing::error!(name, %why, "ALERT [console-switch-unread] the console switch's stored position could not be read: it stays OFF until it is"),
Said::Kept(why) => tracing::warn!(name, %why, "the console switch's stored position could not be read again: it keeps the last one read"),
Said::Again(failures) => tracing::info!(name, failures, "the console switch's stored position is read again"),
}
}
if !older {
let now = show(follow.last());
if shown.as_ref() != Some(&now) {
apply(now.clone());
tracing::info!(name, now = ?now, stored = ?follow.last(), "the console setting's position");
shown = Some(now);
}
}
let pause = step.pause;
tokio::time::sleep(pause).await;
}
}
/// The flips made on this console, per switch name.
fn flips() -> &'static std::sync::Mutex<std::collections::BTreeMap<String, u64>> {
static F: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeMap<String, u64>>> = std::sync::OnceLock::new();
F.get_or_init(Default::default)
}
fn flips_of(name: &str) -> u64 {
flips().lock().unwrap_or_else(|p| p.into_inner()).get(name).copied().unwrap_or(0)
}
/// **A flip made on this console**, said once gunnar has stored it: a read that was on its way
/// when it landed is not applied over it.
pub fn flipped(name: &str) {
*flips().lock().unwrap_or_else(|p| p.into_inner()).entry(name.to_owned()).or_default() += 1;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// ★ Each flip is audited on the console's audit target: the switch, who, before and after.
#[test]
fn a_flip_is_audited_with_who_and_before_and_after() {
use std::sync::{Arc, Mutex};
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct Lines(Arc<Mutex<Vec<String>>>);
struct Capture(Lines);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
if event.metadata().target() != crate::auth::AUDIT_TARGET {
return;
}
struct Visit(String);
impl tracing::field::Visit for Visit {
fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
self.0.push_str(&format!("{}={:?} ", f.name(), v));
}
}
let mut v = Visit(String::new());
event.record(&mut v);
self.0 .0.lock().unwrap().push(v.0);
}
}
let lines = Lines::default();
let subscriber = tracing_subscriber::registry().with(Capture(lines.clone()));
tracing::subscriber::with_default(subscriber, || {
audit("console.x", "kim", None, true);
audit("console.x", "kim", Some(true), false);
});
let got = lines.0.lock().unwrap().clone();
assert_eq!(got.len(), 2, "{got:?}");
for (line, before, on) in [(&got[0], "(unset)", "true"), (&got[1], "on", "false")] {
assert!(line.contains("event=\"console.switch\"") && line.contains("name=\"console.x\"") && line.contains("by=\"kim\""), "{line}");
assert!(line.contains(&format!("before=\"{before}\"")) && line.contains(&format!("on={on}")), "{line}");
}
}
#[test]
fn only_an_administrator_flips_a_switch() {
assert!(may_flip(Some(&Caller::AdminToken)));
assert!(!may_flip(None));
assert_eq!(who(Some(&Caller::AdminToken)), "admin-token");
assert_eq!(who(None), "nobody");
}
/// A reader whose answers are scripted: each call takes the next, the last one repeats.
fn scripted(answers: Vec<Result<Stored, String>>) -> (impl Fn() -> std::future::Ready<Result<Stored, String>>, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let n = asked.clone();
let read = move || {
let i = n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::future::ready(answers[i.min(answers.len() - 1)].clone())
};
(read, asked)
}
/// ★ RA2-15: the rules are the shared crate's, not a copy here (its tests hold them).
#[test]
fn the_switch_rules_are_the_shared_crates() {
let stored: nordisk_console_switch::Stored = stored_of(true, b"on");
assert_eq!(stored, Stored::At(true));
assert!(!effective(true, None));
}
/// ★ RA2-1: **a slow gunnar never lets the environment's ON win, and the console keeps
/// reading.** gunnar does not answer for longer than the old 5 tries (about 6 s), then holds
/// an administrator's OFF; later a flip made elsewhere turns it ON. The switch is never on
/// before the stored position is read, and the flip elsewhere is seen.
#[tokio::test(start_paused = true)]
async fn a_slow_start_keeps_the_switch_off_and_a_flip_elsewhere_is_seen() {
let mut answers: Vec<Result<Stored, String>> = (0..8).map(|_| Err("Gunnar is starting".to_owned())).collect();
answers.extend([Ok(Stored::At(false)), Ok(Stored::At(false)), Ok(Stored::At(true))]);
let (read, asked) = scripted(answers);
let shown = std::sync::Arc::new(Mutex::new(Vec::<bool>::new()));
let s = shown.clone();
let follow = tokio::spawn(follow_with("console.test-slow", true, read, move |on| s.lock().unwrap().push(on)));
tokio::time::sleep(Duration::from_secs(600)).await;
follow.abort();
let shown = shown.lock().unwrap().clone();
assert!(asked.load(std::sync::atomic::Ordering::SeqCst) >= 11, "the console stopped reading: {} asks", asked.load(std::sync::atomic::Ordering::SeqCst));
assert_eq!(shown.first(), Some(&false), "the first position shown is the stored OFF, never the environment's ON: {shown:?}");
assert_eq!(shown.last(), Some(&true), "a flip made elsewhere is seen: {shown:?}");
}
/// ★ RA2-1: a damaged stored value turns the switch OFF, and it is said as an ALERT.
#[tokio::test(start_paused = true)]
async fn a_damaged_value_turns_the_switch_off() {
let (read, _) = scripted(vec![Ok(Stored::At(true)), Ok(Stored::Damaged)]);
let shown = std::sync::Arc::new(Mutex::new(Vec::<bool>::new()));
let s = shown.clone();
let follow = tokio::spawn(follow_with("console.test-damaged", true, read, move |on| s.lock().unwrap().push(on)));
tokio::time::sleep(Duration::from_secs(120)).await;
follow.abort();
assert_eq!(shown.lock().unwrap().clone(), vec![true, false]);
}
/// ★ A read that was on its way when this console's own flip landed is not applied over it.
#[tokio::test(start_paused = true)]
async fn a_read_older_than_a_flip_is_not_applied() {
let name = "console.test-race";
let shown = std::sync::Arc::new(Mutex::new(Vec::<bool>::new()));
let s = shown.clone();
// The read starts, the console flips meanwhile, the read answers the old position.
let read = move || async move {
flipped(name);
Ok(Stored::At(false))
};
let follow = tokio::spawn(follow_with(name, true, read, move |on| s.lock().unwrap().push(on)));
tokio::time::sleep(Duration::from_secs(60)).await;
follow.abort();
assert!(shown.lock().unwrap().is_empty(), "a read older than a flip was applied: {:?}", shown.lock().unwrap());
}
}