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;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 60;
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: Option<String>,
#[arg(long, value_name = "DIR")]
root: Option<PathBuf>,
#[arg(long, value_name = "HEX", value_parser = parse_principal)]
principal: Option<[u8; 32]>,
#[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,
#[arg(
long,
value_name = "SECS",
default_value_t = 0,
value_parser = clap::value_parser!(u64).range(..=MAX_TIMEOUT_SECS)
)]
max_session_secs: u64,
}
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",
];
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)
})
}
const REPOSITORY: &str = "default";
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)
}
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,
}
}
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)?,
},
))
}
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 {
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,
}
}
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,
}
}
};
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));
}
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));
serve_stdio(
&target,
std::io::stdin(),
std::io::stdout(),
idle,
stop_after_hello,
)
}
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)
}
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,
)
}
fn sweep_crashed_uploads(repo_root: &Path) {
let _ = FsBlobStore::new(repo_root).sweep_stale_uploads(STALE_UPLOAD_AGE);
}
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}");
}
}
fn repo_id() -> RepoId {
RepoId {
namespace: NamespaceKey::deployment_default(),
name: RepoName::new(REPOSITORY).unwrap_or_else(|_| unreachable!("a valid repo name")),
}
}
pub(crate) struct ServeTarget {
pub root: PathBuf,
pub repo: RepoId,
pub write_policy: WritePolicy,
pub principal: Principal,
}
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,
));
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;