Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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());
    }
}