mod.rs

In rust/crates/mkit-cli/src/commands/serve/

Preview

Code · 19.1 KiB
//! `mkit serve <path>` — the `mkit+ssh://` forced-command server: the
//! `mkit.rpc.v1.ssh` frame protocol (SPEC-TRANSPORT §4.2) on stdin/stdout
//! against a local repository.
//!
//! The engine is `mkit-server`'s: [`mkit_server::ssh::serve_session`] over
//! the pipeline, with the `.mkit` layout stores (`FsBlobStore` for
//! `<root>/packs/`, `FsLayoutStore` for `<root>/refs/`), so the files are
//! the ones `FileTransport`, `mkit+file://` remotes and any
//! `mkit-server` fs-layout deployment read and write. It runs under a blocking executor
//! (`futures::executor::block_on`): the CLI builds no async runtime, and
//! the stores do blocking I/O inside their futures. A reader thread feeds
//! stdin frames to the session and enforces the idle timeout
//! (`--idle-timeout-secs`, `stdio.rs`).

use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use clap::Parser;
use mkit_core::repo_identity::RepositoryIdentity;
use mkit_core::repo_lock::{self, LockError, RepoLock};
use mkit_rpc::mkit::rpc::v1::ErrorCode;
use mkit_server::fs::{FsBlobStore, FsLayoutStore};
use mkit_server::pipeline::{AuthMode, Hooks, Pipeline, PipelineConfig};
use mkit_server::policy::WritePolicy;
use mkit_server::ssh::{SessionConfig, SessionEnd, WriteFrames, serve_session, upload_limits};
use mkit_server::{
    Addressing, NamespaceKey, NoopMetrics, Principal, RepoId, RepoName, SystemClock,
};

use crate::clap_shim;
use crate::cli::CLI_VERSION;
use crate::exit;

mod stdio;

use stdio::StdioFrameSource;

/// The default of `--idle-timeout-secs` (planner decision Q12).
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 60;

/// The largest `--idle-timeout-secs` and `--max-session-secs`: 7 days.
const MAX_TIMEOUT_SECS: u64 = 7 * 24 * 60 * 60;

#[derive(Debug, Parser)]
#[command(
    name = "mkit serve",
    about = "Speak the mkit-rpc SSH-frame protocol on stdin/stdout (the \
             mkit+ssh:// forced-command server). It has no HTTP or \
             mkit+enc:// listener."
)]
struct ServeOpts {
    /// Path to the repository to serve. Under `--root` it is the
    /// `<NAMESPACE>/<NAME>` the session binds; when it is omitted there,
    /// `SSH_ORIGINAL_COMMAND` supplies it.
    path: Option<String>,
    /// Serve the repositories under DIR: the path names one
    /// `<NAMESPACE>/<NAME>`, resolved to the directory
    /// `<DIR>/<NAMESPACE>/<NAME>`, and only the namespace's owner may
    /// write (SPEC-TRANSPORT §4 root mode).
    #[arg(long, value_name = "DIR")]
    root: Option<PathBuf>,
    /// The Ed25519 public key the sshd forced-command configuration
    /// asserts for this session: 64 lowercase hex characters (the raw
    /// 32-byte key, no prefix). It is a trust assertion — sshd attaches it
    /// to the key it already verified — and is never read from the
    /// environment or `SSH_ORIGINAL_COMMAND`.
    #[arg(long, value_name = "HEX", value_parser = parse_principal)]
    principal: Option<[u8; 32]>,
    /// End the session after this many seconds without a byte from the
    /// client; 0 disables the timeout (at most 604800, 7 days). A slow
    /// upload that keeps sending never trips it.
    #[arg(
        long,
        value_name = "SECS",
        default_value_t = DEFAULT_IDLE_TIMEOUT_SECS,
        value_parser = clap::value_parser!(u64).range(..=MAX_TIMEOUT_SECS)
    )]
    idle_timeout_secs: u64,
    /// End the process this many seconds after it starts, whatever the
    /// client is doing; 0 (the default) disables the cap (at most 604800,
    /// 7 days). It also bounds a client that trickles bytes or stops
    /// reading, which the idle timeout does not.
    #[arg(
        long,
        value_name = "SECS",
        default_value_t = 0,
        value_parser = clap::value_parser!(u64).range(..=MAX_TIMEOUT_SECS)
    )]
    max_session_secs: u64,
}

