Skip to main content

hoike_server/
state.rs

1use hoike_core::ResponderState;
2use hoike_core::config::Config;
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Instant;
6use tokio::sync::Mutex;
7
8#[derive(Clone)]
9pub struct AppState {
10    pub responder: Arc<ResponderState>,
11    pub live_signers: Arc<std::sync::RwLock<HashMap<String, Arc<LiveSignerState>>>>,
12    pub signer: Option<Arc<SignerContext>>,
13    /// Handle to the running gossip node, when this process participates in the
14    /// SWIM mesh. `None` when gossip is disabled — admin fleet endpoints then
15    /// report an empty/disabled roster instead of members and generations.
16    pub gossip: Option<Arc<hoike_gossip::GossipNode>>,
17    pub admin: Arc<AdminState>,
18}
19
20pub struct LiveSignerState {
21    pub signer: Mutex<p256::ecdsa::SigningKey>,
22    pub responder_key_bytes: Vec<u8>,
23    pub validity_secs: u64,
24    pub responder_cert_der: Option<Vec<u8>>,
25}
26
27/// Shared on-demand signing context. Holds the persistent revocation sources
28/// (DogtagSync retains a sync cookie — must be shared, not rebuilt each pass)
29/// behind a mutex that serializes on-demand signing (admin API) against the
30/// background signer loop, so epoch derivation (from the state store) and
31/// `{label}.ahu` writes never interleave.
32pub struct SignerContext {
33    pub sources: Mutex<hoike_sign::PersistentSources>,
34}
35
36pub struct AdminState {
37    pub config: Config,
38    pub started_at: Instant,
39    pub sessions: Mutex<HashMap<String, Session>>,
40}
41
42#[derive(Clone)]
43pub struct Session {
44    pub operator_name: String,
45    pub role: OperatorRole,
46    pub expires_at: Instant,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum OperatorRole {
52    Administrator,
53    Operator,
54    Viewer,
55}
56
57impl OperatorRole {
58    pub fn rank(self) -> u8 {
59        match self {
60            Self::Viewer => 1,
61            Self::Operator => 2,
62            Self::Administrator => 3,
63        }
64    }
65
66    pub fn has_at_least(self, min: Self) -> bool {
67        self.rank() >= min.rank()
68    }
69}
70
71impl std::str::FromStr for OperatorRole {
72    type Err = String;
73    fn from_str(s: &str) -> Result<Self, Self::Err> {
74        match s {
75            "administrator" => Ok(Self::Administrator),
76            "operator" => Ok(Self::Operator),
77            "viewer" => Ok(Self::Viewer),
78            other => Err(format!("unknown role: {other}")),
79        }
80    }
81}
82
83impl std::fmt::Display for OperatorRole {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::Administrator => write!(f, "administrator"),
87            Self::Operator => write!(f, "operator"),
88            Self::Viewer => write!(f, "viewer"),
89        }
90    }
91}
92
93impl AppState {
94    pub fn new(responder: ResponderState, config: Config) -> Self {
95        AppState {
96            responder: Arc::new(responder),
97            live_signers: Arc::new(std::sync::RwLock::new(HashMap::new())),
98            signer: None,
99            gossip: None,
100            admin: Arc::new(AdminState {
101                config,
102                started_at: Instant::now(),
103                sessions: Mutex::new(HashMap::new()),
104            }),
105        }
106    }
107
108    /// Single-CA compatibility builder; never provides a cross-CA fallback.
109    pub fn with_live_signer(self, live: LiveSignerState) -> Self {
110        let labels: Vec<_> = self
111            .admin
112            .config
113            .ca
114            .iter()
115            .filter(|ca| ca.nonce_policy == "live")
116            .map(|ca| ca.label.clone())
117            .collect();
118        assert_eq!(
119            labels.len(),
120            1,
121            "with_live_signer requires exactly one live CA"
122        );
123        self.with_live_signer_for(&labels[0], live)
124    }
125
126    pub fn with_live_signer_for(self, label: &str, live: LiveSignerState) -> Self {
127        self.live_signers
128            .write()
129            .expect("live signer lock poisoned")
130            .insert(label.into(), Arc::new(live));
131        self
132    }
133
134    pub fn live_signer_for(&self, label: &str) -> Option<Arc<LiveSignerState>> {
135        self.live_signers.read().ok()?.get(label).cloned()
136    }
137
138    pub fn reload_live_signer(&self, ca: &hoike_core::config::CaConfig) -> Result<(), String> {
139        if ca.nonce_policy != "live" {
140            return Ok(());
141        }
142        let live = LiveSignerState::from_config(ca)?;
143        self.live_signers
144            .write()
145            .map_err(|e| e.to_string())?
146            .insert(ca.label.clone(), Arc::new(live));
147        Ok(())
148    }
149
150    /// Attach the shared on-demand signing context. The returned `Arc` is also
151    /// cloned into the background signer loop so both paths share one mutex.
152    pub fn with_signer_context(mut self, ctx: SignerContext) -> Self {
153        self.signer = Some(Arc::new(ctx));
154        self
155    }
156
157    /// Attach a handle to the running gossip node so admin fleet endpoints can
158    /// read membership and the generation table, and so the signer path can
159    /// announce new generations.
160    pub fn with_gossip(mut self, gossip: Arc<hoike_gossip::GossipNode>) -> Self {
161        self.gossip = Some(gossip);
162        self
163    }
164}
165
166impl LiveSignerState {
167    pub fn from_config(ca: &hoike_core::config::CaConfig) -> Result<Self, String> {
168        let material = hoike_sign::live::load_live_material(ca)?;
169        Ok(Self {
170            signer: Mutex::new(material.key),
171            responder_key_bytes: material.responder_key_bytes,
172            validity_secs: ca.validity_secs,
173            responder_cert_der: material.responder_cert_der,
174        })
175    }
176}