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