/// The listener flags `mkit serve` used to take, removed along with the HTTP
/// (`--http`) and encrypted (`--listen-enc`) listeners. Clap rejects them as
/// unknown arguments; [`run`] then adds a hint.
const REMOVED_LISTENER_FLAGS: &[&str] = &[
    "--http",
    "--http-token",
    "--unsafe-allow-any-http-peer",
    "--listen-enc",
    "--enc-authorized-peers",
    "--enc-server-key",
    "--unsafe-allow-any-enc-peer",
    "--enc-idle-timeout-secs",
    "--enc-handshake-timeout-secs",
];

/// The first removed listener flag in `args` (as `--flag` or
/// `--flag=value`), stopping at a `--` separator.
fn removed_listener_flag(args: &[String]) -> Option<&'static str> {
    args.iter()
        .take_while(|a| a.as_str() != "--")
        .find_map(|a| {
            let name = a.split_once('=').map_or(a.as_str(), |(n, _)| n);
            REMOVED_LISTENER_FLAGS.iter().copied().find(|f| *f == name)
        })
}

/// The repository identity `mkit serve` serves its root as: `mkit-server`'s
/// default `--repository`, so both address one root's refs alike.
const REPOSITORY: &str = "default";

/// `--principal`'s value: exactly the raw 32-byte Ed25519 public key as
/// 64 lowercase hex characters — no `ed25519-` prefix, no `0x`, nothing
/// else. Clap maps a refusal to USAGE before any frame is read.
fn parse_principal(text: &str) -> Result<[u8; 32], String> {
    let bad =
        || "expected 64 lowercase hex characters (a raw 32-byte Ed25519 public key)".to_owned();
    let hex = |b: u8| b.is_ascii_digit() || (b'a'..=b'f').contains(&b);
    if text.len() != 64 || !text.bytes().all(&hex) {
        return Err(bad());
    }
    let mut out = [0u8; 32];
    let nibble = |b: u8| {
        if b.is_ascii_digit() {
            b - b'0'
        } else {
            b - b'a' + 10
        }
    };
    for (byte, pair) in out.iter_mut().zip(text.as_bytes().chunks_exact(2)) {
        *byte = (nibble(pair[0]) << 4) | nibble(pair[1]);
    }
    Ok(out)
}

/// The repository path an `SSH_ORIGINAL_COMMAND` of exactly
/// `mkit serve <path>` carries (SPEC-TRANSPORT §4's forced-command form):
/// every byte is `[A-Za-z0-9._/-]` or a single space, the command splits
/// on single spaces into exactly `["mkit", "serve", path]`, and `path`
/// does not start with `-` (so no flag can be smuggled). Any other form —
/// extra arguments, `sh -c`, quotes, tabs or doubled spaces, NUL, CR/LF,
/// `;`, `$` — is refused.
fn parse_original_command(command: &str) -> Option<&str> {
    let ok_byte =
        |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'/' | b'-' | b' ');
    if !command.bytes().all(ok_byte) {
        return None;
    }
    let mut parts = command.split(' ');
    match (parts.next(), parts.next(), parts.next(), parts.next()) {
        (Some("mkit"), Some("serve"), Some(path), None)
            if !path.is_empty() && !path.starts_with('-') =>
        {
            Some(path)
        }
        _ => None,
    }
}

