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_wire_protocol::control_codec::{
403        TAG_AUTH_FAILED, TAG_CHALLENGE, TAG_CHALLENGE_RESPONSE, TAG_SERVER_READY,
404    };
405
406    fn operator_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
407        let signing_key = SigningKey::from_bytes(&[0xAD; 32]);
408        let pub_b64 = base64::Engine::encode(
409            &base64::engine::general_purpose::STANDARD,
410            signing_key.verifying_key().to_bytes(),
411        );
412        let content = format!("operator {pub_b64} test-ops\n");
413        let keys = AuthorizedKeys::parse(&content).expect("parse authorized_keys");
414        (signing_key, Arc::new(keys))
415    }
416
417    fn trader_keys() -> (SigningKey, Arc<AuthorizedKeys>) {
418        let signing_key = SigningKey::from_bytes(&[0xBD; 32]);
419        let pub_b64 = base64::Engine::encode(
420            &base64::engine::general_purpose::STANDARD,
421            signing_key.verifying_key().to_bytes(),
422        );
423        let content = format!("trader {pub_b64} test-trader\n");
424        let keys = AuthorizedKeys::parse(&content).expect("parse authorized_keys");
425        (signing_key, Arc::new(keys))
426    }
427
428    /// Perform the transport-level auth handshake on `stream`, returning
429    /// the tag byte of the server's final response (`TAG_SERVER_READY` or
430    /// `TAG_AUTH_FAILED`). Builds frames directly from the control-codec
431    /// wire format so the test needs no exchange-protocol codec.
432    fn client_authenticate(stream: &mut TcpStream, key: &SigningKey) -> u8 {
433        // Read the Challenge: [len:u32][TAG_CHALLENGE][nonce:32].
434        let mut len_buf = [0u8; 4];
435        stream.read_exact(&mut len_buf).expect("read challenge len");
436        let frame_len = u32::from_le_bytes(len_buf) as usize;
437        let mut frame_buf = vec![0u8; frame_len];
438        stream
439            .read_exact(&mut frame_buf)
440            .expect("read challenge payload");
441        assert_eq!(frame_buf[0], TAG_CHALLENGE, "expected Challenge");
442        let nonce = &frame_buf[1..33];
443
444        // Reply with a ChallengeResponse:
445        // [len:u32][seq:u64][TAG_CHALLENGE_RESPONSE][sig:64][pubkey:32].
446        let signature = key.sign(nonce);
447        let mut frame = Vec::with_capacity(105);
448        frame.extend_from_slice(&0u64.to_le_bytes()); // request_seq
449        frame.push(TAG_CHALLENGE_RESPONSE);
450        frame.extend_from_slice(&signature.to_bytes());
451        frame.extend_from_slice(&key.verifying_key().to_bytes());
452        stream
453            .write_all(&(frame.len() as u32).to_le_bytes())
454            .expect("send result len");
455        stream.write_all(&frame).expect("send");
456        stream.flush().expect("flush");
457
458        // Read the server's result frame and return its tag byte.
459        let mut len_buf2 = [0u8; 4];
460        stream.read_exact(&mut len_buf2).expect("read result len");
461        let result_len = u32::from_le_bytes(len_buf2) as usize;
462        let mut result_buf = vec![0u8; result_len];
463        stream
464            .read_exact(&mut result_buf)
465            .expect("read result payload");
466        result_buf[0]
467    }
468
469    fn ephemeral_listener() -> (TcpListener, SocketAddr) {
470        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
471        let addr = listener.local_addr().unwrap();
472        (listener, addr)
473    }
474
475    /// Helper: connect, authenticate, send a command, return the
476    /// server's first response line.
477    fn send_command(addr: SocketAddr, key: &SigningKey, command: &[u8]) -> String {
478        let mut stream = TcpStream::connect(addr).unwrap();
479        assert_eq!(client_authenticate(&mut stream, key), TAG_SERVER_READY);
480        stream.write_all(command).unwrap();
481        stream.flush().unwrap();
482        let mut reader = BufReader::new(stream);
483        let mut line = String::new();
484        reader.read_line(&mut line).unwrap();
485        line.trim().to_string()
486    }
487
488    #[test]
489    fn promote_command_sets_flag_when_wired() {
490        let (listener, addr) = ephemeral_listener();
491        drop(listener);
492
493        let (key, auth_keys) = operator_keys();
494        let promote = Arc::new(AtomicBool::new(false));
495        let rotate = Arc::new(AtomicBool::new(false));
496        let shutdown = Arc::new(AtomicBool::new(false));
497        let _h = spawn(
498            addr,
499            Some(Arc::clone(&promote)),
500            Some(Arc::clone(&rotate)),
501            None,
502            Arc::clone(&shutdown),
503            auth_keys,
504        );
505        std::thread::sleep(Duration::from_millis(200));
506
507        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
508        assert!(promote.load(Ordering::Acquire));
509        assert!(!rotate.load(Ordering::Acquire));
510
511        shutdown.store(true, Ordering::Release);
512    }
513
514    #[test]
515    fn rotate_command_sets_flag_when_wired() {
516        let (listener, addr) = ephemeral_listener();
517        drop(listener);
518
519        let (key, auth_keys) = operator_keys();
520        let promote = Arc::new(AtomicBool::new(false));
521        let rotate = Arc::new(AtomicBool::new(false));
522        let shutdown = Arc::new(AtomicBool::new(false));
523        let _h = spawn(
524            addr,
525            Some(Arc::clone(&promote)),
526            Some(Arc::clone(&rotate)),
527            None,
528            Arc::clone(&shutdown),
529            auth_keys,
530        );
531        std::thread::sleep(Duration::from_millis(200));
532
533        assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
534        assert!(rotate.load(Ordering::Acquire));
535        assert!(!promote.load(Ordering::Acquire));
536
537        shutdown.store(true, Ordering::Release);
538    }
539
540    /// On a primary-only node (no promote flag wired), PROMOTE returns
541    /// ERR rather than silently no-opping.
542    #[test]
543    fn promote_rejected_when_not_wired() {
544        let (listener, addr) = ephemeral_listener();
545        drop(listener);
546
547        let (key, auth_keys) = operator_keys();
548        let rotate = Arc::new(AtomicBool::new(false));
549        let shutdown = Arc::new(AtomicBool::new(false));
550        let _h = spawn(
551            addr,
552            None,
553            Some(Arc::clone(&rotate)),
554            None,
555            Arc::clone(&shutdown),
556            auth_keys,
557        );
558        std::thread::sleep(Duration::from_millis(200));
559
560        let resp = send_command(addr, &key, b"PROMOTE\n");
561        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
562        assert!(!rotate.load(Ordering::Acquire));
563
564        shutdown.store(true, Ordering::Release);
565    }
566
567    /// On a node without runtime rotation enabled, ROTATE returns ERR.
568    #[test]
569    fn rotate_rejected_when_not_wired() {
570        let (listener, addr) = ephemeral_listener();
571        drop(listener);
572
573        let (key, auth_keys) = operator_keys();
574        let promote = Arc::new(AtomicBool::new(false));
575        let shutdown = Arc::new(AtomicBool::new(false));
576        let _h = spawn(
577            addr,
578            Some(Arc::clone(&promote)),
579            None,
580            None,
581            Arc::clone(&shutdown),
582            auth_keys,
583        );
584        std::thread::sleep(Duration::from_millis(200));
585
586        let resp = send_command(addr, &key, b"ROTATE\n");
587        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
588        assert!(!promote.load(Ordering::Acquire));
589
590        shutdown.store(true, Ordering::Release);
591    }
592
593    /// The listener stays alive across multiple commands — important
594    /// for ROTATE which an operator may issue many times over a long
595    /// run.
596    #[test]
597    fn listener_handles_multiple_commands() {
598        let (listener, addr) = ephemeral_listener();
599        drop(listener);
600
601        let (key, auth_keys) = operator_keys();
602        let promote = Arc::new(AtomicBool::new(false));
603        let rotate = Arc::new(AtomicBool::new(false));
604        let shutdown = Arc::new(AtomicBool::new(false));
605        let _h = spawn(
606            addr,
607            Some(Arc::clone(&promote)),
608            Some(Arc::clone(&rotate)),
609            None,
610            Arc::clone(&shutdown),
611            auth_keys,
612        );
613        std::thread::sleep(Duration::from_millis(200));
614
615        // Three rotations, each consuming the flag (simulates the
616        // journal stage's CAS).
617        for _ in 0..3 {
618            assert_eq!(send_command(addr, &key, b"ROTATE\n"), "OK");
619            assert!(rotate.load(Ordering::Acquire));
620            rotate
621                .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
622                .expect("flag should still be set");
623            std::thread::sleep(Duration::from_millis(100));
624        }
625
626        // Final PROMOTE on the same listener still works.
627        assert_eq!(send_command(addr, &key, b"PROMOTE\n"), "OK");
628        assert!(promote.load(Ordering::Acquire));
629
630        shutdown.store(true, Ordering::Release);
631    }
632
633    #[test]
634    fn unknown_command_rejected() {
635        let (listener, addr) = ephemeral_listener();
636        drop(listener);
637
638        let (key, auth_keys) = operator_keys();
639        let promote = Arc::new(AtomicBool::new(false));
640        let rotate = Arc::new(AtomicBool::new(false));
641        let shutdown = Arc::new(AtomicBool::new(false));
642        let _h = spawn(
643            addr,
644            Some(Arc::clone(&promote)),
645            Some(Arc::clone(&rotate)),
646            None,
647            Arc::clone(&shutdown),
648            auth_keys,
649        );
650        std::thread::sleep(Duration::from_millis(200));
651
652        let resp = send_command(addr, &key, b"INVALID\n");
653        assert!(resp.starts_with("ERR"), "expected ERR, got {resp}");
654        assert!(!promote.load(Ordering::Acquire));
655        assert!(!rotate.load(Ordering::Acquire));
656
657        shutdown.store(true, Ordering::Release);
658    }
659
660    #[test]
661    fn non_operator_key_rejected() {
662        let (listener, addr) = ephemeral_listener();
663        drop(listener);
664
665        let (trader_key, auth_keys) = trader_keys();
666        let promote = Arc::new(AtomicBool::new(false));
667        let rotate = Arc::new(AtomicBool::new(false));
668        let shutdown = Arc::new(AtomicBool::new(false));
669        let _h = spawn(
670            addr,
671            Some(Arc::clone(&promote)),
672            Some(Arc::clone(&rotate)),
673            None,
674            Arc::clone(&shutdown),
675            auth_keys,
676        );
677        std::thread::sleep(Duration::from_millis(200));
678
679        let mut stream = TcpStream::connect(addr).unwrap();
680        let result = client_authenticate(&mut stream, &trader_key);
681        assert_eq!(result, TAG_AUTH_FAILED);
682        assert!(!promote.load(Ordering::Acquire));
683        assert!(!rotate.load(Ordering::Acquire));
684
685        shutdown.store(true, Ordering::Release);
686    }
687
688    /// Driver: spawn an admin listener with only the durability-mode
689    /// atomic wired (mirrors a primary-only node in commit 2), pre-seed
690    /// it with `initial`, send the supplied command, and return
691    /// `(response, mode_after)`.
692    fn run_durability(initial: DurabilityMode, cmd: &[u8]) -> (String, Option<DurabilityMode>) {
693        let (listener, addr) = ephemeral_listener();
694        drop(listener);
695
696        let (key, auth_keys) = operator_keys();
697        let mode = Arc::new(AtomicU8::new(initial.as_u8()));
698        let shutdown = Arc::new(AtomicBool::new(false));
699        let _h = spawn(
700            addr,
701            None,
702            None,
703            Some(Arc::clone(&mode)),
704            Arc::clone(&shutdown),
705            auth_keys,
706        );
707        std::thread::sleep(Duration::from_millis(200));
708
709        let resp = send_command(addr, &key, cmd);
710        let after = DurabilityMode::from_u8(mode.load(Ordering::Relaxed));
711        shutdown.store(true, Ordering::Release);
712        (resp, after)
713    }
714
715    #[test]
716    fn durability_command_swaps_mode() {
717        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY local\n");
718        assert_eq!(resp, "OK");
719        assert_eq!(after, Some(DurabilityMode::Local));
720    }
721
722    #[test]
723    fn durability_command_accepts_each_mode() {
724        for target in [
725            DurabilityMode::Local,
726            DurabilityMode::Hybrid,
727            DurabilityMode::DurablyReplicated,
728        ] {
729            let cmd = format!("DURABILITY {}\n", target.as_str());
730            let (resp, after) = run_durability(DurabilityMode::Local, cmd.as_bytes());
731            assert_eq!(resp, "OK", "mode {target}");
732            assert_eq!(after, Some(target));
733        }
734    }
735
736    #[test]
737    fn durability_command_rejects_unknown_mode() {
738        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY fast\n");
739        assert!(
740            resp.starts_with("ERR DURABILITY unknown mode"),
741            "expected unknown-mode ERR, got {resp}"
742        );
743        // Atomic must NOT have been clobbered on a bad command.
744        assert_eq!(after, Some(DurabilityMode::Hybrid));
745    }
746
747    #[test]
748    fn durability_command_rejects_missing_argument() {
749        let (resp, after) = run_durability(DurabilityMode::Hybrid, b"DURABILITY\n");
750        assert!(
751            resp.starts_with("ERR DURABILITY requires a mode"),
752            "expected missing-arg ERR, got {resp}"
753        );
754        assert_eq!(after, Some(DurabilityMode::Hybrid));
755    }
756
757    #[test]
758    fn durability_command_rejected_when_not_wired() {
759        // On a pure-replica node (no response stage), DURABILITY must
760        // not silently no-op — operators get a structured ERR.
761        let (listener, addr) = ephemeral_listener();
762        drop(listener);
763
764        let (key, auth_keys) = operator_keys();
765        let promote = Arc::new(AtomicBool::new(false));
766        let shutdown = Arc::new(AtomicBool::new(false));
767        let _h = spawn(
768            addr,
769            Some(Arc::clone(&promote)),
770            None,
771            None,
772            Arc::clone(&shutdown),
773            auth_keys,
774        );
775        std::thread::sleep(Duration::from_millis(200));
776
777        let resp = send_command(addr, &key, b"DURABILITY local\n");
778        assert!(
779            resp.starts_with("ERR DURABILITY not available"),
780            "expected not-available ERR, got {resp}"
781        );
782
783        shutdown.store(true, Ordering::Release);
784    }
785}