rmux-server 0.10.0

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
//! Web-share wire protocol v1 (X25519 + ML-KEM-768 hybrid).
//!
//! This module owns the shared vocabulary — constants, the client/auth wire
//! types, and the small parse/validate helpers — and re-exports the directional
//! logic split across submodules by responsibility:
//!
//! - [`handshake`]: pre-ready hello / challenge / encrypted auth.
//! - [`inbound`]: decode and dispatch client text/binary frames.
//! - [`outbound`]: frame terminal output and the JSON server messages.
//!
//! Submodules are descendants of this module, so they read these private items
//! directly via `super::` without elevating their visibility.

use serde::Deserialize;
use std::time::Duration;

use rmux_proto::{AttachedWindowsConsoleKey, ResizePaneAdjustment, SplitDirection, TerminalSize};
#[cfg(windows)]
use rmux_pty::WindowsConsoleKeyEvent;

use crate::input_keys::{encode_key, ExtendedKeyFormat};
use crate::keys::parse_key_code;
use crate::web::crypto::E2EE_CAPABILITY;

mod handshake;
mod inbound;
mod outbound;
#[cfg(test)]
mod tests;

pub(crate) use handshake::{
    build_challenge, close_for_auth_error, read_auth_message, read_client_hello, send_text,
};
pub(crate) use inbound::{
    handle_pane_client_text, handle_pane_operator_binary_frame, handle_session_client_text,
    handle_session_operator_binary_frame,
};
pub(crate) use outbound::{
    queue_output, queue_session_keyframe, queue_session_pane_frame, queue_session_view,
    queue_snapshot, send_ready, send_revoked, send_viewer_count,
};

pub(crate) const PRE_AUTH_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const AUTH_FRAME_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) const UNIFORM_AUTH_DELAY: Duration = Duration::from_millis(50);

pub(crate) const WEB_SHARE_PROTOCOL_VERSION: u16 = 1;