/// The identity `path` names in root mode, validated and resolved under
/// `root`: `<NAMESPACE>/<NAME>` in the §7.4 grammar (a bare name, an
/// uppercase byte, `..` or another component is refused), then the
/// canonical `<ROOT>/<NAMESPACE>/<NAME>` must be that path exactly — a
/// surviving symlink component would serve one repository under
/// another's identity, which the owner check would then attribute
/// wrongly. `MKIT_SERVE_ROOT` pins the result like plain mode.
fn resolve_root_repo(root: &Path, path: &str) -> Result<(PathBuf, RepoId), u8> {
    let trimmed = path.trim_matches('/');
    let identity = RepositoryIdentity::parse(trimmed).map_err(|_| exit::USAGE)?;
    let Some(namespace) = identity.namespace() else {
        return Err(exit::USAGE);
    };
    let root = std::fs::canonicalize(root).map_err(|_| exit::NOINPUT)?;
    let expected = root.join(namespace.to_string()).join(identity.name());
    let resolved = std::fs::canonicalize(&expected).map_err(|_| exit::NOINPUT)?;
    if resolved != expected {
        return Err(exit::NOPERM);
    }
    if !resolved.is_dir() || !resolved.join(".mkit").is_dir() {
        return Err(exit::DATAERR);
    }
    if let Ok(pinned) = std::env::var("MKIT_SERVE_ROOT") {
        let pinned = std::fs::canonicalize(&pinned).map_err(|_| exit::NOPERM)?;
        if !resolved.starts_with(&pinned) {
            return Err(exit::NOPERM);
        }
    }
    Ok((
        resolved,
        RepoId {
            namespace: NamespaceKey::from_namespace(namespace),
            name: RepoName::new(identity.name()).map_err(|_| exit::USAGE)?,
        },
    ))
}

/// How old an upload's temp file must be before the startup sweep removes
/// it. A live upload rewrites its temp file continuously; see
/// [`sweep_crashed_uploads`].
const STALE_UPLOAD_AGE: Duration = Duration::from_hours(1);

#[must_use]
pub fn run(args: &[String]) -> u8 {
    let opts = match clap_shim::parse::<ServeOpts>("mkit serve", args) {
        Ok(o) => o,
        Err(code) => {
            if let Some(flag) = removed_listener_flag(args) {
                eprintln!(
                    "hint: `mkit serve` no longer takes `{flag}`; it only speaks the ssh-frame \
                     protocol on stdin/stdout.\n\
                     \x20     Use SSH (`mkit serve`) or a Connect server such as vcs-worker.\n\
                     \x20     The mkit+enc:// transport is deprecated: no maintained server."
                );
            }
            return code;
        }
    };

    let principal = Principal::SshForcedCommand {
        key: opts.principal,
    };
    let target = match &opts.root {
        // Plain mode, unchanged: one repository directory, open writes.
        None => {
            let Some(path) = &opts.path else {
                eprintln!(
                    "error: the following required arguments were not provided:\n  <PATH>\n\n\
                     Usage: mkit serve <PATH>\n\n\
                     For more information, try '--help'."
                );
                return exit::USAGE;
            };
            match resolve_repo_path(path) {
                Ok(root) => ServeTarget {
                    root,
                    repo: repo_id(),
                    write_policy: WritePolicy::Open,
                    principal,
                },
                Err(code) => return code,
            }
        }
        // Root mode: the path (or `SSH_ORIGINAL_COMMAND`) names the one
        // `<NAMESPACE>/<NAME>` this process serves under `--root`.
        Some(root) => {
            let path = match &opts.path {
                Some(path) => Some(path.clone()),
                None => std::env::var("SSH_ORIGINAL_COMMAND")
                    .ok()
                    .and_then(|command| parse_original_command(&command).map(str::to_owned)),
            };
            let Some(path) = path else {
                eprintln!(
                    "mkit serve: --root serves <NAMESPACE>/<NAME>, from the path or \
                     `SSH_ORIGINAL_COMMAND` `mkit serve <NAMESPACE>/<NAME>`"
                );
                return exit::USAGE;
            };
            match resolve_root_repo(root, &path) {
                Ok((root, repo)) => ServeTarget {
                    root,
                    repo,
                    write_policy: WritePolicy::Owner,
                    principal,
                },
                Err(code) => return code,
            }
        }
    };

    // Held for the whole lifetime of this `serve` process
    // (SPEC-CONCURRENCY §3.1, MKIT-11/#655): local
    // worktree-mutating commands and `gc` probe this same lock (see
    // `commands::warn_if_served`) to detect a live `serve` and warn.
    // SHARED, not exclusive — SPEC-TRANSPORT documents multiple
    // concurrent `serve` processes against one root (e.g. one per SSH
    // forced-command connection) as a supported deployment, so `serve`
    // instances must not exclude each other.
    let _serve_guard = match lock_and_sweep(&target.root) {
        Ok(g) => g,
        Err(e) => {
            eprintln!("mkit serve: serve lock: {e}");
            return exit::TEMPFAIL;
        }
    };

    if opts.max_session_secs > 0 {
        spawn_session_cap(Duration::from_secs(opts.max_session_secs));
    }

    // Test-only fault injection for the mkit#703 SSH retry regression
    // test (`tests/ssh_retry_e2e.rs`): end right after a successful
    // `Hello`/`HelloResponse`, before answering any verb, so the process
    // exits and the child pipe closes — simulating a mid-session
    // connection drop that the client's `SshTransport` retry/reconnect
    // path (SPEC-TRANSPORT §7) must recover from. Never set in
    // production.
    let stop_after_hello = std::env::var_os("MKIT_SERVE_TEST_DIE_AFTER_HELLO").is_some();
    let idle = (opts.idle_timeout_secs > 0).then(|| Duration::from_secs(opts.idle_timeout_secs));
    // `Stdin`/`Stdout`, not their locks: the reader thread needs `Send`,
    // and each call locks. `WriteFrames` flushes after every frame.
    serve_stdio(
        &target,
        std::io::stdin(),
        std::io::stdout(),
        idle,
        stop_after_hello,
    )
}

