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