/// The default wire close pair used for pre-ready handshake rejection.
///
/// A web-share relay performing its own DH knows both DH secrets, so only the
/// token authenticates the channel and the PIN is a secondary factor.
/// Distinguishable close codes before token/PIN authentication would leak a
/// PIN/identity oracle, so those failures collapse to this one pair. The
/// precise reason is logged server-side, never sent.
pub(crate) const HANDSHAKE_REJECTED: (u16, &str) = (4000, "handshake_rejected");
pub(crate) const PANE_FRAME_CAPABILITY: &str = "pane-frame-v1";
pub(crate) const PANE_RECOVERY_COVERAGE_CAPABILITY: &str = "pane-recovery-coverage-v1";
const SERVER_CAPABILITIES: &[&str] = &[
    E2EE_CAPABILITY,
    "terminal-palette-v1",
    PANE_FRAME_CAPABILITY,
    PANE_RECOVERY_COVERAGE_CAPABILITY,
];
const OPERATOR_INPUT_FRAME_MAX: usize = 4 * 1024;
const MAX_SESSION_RESIZE_DIMENSION: u16 = 4096;
const MAX_SESSION_RESIZE_CELLS: u32 = 1_000_000;
const MAX_PANE_RESIZE_CELLS: u16 = 10_000;
const WS_OUTPUT_RAW: u8 = 0x01;
const WS_RESIZE_NOTIFY: u8 = 0x02;
const WS_SNAPSHOT_FULL: u8 = 0x10;
const WS_SESSION_VIEW: u8 = 0x11;
const WS_SESSION_PANE_FRAME: u8 = 0x12;
const WS_PANE_RECOVERY_SNAPSHOT: u8 = 0x13;
const WS_INPUT_TEXT: u8 = 0x80;
const WS_INPUT_KEY: u8 = 0x81;
const WS_RESIZE_REQUEST: u8 = 0x82;
const WS_ATTACH_INPUT: u8 = 0x83;
const WS_SESSION_RESIZE_PANE: u8 = 0x84;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SessionScrollRequest {
    pub(crate) pane_id: u32,
    pub(crate) delta: i32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SessionClientTextOutcome {
    None,
    Scroll(SessionScrollRequest),
    Snapshot,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SessionOperatorBinaryOutcome {
    None,
    Resize,
    Snapshot,
}

#[derive(Debug)]
pub(crate) struct AuthMessage {
    pub(crate) pin: Option<String>,
    pub(crate) supports_session_pane_frame: bool,
    pub(crate) supports_pane_recovery_coverage: bool,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AuthWireMessage {
    #[serde(rename = "type")]
    kind: String,
    protocol_version: u16,
    capabilities: Vec<String>,
    #[serde(default)]
    pin: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
    Logout,
    PaneScroll { pane_id: u32, delta: i32 },
    SelectPane { pane_id: u32 },
    SplitPane { direction: ClientSplitDirection },
    NewWindow,
    KillPane,
    SelectWindow { window_index: u32 },
    RenameWindow { window_index: u32, name: String },
    KillWindow { window_index: u32 },
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ClientSplitDirection {
    Horizontal,
    Vertical,
}

impl From<ClientSplitDirection> for SplitDirection {
    fn from(value: ClientSplitDirection) -> Self {
        match value {
            ClientSplitDirection::Horizontal => Self::Horizontal,
            ClientSplitDirection::Vertical => Self::Vertical,
        }
    }
}

fn parse_resize_body(body: &[u8]) -> Option<TerminalSize> {
    if body.len() != 4 {
        return None;
    }
    let cols = u16::from_be_bytes([body[0], body[1]]);
    let rows = u16::from_be_bytes([body[2], body[3]]);
    is_valid_session_resize(cols, rows).then_some(TerminalSize { cols, rows })
}

fn is_valid_session_resize(cols: u16, rows: u16) -> bool {
    cols > 0
        && rows > 0
        && cols <= MAX_SESSION_RESIZE_DIMENSION
        && rows <= MAX_SESSION_RESIZE_DIMENSION
        && u32::from(cols).saturating_mul(u32::from(rows)) <= MAX_SESSION_RESIZE_CELLS
}

fn parse_pane_resize_body(body: &[u8]) -> Option<(u32, ResizePaneAdjustment)> {
    if body.len() != 7 {
        return None;
    }
    let pane_id = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
    let cells = u16::from_be_bytes([body[5], body[6]]);
    if cells == 0 || cells > MAX_PANE_RESIZE_CELLS {
        return None;
    }
    let adjustment = match body[4] {
        0 => ResizePaneAdjustment::Left { cells },
        1 => ResizePaneAdjustment::Right { cells },
        2 => ResizePaneAdjustment::Up { cells },
        3 => ResizePaneAdjustment::Down { cells },
        _ => return None,
    };
    Some((pane_id, adjustment))
}

fn session_logout_allowed(is_operator: bool, controls: bool) -> bool {
    is_operator && controls
}

fn valid_window_name(name: &str) -> bool {
    !name.is_empty() && name.len() <= 128 && !name.chars().any(char::is_control)
}

fn encode_session_key(token: &str) -> Option<Vec<u8>> {
    let key = parse_key_code(token)?;
    encode_key(0, ExtendedKeyFormat::Xterm, key)
}

fn encode_session_windows_console_key(token: &str) -> Option<(Vec<u8>, AttachedWindowsConsoleKey)> {
    #[cfg(not(windows))]
    {
        let _ = token;
        None
    }

    #[cfg(windows)]
    {
        let key = rmux_core::key_code_lookup_bits(parse_key_code(token)?);
        let (bytes, key_event) = session_windows_ctrl_letter_for_key(key)?;
        Some((bytes, key_event))
    }
}

#[cfg(windows)]
fn session_windows_ctrl_letter_for_key(
    key: rmux_core::KeyCode,
) -> Option<(Vec<u8>, AttachedWindowsConsoleKey)> {
    for letter in b'A'..=b'Z' {
        let name = format!("C-{}", char::from(letter.to_ascii_lowercase()));
        if Some(key)
            == rmux_core::key_string_lookup_string(&name).map(rmux_core::key_code_lookup_bits)
        {
            let event = WindowsConsoleKeyEvent::ctrl_letter(letter)?;
            return Some((
                vec![letter - b'A' + 1],
                AttachedWindowsConsoleKey::new(
                    event.virtual_key_code(),
                    event.virtual_scan_code(),
                    event.unicode_char(),
                    event.control_key_state(),
                    event.repeat_count(),
                ),
            ));
        }
    }
    None
}