/// Resolve and validate the on-disk path supplied to `mkit serve`.
pub(crate) fn resolve_repo_path(path: &str) -> Result<PathBuf, u8> {
    let resolved = std::fs::canonicalize(path).map_err(|_| exit::NOINPUT)?;
    if !resolved.is_dir() {
        return Err(exit::DATAERR);
    }
    if !resolved.join(".mkit").is_dir() {
        return Err(exit::DATAERR);
    }
    if let Ok(root) = std::env::var("MKIT_SERVE_ROOT") {
        let pinned = std::fs::canonicalize(&root).map_err(|_| exit::NOPERM)?;
        if !resolved.starts_with(&pinned) {
            return Err(exit::NOPERM);
        }
    }
    Ok(resolved)
}

/// Take the shared `serve.lock` for this process's lifetime, first
/// sweeping crashed uploads when no other server holds it.
///
/// The sweep runs only while this process holds `serve.lock`
/// EXCLUSIVELY, taken without waiting: every `mkit serve` and
/// `mkit-server` holds it shared for its whole life, so while it is held
/// exclusively no streaming upload of theirs can be in flight, and a busy
/// lock (another server is up) skips the sweep. `FileTransport` writers
/// (`mkit push` to a `mkit+file://` remote) take no such lock, but write
/// each temp file in one go, so the age bound keeps theirs safe. The
/// exclusive hold is then released and the shared one taken, as before.
fn lock_and_sweep(repo_root: &Path) -> Result<RepoLock, LockError> {
    let dot_mkit = repo_root.join(".mkit");
    if let Ok(exclusive) =
        repo_lock::acquire(&dot_mkit, crate::commands::SERVE_LOCK, Duration::ZERO)
    {
        sweep_crashed_uploads(repo_root);
        drop(exclusive);
    }
    repo_lock::acquire_shared(
        &dot_mkit,
        crate::commands::SERVE_LOCK,
        repo_lock::DEFAULT_TIMEOUT,
    )
}

/// Remove the temp files (`packs/.<hex>.tmp.<pid>.<seq>`) that uploads
/// left when their process crashed, once they are [`STALE_UPLOAD_AGE`]
/// old. Best effort: a failure is ignored, and the next start retries.
fn sweep_crashed_uploads(repo_root: &Path) {
    let _ = FsBlobStore::new(repo_root).sweep_stale_uploads(STALE_UPLOAD_AGE);
}

