Skip to main content

melin_server_runtime/
admin.rs

1//! Operator admin endpoint — single TCP listener for all server-side
2//! commands an exchange operator may send.
3//!
4//! Authentication is Ed25519 challenge-response with operator-only keys
5//! (the same handshake used for trading sessions). After auth, the
6//! client sends one ASCII command terminated by `\n`:
7//!
8//! - `PROMOTE` — replica → primary leadership transition. Sets the
9//!   shared promote flag; the replica receive loop observes it and
10//!   exits with the recovered state. Available only when the spawn
11//!   caller wired a promote flag (typically the replica path).
12//! - `ROTATE` — archive the current journal segment at the next fsync
13//!   boundary and start a fresh one. Available only when the spawn
14//!   caller wired a rotation flag (any node with `--max-journal-mib >
15//!   0` or runtime rotation enabled).
16//! - `DURABILITY <local|hybrid|durably-replicated>` — atomically swap
17//!   the active durability mode on a node running a response stage
18//!   (primary, or post-promotion replica). Lets an operator resume
19//!   trading at reduced durability immediately after a promotion (no
20//!   restart, no client reconnects) and restore the target mode once
21//!   replicas reattach. Available only when the spawn caller wired the
22//!   shared mode atomic.
23//!
24//! A command for which the corresponding flag is `None` is rejected
25//! with `ERR <command> not available on this node\n` so operators get
26//! a clear diagnostic instead of a silent no-op.
27//!
28//! The listener stays alive for the lifetime of the process — multiple
29//! ROTATEs over a long run, and an eventual PROMOTE on a replica, all
30//! flow through the same socket. Concurrent or repeated triggers
31//! collapse via CAS in the journal stage / receive loop, so duplicate
32//! commands do not queue.
33
34use std::io::{BufRead, BufReader, Write};
35use std::net::{SocketAddr, TcpListener, TcpStream};
36use std::sync::Arc;
37use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
38use std::thread::JoinHandle;
39use std::time::Duration;
40
41use crate::durability_policy::DurabilityMode;
42
43use ed25519_dalek::{Verifier, VerifyingKey};
44use tracing::{debug, error, info};
45
46use melin_app::auth::{AuthorizedKeys, Permission};
47use melin_wire_protocol::control::TransportResponse;
48use melin_wire_protocol::control_codec;
49
50/// Spawn the admin listener on a dedicated thread.
51///
52/// Any of `promote` / `rotate_requested` / `durability_mode` may be
53/// `None` to disable the corresponding command on this node. The
54/// listener still accepts connections and authenticates them — a
55/// disabled command is rejected at the command-dispatch step, not at
56/// connect time, so operator tooling sees a structured ERR rather than
57/// a TCP RST.
58pub fn spawn(
59    bind_addr: SocketAddr,
60    promote: Option<Arc<AtomicBool>>,
61    rotate_requested: Option<Arc<AtomicBool>>,
62    durability_mode: Option<Arc<AtomicU8>>,
63    shutdown: Arc<AtomicBool>,
64    authorized_keys: Arc<AuthorizedKeys>,
65) -> JoinHandle<()> {
66    std::thread::Builder::new()
67        .name("admin-listener".into())
68        .spawn(move || {
69            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
70                run(
71                    bind_addr,
72                    promote.as_deref(),
73                    rotate_requested.as_deref(),
74                    durability_mode.as_deref(),
75                    &shutdown,
76                    &authorized_keys,
77                )
78            }));
79            if let Err(panic) = result {
80                let msg = panic_message(&panic);
81                error!(addr = %bind_addr, panic = %msg, "admin listener thread panicked");
82            }
83        })
84        .expect("failed to spawn admin listener thread")
85}
86
87fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
88    if let Some(s) = payload.downcast_ref::<&'static str>() {
89        (*s).to_string()
90    } else if let Some(s) = payload.downcast_ref::<String>() {
91        s.clone()
92    } else {
93        "<non-string panic payload>".to_string()
94    }
95}
96
97fn run(
98    bind_addr: SocketAddr,
99    promote: Option<&AtomicBool>,
100    rotate_requested: Option<&AtomicBool>,
101    durability_mode: Option<&AtomicU8>,
102    shutdown: &AtomicBool,
103    authorized_keys: &AuthorizedKeys,
104) {
105    let listener = match TcpListener::bind(bind_addr) {
106        Ok(l) => l,
107        Err(e) => {
108            error!(addr = %bind_addr, error = %e, "admin listener bind failed");
109            return;
110        }
111    };
112    listener
113        .set_nonblocking(true)
114        .expect("set admin listener nonblocking");
115
116    info!(
117        addr = %bind_addr,
118        promote_enabled = promote.is_some(),
119        rotate_enabled = rotate_requested.is_some(),
120        durability_enabled = durability_mode.is_some(),
121        "admin listener started"
122    );
123
124    loop {
125        if shutdown.load(Ordering::Relaxed) {
126            return;
127        }
128
129        match listener.accept() {
130            Ok((stream, peer)) => {
131                debug!(peer = %peer, "admin connection accepted");
132                handle_connection(
133                    stream,
134                    promote,
135                    rotate_requested,
136                    durability_mode,
137                    authorized_keys,
138                );
139            }
140            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
141                std::thread::sleep(Duration::from_millis(100));
142            }
143            Err(e) => {
144                debug!(error = %e, "admin listener accept error");
145                std::thread::sleep(Duration::from_millis(100));
146            }
147        }
148    }
149}
150
151/// Authenticate a connection via Ed25519 challenge-response. Operator
152/// keys only. Returns `Ok(())` on success, `Err(reason)` otherwise; the
153/// caller has already sent an `AuthFailed` response on the error path.
154fn authenticate(stream: &mut TcpStream, authorized_keys: &AuthorizedKeys) -> Result<(), String> {
155    let mut nonce = [0u8; 32];
156    getrandom::fill(&mut nonce).map_err(|e| format!("getrandom failed: {e}"))?;
157
158    let mut buf = [0u8; 128];
159    let written =
160        control_codec::encode_transport_response(&TransportResponse::Challenge { nonce }, &mut buf)
161            .map_err(|e| format!("encode Challenge: {e}"))?;
162    stream
163        .write_all(&buf[..written])
164        .map_err(|e| format!("send Challenge: {e}"))?;
165    stream
166        .flush()
167        .map_err(|e| format!("flush Challenge: {e}"))?;
168
169    let mut len_buf = [0u8; 4];
170    std::io::Read::read_exact(stream, &mut len_buf)
171        .map_err(|e| format!("read auth frame length: {e}"))?;
172    let frame_len = u32::from_le_bytes(len_buf) as usize;
173    if frame_len > 256 {
174        send_auth_failed(stream);
175        return Err(format!("auth frame too large: {frame_len}"));
176    }
177    let mut frame_buf = [0u8; 256];
178    std::io::Read::read_exact(stream, &mut frame_buf[..frame_len])
179        .map_err(|e| format!("read auth frame payload: {e}"))?;
180
181    let (_seq, cr) = match control_codec::decode_challenge_response(&frame_buf[..frame_len]) {
182        Ok(pair) => pair,
183        Err(e) => {
184            send_auth_failed(stream);
185            return Err(format!("decode ChallengeResponse: {e}"));
186        }
187    };
188
189    let (signature_bytes, public_key_bytes) = (cr.signature, cr.public_key);
190
191    let permission = match authorized_keys.lookup(&public_key_bytes) {
192        Some(perm) => perm,
193        None => {
194            send_auth_failed(stream);
195            return Err("unknown public key".into());
196        }
197    };
198    if permission != Permission::Operator {
199        send_auth_failed(stream);
200        return Err(format!(
201            "admin endpoint requires operator key, got {permission:?}"
202        ));
203    }
204
205    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|e| {
206        send_auth_failed(stream);
207        format!("invalid public key: {e}")
208    })?;
209    let signature = ed25519_dalek::Signature::from_bytes(&signature_bytes);
210    verifying_key.verify(&nonce, &signature).map_err(|e| {
211        send_auth_failed(stream);
212        format!("signature verification failed: {e}")
213    })?;
214
215    let written =
216        control_codec::encode_transport_response(&TransportResponse::ServerReady, &mut buf)
217            .map_err(|e| format!("encode ServerReady: {e}"))?;
218    stream
219        .write_all(&buf[..written])
220        .map_err(|e| format!("send ServerReady: {e}"))?;
221    stream
222        .flush()
223        .map_err(|e| format!("flush ServerReady: {e}"))?;
224
225    Ok(())
226}
227
228fn send_auth_failed(stream: &mut TcpStream) {
229    let mut buf = [0u8; 8];
230    if let Ok(written) =
231        control_codec::encode_transport_response(&TransportResponse::AuthFailed, &mut buf)
232    {
233        // Best-effort: an unauthenticated peer may already be gone, and
234        // we're about to drop the stream regardless. Write errors here
235        // carry no actionable signal.
236        send_best_effort(stream, &buf[..written]);
237    }
238}
239
240/// Write `payload` to `stream` and flush, ignoring errors. Used for
241/// terminal admin responses where the connection is about to close: the
242/// client may already have disconnected, and there's nothing the server
243/// can usefully do with a write error after the in-process side effect
244/// (flag CAS, auth rejection) has already happened.
245fn send_best_effort(stream: &mut TcpStream, payload: &[u8]) {
246    if let Err(e) = stream.write_all(payload) {
247        debug!(error = %e, "admin write failed");
248        return;
249    }
250    if let Err(e) = stream.flush() {
251        debug!(error = %e, "admin flush failed");
252    }
253}
254
255/// Handle one authenticated admin connection. Reads a single command
256/// line and dispatches it.
257fn handle_connection(
258    mut stream: TcpStream,
259    promote: Option<&AtomicBool>,
260    rotate_requested: Option<&AtomicBool>,
261    durability_mode: Option<&AtomicU8>,
262    authorized_keys: &AuthorizedKeys,
263) {
264    stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
265    stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
266
267    if let Err(reason) = authenticate(&mut stream, authorized_keys) {
268        debug!(reason = %reason, "admin auth failed");
269        return;
270    }
271
272    let cloned = match stream.try_clone() {
273        Ok(s) => s,
274        Err(e) => {
275            debug!(error = %e, "failed to clone admin stream");
276            return;
277        }
278    };
279    let mut reader = BufReader::new(cloned);
280
281    let mut line = String::new();
282    if reader.read_line(&mut line).is_err() {
283        debug!("failed to read from admin connection");
284        return;
285    }
286
287    let trimmed = line.trim();
288    match trimmed {
289        "PROMOTE" => match promote {
290            Some(flag) => {
291                flag.store(true, Ordering::Release);
292                send_best_effort(&mut stream, b"OK\n");
293                info!("promotion triggered by operator");
294            }
295            None => {
296                send_best_effort(&mut stream, b"ERR PROMOTE not available on this node\n");
297                debug!("rejected PROMOTE — flag not wired (primary node?)");
298            }
299        },
300        "ROTATE" => match rotate_requested {
301            Some(flag) => {
302                flag.store(true, Ordering::Release);
303                send_best_effort(&mut stream, b"OK\n");
304                info!("rotation requested by operator");
305            }
306            None => {
307                send_best_effort(&mut stream, b"ERR ROTATE not available on this node\n");
308                debug!("rejected ROTATE — flag not wired");
309            }
310        },
311        cmd if cmd.starts_with("DURABILITY") => {
312            // Parse `DURABILITY <mode>` with any positive whitespace
313            // between the verb and the argument. `splitn(2, ' ')` is
314            // intentional: future modes (e.g. multi-region variants)
315            // will pass additional whitespace-separated parameters
316            // through this same line and we don't want to lock in
317            // single-space framing now.
318            let mut parts = cmd.splitn(2, char::is_whitespace);
319            // Discard the verb token; we already matched on it.
320            let _ = parts.next();
321            let arg = parts.next().map(str::trim).unwrap_or("");
322            handle_durability(&mut stream, durability_mode, arg);
323        }
324        other => {
325            debug!(received = %other, "unknown admin command");
326            send_best_effort(&mut stream, b"ERR unknown command\n");
327        }
328    }
329}
330
331/// Apply a `DURABILITY <mode>` command. Validates the argument,
332/// publishes the new mode through the shared atomic if the node has a
333/// response stage wired, and emits an INFO log carrying the prev → next
334/// transition for the audit trail. Auth is enforced upstream in
335/// [`authenticate`], so reaching this point already implies an
336/// operator-signed request.
337fn handle_durability(stream: &mut TcpStream, durability_mode: Option<&AtomicU8>, arg: &str) {
338    let Some(atomic) = durability_mode else {
339        send_best_effort(stream, b"ERR DURABILITY not available on this node\n");
340        debug!("rejected DURABILITY — atomic not wired (replica node?)");
341        return;
342    };
343    if arg.is_empty() {
344        send_best_effort(
345            stream,
346            b"ERR DURABILITY requires a mode (local|hybrid|durably-replicated)\n",
347        );
348        debug!("rejected DURABILITY — missing argument");
349        return;
350    }
351    let Some(next) = DurabilityMode::parse(arg) else {
352        // Build the diagnostic into a small stack buffer to avoid
353        // allocating on the admin path. The longest valid name is
354        // `durably-replicated` (18 chars); a 128-byte buffer covers
355        // any reasonable bad input the operator might paste.
356        let mut buf = [0u8; 128];
357        let msg = format_unknown_mode(&mut buf, arg);
358        send_best_effort(stream, msg);
359        debug!(received = %arg, "rejected DURABILITY — unknown mode");
360        return;
361    };
362    // Relaxed exchange: the only writer is the admin handler itself
363    // (the response stage only reads), and only the current mode
364    // matters — losing the ordering of prev observations relative to
365    // unrelated events on other threads is fine.
366    let prev_byte = atomic.swap(next.as_u8(), Ordering::Relaxed);
367    let prev = DurabilityMode::from_u8(prev_byte)
368        .map(|m| m.as_str())
369        .unwrap_or("<corrupted>");
370    send_best_effort(stream, b"OK\n");
371    info!(
372        prev = prev,
373        next = next.as_str(),
374        "durability mode changed by operator"
375    );
376}
377
378/// Format an "unknown mode" diagnostic into `buf` without allocating.
379/// Returns the populated subslice. The buffer is sized so the longest
380/// realistic operator input fits; truncation is acceptable here since
381/// the operator already knows what they typed.
382fn format_unknown_mode<'a>(buf: &'a mut [u8], arg: &str) -> &'a [u8] {
383    use std::io::Write as _;
384    let mut cursor = std::io::Cursor::new(&mut buf[..]);
385    // Best-effort write — if `arg` is pathologically long the write
386    // truncates and the operator gets a partial message, which is
387    // strictly better than allocating on the admin hot path.
388    let _ = writeln!(
389        cursor,
390        "ERR DURABILITY unknown mode `{arg}` (expected local|hybrid|durably-replicated)"
391    );
392    let n = cursor.position() as usize;
393    &cursor.into_inner()[..n]
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::io::{BufRead, BufReader, Read, Write};
400
401    use ed25519_dalek::{Signer, SigningKey};
402    use melin_protocol::codec;
403    use melin_protocol::message::{Request, ResponseKind};
404
405    fn operator_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
406        let signing_key = SigningKey::from_bytes(&[0xAD; 32]);
407        let pub_b64 = base64::Engine::encode(
408            &base64::engine::general_purpose::STANDARD,
409            signing_key.verifying_key().to_bytes(),
410        );
411        let content = format!("operator {pub_b64} test-ops\n");
412        let keys = AuthorizedKeys::parse(&content).expect("parse authorized_keys");
413        (signing_key, Arc::new(keys))
414    }
415
416    fn trader_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
417        let signing_key = SigningKey::from_bytes(&[0xBD; 32]);
418        let pub_b64 = base64::Engine::encode(
419            &base64::engine::general_purpose::STANDARD,
420            signing_key.verifying_key().to_bytes(),
421        );
422        let content = format!("trader {pub_b64} test-trader\n");
423        let keys = AuthorizedKeys::parse(&content).expect("parse authorized_keys");
424        (signing_key, Arc::new(keys))
425    }
426
427    fn client_authenticate(stream: &mut TcpStream, key: &SigningKey) -> ResponseKind {
428        let mut len_buf = [0u8; 4];
429        stream.read_exact(&mut len_buf).expect("read challenge len");
430        let frame_len = u32::from_le_bytes(len_buf) as usize;
431        let mut frame_buf = vec![0u8; frame_len];
432        stream
433            .read_exact(&mut frame_buf)
434            .expect("read challenge payload");
435        let response = codec::decode_response(&frame_buf).expect("decode challenge");
436        let nonce = match response {
437            ResponseKind::Challenge { nonce } => nonce,
438            other => panic!("expected Challenge, got {other:?}"),
439        };
440
441        let signature = key.sign(&nonce);
442        let request = Request::ChallengeResponse {
443            signature: signature.to_bytes(),
444            public_key: key.verifying_key().to_bytes(),
445        };
446        let mut encode_buf = [0u8; 256];
447        let written = codec::encode_request(&request, 0, &mut encode_buf).expect("encode");
448        stream.write_all(&encode_buf[..written]).expect("send");
449        stream.flush().expect("flush");
450
451        let mut len_buf2 = [0u8; 4];
452        stream.read_exact(&mut len_buf2).expect("read result len");
453        let result_len = u32::from_le_bytes(len_buf2) as usize;
454        let mut result_buf = vec![0u8; result_len];
455        stream
456            .read_exact(&mut result_buf)
457            .expect("read result payload");
458        codec::decode_response(&result_buf).expect("decode result")
459    }
460
461    fn ephemeral_listener() -> (TcpListener, SocketAddr) {
462        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
463        let addr = listener.local_addr().unwrap();
464        (listener, addr)
465    }
466
467    /// Helper: connect, authenticate, send a command, return the
468    /// server's first response line.
469    fn send_command(addr: SocketAddr, key: &SigningKey, command: &[u8]) -> String {
470        let mut stream = TcpStream::connect(addr).unwrap();
471        assert!(matches!(
472            client_authenticate(&mut stream, key),
473            ResponseKind::ServerReady
474        ));
475        stream.write_all(command).unwrap();
476        stream.flush().unwrap();
477        let mut reader = BufReader::new(stream);
478        let mut line = String::new();
479        reader.read_line(&mut line).unwrap();
480        line.trim().to_string()
481    }
482
483    #[test]
484    fn promote_command_sets_flag_when_wired() {
485        let (listener, addr) = ephemeral_listener();
486        drop(listener);
487
488        let (key, auth_keys) = operator_keys();
489        let promote = Arc::new(AtomicBool::new(false));
490        let rotate = Arc::new(AtomicBool::new(false));
491        let shutdown = Arc::new(AtomicBool::new(false));
492        let _h = spawn(
493            addr,
494            Some(Arc::clone(&promote)),
495            Some(Arc::clone(&rotate)),
496            None,
497            Arc::clone(&shutdown),
498            auth_keys,
499        );
500        std::thread::sleep(Duration::from_millis(200));
501
502        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
503        assert!(promote.load(Ordering::Acquire));
504        assert!(!rotate.load(Ordering::Acquire));
505
506        shutdown.store(true, Ordering::Release);
507    }
508
509    #[test]
510    fn rotate_command_sets_flag_when_wired() {
511        let (listener, addr) = ephemeral_listener();
512        drop(listener);
513
514        let (key, auth_keys) = operator_keys();
515        let promote = Arc::new(AtomicBool::new(false));
516        let rotate = Arc::new(AtomicBool::new(false));
517        let shutdown = Arc::new(AtomicBool::new(false));
518        let _h = spawn(
519            addr,
520            Some(Arc::clone(&promote)),
521            Some(Arc::clone(&rotate)),
522            None,
523            Arc::clone(&shutdown),
524            auth_keys,
525        );
526        std::thread::sleep(Duration::from_millis(200));
527
528        assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
529        assert!(rotate.load(Ordering::Acquire));
530        assert!(!promote.load(Ordering::Acquire));
531
532        shutdown.store(true, Ordering::Release);
533    }
534
535    /// On a primary-only node (no promote flag wired), PROMOTE returns
536    /// ERR rather than silently no-opping.
537    #[test]
538    fn promote_rejected_when_not_wired() {
539        let (listener, addr) = ephemeral_listener();
540        drop(listener);
541
542        let (key, auth_keys) = operator_keys();
543        let rotate = Arc::new(AtomicBool::new(false));
544        let shutdown = Arc::new(AtomicBool::new(false));
545        let _h = spawn(
546            addr,
547            None,
548            Some(Arc::clone(&rotate)),
549            None,
550            Arc::clone(&shutdown),
551            auth_keys,
552        );
553        std::thread::sleep(Duration::from_millis(200));
554
555        let resp = send_command(addr, &key, b"PROMOTE\n");
556        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
557        assert!(!rotate.load(Ordering::Acquire));
558
559        shutdown.store(true, Ordering::Release);
560    }
561
562    /// On a node without runtime rotation enabled, ROTATE returns ERR.
563    #[test]
564    fn rotate_rejected_when_not_wired() {
565        let (listener, addr) = ephemeral_listener();
566        drop(listener);
567
568        let (key, auth_keys) = operator_keys();
569        let promote = Arc::new(AtomicBool::new(false));
570        let shutdown = Arc::new(AtomicBool::new(false));
571        let _h = spawn(
572            addr,
573            Some(Arc::clone(&promote)),
574            None,
575            None,
576            Arc::clone(&shutdown),
577            auth_keys,
578        );
579        std::thread::sleep(Duration::from_millis(200));
580
581        let resp = send_command(addr, &key, b"ROTATE\n");
582        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
583        assert!(!promote.load(Ordering::Acquire));
584
585        shutdown.store(true, Ordering::Release);
586    }
587
588    /// The listener stays alive across multiple commands — important
589    /// for ROTATE which an operator may issue many times over a long
590    /// run.
591    #[test]
592    fn listener_handles_multiple_commands() {
593        let (listener, addr) = ephemeral_listener();
594        drop(listener);
595
596        let (key, auth_keys) = operator_keys();
597        let promote = Arc::new(AtomicBool::new(false));
598        let rotate = Arc::new(AtomicBool::new(false));
599        let shutdown = Arc::new(AtomicBool::new(false));
600        let _h = spawn(
601            addr,
602            Some(Arc::clone(&promote)),
603            Some(Arc::clone(&rotate)),
604            None,
605            Arc::clone(&shutdown),
606            auth_keys,
607        );
608        std::thread::sleep(Duration::from_millis(200));
609
610        // Three rotations, each consuming the flag (simulates the
611        // journal stage's CAS).
612        for _ in 0..3 {
613            assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
614            assert!(rotate.load(Ordering::Acquire));
615            rotate
616                .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
617                .expect("flag should still be set");
618            std::thread::sleep(Duration::from_millis(100));
619        }
620
621        // Final PROMOTE on the same listener still works.
622        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
623        assert!(promote.load(Ordering::Acquire));
624
625        shutdown.store(true, Ordering::Release);
626    }
627
628    #[test]
629    fn unknown_command_rejected() {
630        let (listener, addr) = ephemeral_listener();
631        drop(listener);
632
633        let (key, auth_keys) = operator_keys();
634        let promote = Arc::new(AtomicBool::new(false));
635        let rotate = Arc::new(AtomicBool::new(false));
636        let shutdown = Arc::new(AtomicBool::new(false));
637        let _h = spawn(
638            addr,
639            Some(Arc::clone(&promote)),
640            Some(Arc::clone(&rotate)),
641            None,
642            Arc::clone(&shutdown),
643            auth_keys,
644        );
645        std::thread::sleep(Duration::from_millis(200));
646
647        let resp = send_command(addr, &key, b"INVALID\n");
648        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
649        assert!(!promote.load(Ordering::Acquire));
650        assert!(!rotate.load(Ordering::Acquire));
651
652        shutdown.store(true, Ordering::Release);
653    }
654
655    #[test]
656    fn non_operator_key_rejected() {
657        let (listener, addr) = ephemeral_listener();
658        drop(listener);
659
660        let (trader_key, auth_keys) = trader_keys();
661        let promote = Arc::new(AtomicBool::new(false));
662        let rotate = Arc::new(AtomicBool::new(false));
663        let shutdown = Arc::new(AtomicBool::new(false));
664        let _h = spawn(
665            addr,
666            Some(Arc::clone(&promote)),
667            Some(Arc::clone(&rotate)),
668            None,
669            Arc::clone(&shutdown),
670            auth_keys,
671        );
672        std::thread::sleep(Duration::from_millis(200));
673
674        let mut stream = TcpStream::connect(addr).unwrap();
675        let result = client_authenticate(&mut stream, &trader_key);
676        assert!(matches!(result, ResponseKind::AuthFailed));
677        assert!(!promote.load(Ordering::Acquire));
678        assert!(!rotate.load(Ordering::Acquire));
679
680        shutdown.store(true, Ordering::Release);
681    }
682
683    /// Driver: spawn an admin listener with only the durability-mode
684    /// atomic wired (mirrors a primary-only node in commit 2), pre-seed
685    /// it with `initial`, send the supplied command, and return
686    /// `(response, mode_after)`.
687    fn run_durability(initial: DurabilityMode, cmd: &[u8]) -> (String, Option<DurabilityMode>) {
688        let (listener, addr) = ephemeral_listener();
689        drop(listener);
690
691        let (key, auth_keys) = operator_keys();
692        let mode = Arc::new(AtomicU8::new(initial.as_u8()));
693        let shutdown = Arc::new(AtomicBool::new(false));
694        let _h = spawn(
695            addr,
696            None,
697            None,
698            Some(Arc::clone(&mode)),
699            Arc::clone(&shutdown),
700            auth_keys,
701        );
702        std::thread::sleep(Duration::from_millis(200));
703
704        let resp = send_command(addr, &key, cmd);
705        let after = DurabilityMode::from_u8(mode.load(Ordering::Relaxed));
706        shutdown.store(true, Ordering::Release);
707        (resp, after)
708    }
709
710    #[test]
711    fn durability_command_swaps_mode() {
712        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY local\n");
713        assert_eq!(resp, "OK");
714        assert_eq!(after, Some(DurabilityMode::Local));
715    }
716
717    #[test]
718    fn durability_command_accepts_each_mode() {
719        for target in [
720            DurabilityMode::Local,
721            DurabilityMode::Hybrid,
722            DurabilityMode::DurablyReplicated,
723        ] {
724            let cmd = format!("DURABILITY {}\n", target.as_str());
725            let (resp, after) = run_durability(DurabilityMode::Local, cmd.as_bytes());
726            assert_eq!(resp, "OK", "mode {target}");
727            assert_eq!(after, Some(target));
728        }
729    }
730
731    #[test]
732    fn durability_command_rejects_unknown_mode() {
733        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY fast\n");
734        assert!(
735            resp.starts_with("ERR DURABILITY unknown mode"),
736            "expected unknown-mode ERR, got {resp}"
737        );
738        // Atomic must NOT have been clobbered on a bad command.
739        assert_eq!(after, Some(DurabilityMode::Hybrid));
740    }
741
742    #[test]
743    fn durability_command_rejects_missing_argument() {
744        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY\n");
745        assert!(
746            resp.starts_with("ERR DURABILITY requires a mode"),
747            "expected missing-arg ERR, got {resp}"
748        );
749        assert_eq!(after, Some(DurabilityMode::Hybrid));
750    }
751
752    #[test]
753    fn durability_command_rejected_when_not_wired() {
754        // On a pure-replica node (no response stage), DURABILITY must
755        // not silently no-op — operators get a structured ERR.
756        let (listener, addr) = ephemeral_listener();
757        drop(listener);
758
759        let (key, auth_keys) = operator_keys();
760        let promote = Arc::new(AtomicBool::new(false));
761        let shutdown = Arc::new(AtomicBool::new(false));
762        let _h = spawn(
763            addr,
764            Some(Arc::clone(&promote)),
765            None,
766            None,
767            Arc::clone(&shutdown),
768            auth_keys,
769        );
770        std::thread::sleep(Duration::from_millis(200));
771
772        let resp = send_command(addr, &key, b"DURABILITY local\n");
773        assert!(
774            resp.starts_with("ERR DURABILITY not available"),
775            "expected not-available ERR, got {resp}"
776        );
777
778        shutdown.store(true, Ordering::Release);
779    }
780}