/// `--max-session-secs`: a thread that ends the process once `max` has
/// passed. It must work while the main thread is blocked in a write to a
/// client that stopped reading, so it ends the process rather than
/// signalling the session. That is safe at any point: an upload's temp
/// file is never visible (and is swept later), every ref write is one
/// atomic rename, and the kernel releases the ref and serve locks.
fn spawn_session_cap(max: Duration) {
    let spawned = std::thread::Builder::new()
        .name("mkit-serve-session-cap".to_owned())
        .spawn(move || {
            std::thread::sleep(max);
            eprintln!(
                "mkit serve: session exceeded --max-session-secs {}; closing",
                max.as_secs()
            );
            std::process::exit(i32::from(exit::PROTOCOL_ERROR));
        });
    if let Err(e) = spawned {
        eprintln!("mkit serve: --max-session-secs is not enforced: {e}");
    }
}

/// The repository `mkit serve` serves in plain mode (see [`REPOSITORY`]).
fn repo_id() -> RepoId {
    RepoId {
        namespace: NamespaceKey::deployment_default(),
        name: RepoName::new(REPOSITORY).unwrap_or_else(|_| unreachable!("a valid repo name")),
    }
}

/// What `serve_stdio` serves and as whom.
pub(crate) struct ServeTarget {
    /// The repository's canonical on-disk root.
    pub root: PathBuf,
    /// Its storage identity: `default` in the reserved `root` namespace
    /// in plain mode; the path's `<NAMESPACE>/<NAME>` in root mode.
    pub repo: RepoId,
    /// `Open` in plain mode, `Owner` in root mode.
    pub write_policy: WritePolicy,
    /// The session's principal; `key` only from `--principal`.
    pub principal: Principal,
}

/// Serve one ssh-frame session over `input` and `output` against
/// `target`, returning the exit code: [`exit::OK`] for a clean end or
/// a failed write (the client went away), [`exit::PROTOCOL_ERROR`] for a
/// protocol error or an idle timeout. `idle` bounds the time without a byte
/// from the client (`None` disables it); a timeout is answered, best
/// effort, with `Error{INVALID_REQUEST, "idle timeout"}`.
///
/// The ref store is opened with [`FsLayoutStore::open`], which refuses a
/// root whose refs live in `mkit-server`'s `SQLite` database.
pub(crate) fn serve_stdio<R, W>(
    target: &ServeTarget,
    input: R,
    output: W,
    idle: Option<Duration>,
    stop_after_hello: bool,
) -> u8
where
    R: Read + Send + 'static,
    W: Write + Send,
{
    let meta = match FsLayoutStore::open(&target.root, &target.repo) {
        Ok(meta) => meta,
        Err(e) => {
            eprintln!("mkit serve: {e}");
            return exit::CONFIG_ERROR;
        }
    };
    let mut cfg = PipelineConfig::new(
        Addressing::Single {
            repo: target.repo.clone(),
        },
        AuthMode::TransportIdentity,
        upload_limits(),
    );
    cfg.write_policy = target.write_policy;
    let pipeline = match Pipeline::new(
        FsBlobStore::new(&target.root),
        meta,
        Hooks::new(),
        cfg,
        Arc::new(SystemClock),
        Arc::new(NoopMetrics),
    ) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("mkit serve: {}", e.public_message());
            return exit::SOFTWARE;
        }
    };
    let mut src = match StdioFrameSource::spawn(input, idle) {
        Ok(src) => src,
        Err(e) => {
            eprintln!("mkit serve: stdin reader: {e}");
            return exit::SOFTWARE;
        }
    };
    let mut sink = WriteFrames(output);
    let mut session = SessionConfig::new(format!("mkit serve/{CLI_VERSION}"));
    session.stop_after_hello = stop_after_hello;
    let end = futures::executor::block_on(serve_session(
        &pipeline,
        target.principal.clone(),
        &mut src,
        &mut sink,
        &session,
    ));
    // The reader thread may still be blocked on stdin; the process exits
    // under it.
    match end {
        SessionEnd::Clean | SessionEnd::IoError => exit::OK,
        SessionEnd::ProtocolError => exit::PROTOCOL_ERROR,
        SessionEnd::Timeout => {
            let frame = mkit_rpc::ssh_error_frame(ErrorCode::InvalidRequest, "idle timeout");
            let _ = mkit_rpc::write_frame(&mut sink.0, &frame);
            let _ = sink.0.flush();
            exit::PROTOCOL_ERROR
        }
    }
}

#[cfg(test)]
mod tests;

Files