use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use shep_client::{Client, START_DEADLINE};
use shep_core::config::AppConfig;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{Request, Response};
use crate::cli::ServeArgs;
use crate::exit::ExitCode;
use crate::output::{FlockRows, Render, Streams, emit, write_outcome};
use crate::serve::auth::{self, AuthError, Credentials};
use crate::serve::worker::{self, ServeConfig};
#[derive(Debug)]
enum ServeRefusal {
RootUnresolvable {
root: PathBuf,
source: std::io::Error,
},
RootNotADirectory {
root: PathBuf,
},
Auth(AuthError),
MissingSpaIndex {
root: PathBuf,
},
}
impl std::fmt::Display for ServeRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RootUnresolvable { root, source } => write!(f, "{}: {source}", root.display()),
Self::RootNotADirectory { root } => write!(f, "{}: not a directory", root.display()),
Self::Auth(err) => write!(f, "{err}"),
Self::MissingSpaIndex { root } => write!(
f,
"--spa was given but {} has no index.html",
root.display()
),
}
}
}
impl core::error::Error for ServeRefusal {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::RootUnresolvable { source, .. } => Some(source),
Self::RootNotADirectory { .. } | Self::MissingSpaIndex { .. } => None,
Self::Auth(err) => Some(err),
}
}
}
impl From<AuthError> for ServeRefusal {
fn from(source: AuthError) -> Self {
Self::Auth(source)
}
}
fn refusal_exit_code(refusal: &ServeRefusal) -> ExitCode {
match refusal {
ServeRefusal::RootUnresolvable { .. } | ServeRefusal::RootNotADirectory { .. } => {
ExitCode::Usage
}
ServeRefusal::Auth(_) | ServeRefusal::MissingSpaIndex { .. } => ExitCode::InvalidConfig,
}
}
fn fail(streams: &mut Streams<'_>, refusal: &ServeRefusal) -> ExitCode {
let code = refusal_exit_code(refusal);
streams.fail(code, &refusal.to_string())
}
fn validate_root(root: &Path) -> Result<PathBuf, ServeRefusal> {
let canonical =
std::fs::canonicalize(root).map_err(|source| ServeRefusal::RootUnresolvable {
root: root.to_path_buf(),
source,
})?;
if canonical.is_dir() {
Ok(canonical)
} else {
Err(ServeRefusal::RootNotADirectory { root: canonical })
}
}
fn validate_auth(path: &Path) -> Result<(PathBuf, Credentials), ServeRefusal> {
let credentials = auth::load(path)?;
let canonical = std::fs::canonicalize(path).map_err(|source| {
ServeRefusal::Auth(AuthError::Io {
path: path.to_path_buf(),
source,
})
})?;
Ok((canonical, credentials))
}
fn exposure_notice(bind: IpAddr, auth: bool, root: &Path) -> Option<String> {
if bind.is_loopback() {
return None;
}
let root = root.display();
Some(if auth {
format!(
"shep serve: bound to {bind}, reachable from beyond this host — {root} is exposed \
to anything that can reach the port"
)
} else {
format!(
"shep serve: bound to {bind}, reachable from beyond this host, with no --auth set — \
{root}'s files will be readable by anything that can reach the port"
)
})
}
fn follow_symlinks_notice(follow_symlinks: bool) -> Option<String> {
if !follow_symlinks {
return None;
}
Some(
"shep serve: --follow-symlinks reopens the check-then-open race (TOCTOU) the default \
per-component walk closes — a symlink under the docroot can now point anywhere this \
process can read"
.to_string(),
)
}
fn sheep_args(root: &Path, auth: Option<&Path>, args: &ServeArgs) -> Vec<String> {
let mut out = vec!["serve".to_string(), root.display().to_string()];
out.push("--port".to_string());
out.push(args.port.to_string());
out.push("--bind".to_string());
out.push(args.bind.to_string());
if args.spa {
out.push("--spa".to_string());
}
if args.listing {
out.push("--listing".to_string());
}
if args.hidden {
out.push("--hidden".to_string());
}
if args.follow_symlinks {
out.push("--follow-symlinks".to_string());
}
if let Some(auth) = auth {
out.push("--auth".to_string());
out.push(auth.display().to_string());
}
out.push("--foreground".to_string());
out
}
fn default_name(root: &Path) -> String {
root.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "serve".to_string())
}
pub async fn serve(streams: &mut Streams<'_>, paths: &ShepPaths, args: &ServeArgs) -> ExitCode {
let root = match validate_root(&args.root) {
Ok(root) => root,
Err(refusal) => return fail(streams, &refusal),
};
let auth = match args.auth.as_deref() {
Some(path) => match validate_auth(path) {
Ok(auth) => Some(auth),
Err(refusal) => return fail(streams, &refusal),
},
None => None,
};
if args.spa && !root.join("index.html").is_file() {
return fail(streams, &ServeRefusal::MissingSpaIndex { root });
}
if let Some(notice) = exposure_notice(args.bind, auth.is_some(), &root) {
streams.aside("exposure", ¬ice);
}
if let Some(notice) = follow_symlinks_notice(args.follow_symlinks) {
streams.aside("follow_symlinks", ¬ice);
}
if args.foreground {
let cfg = ServeConfig {
root,
bind: SocketAddr::new(args.bind, args.port),
spa: args.spa,
listing: args.listing,
hidden: args.hidden,
auth: auth.map(|(_, credentials)| credentials),
follow_symlinks: args.follow_symlinks,
connection_deadline: worker::CONNECTION_DEADLINE,
};
return worker::run(cfg).await;
}
register(
streams,
paths,
&root,
auth.as_ref().map(|(path, _)| path.as_path()),
args,
)
.await
}
async fn register(
streams: &mut Streams<'_>,
paths: &ShepPaths,
root: &Path,
auth: Option<&Path>,
args: &ServeArgs,
) -> ExitCode {
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(source) => {
let message = format!("could not resolve this binary's own path: {source}");
return streams.fail(ExitCode::Failure, &message);
}
};
let name = args.name.clone().unwrap_or_else(|| default_name(root));
let mut app = AppConfig::minimal(&name, &exe.display().to_string());
app.args = sheep_args(root, auth, args);
app.fold.clone_from(&args.fold);
let client = match crate::connect_or_spawn_client(streams, paths).await {
Ok(client) => client,
Err(code) => return code,
};
request_and_render(
&client,
streams,
"serve",
Request::Start { apps: vec![app] },
Some(START_DEADLINE),
|response| match response {
Response::Started(procs) => Some(FlockRows(procs)),
_ => None,
},
)
.await
}
async fn request_and_render<T, F>(
client: &Client,
streams: &mut Streams<'_>,
command: &str,
body: Request,
deadline: Option<Duration>,
extract: F,
) -> ExitCode
where
T: Render,
F: FnOnce(Response) -> Option<T>,
{
match client.request_with_deadline(body, deadline).await {
Ok(response) => match extract(response) {
Some(payload) => write_outcome(emit(
&mut *streams.out,
streams.fmt,
command,
payload,
streams.style,
)),
None => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
},
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn full_args() -> ServeArgs {
ServeArgs {
root: PathBuf::from("./dist"),
port: 9000,
bind: "0.0.0.0".parse().unwrap(),
name: Some("web".into()),
fold: Some("prod".into()),
spa: true,
listing: true,
hidden: true,
follow_symlinks: true,
auth: Some(PathBuf::from("./creds")),
foreground: false,
}
}
#[test]
fn the_registered_command_line_is_absolute_and_carries_every_flag() {
let args = full_args();
let built = sheep_args(Path::new("/srv/www"), Some(Path::new("/srv/creds")), &args);
assert_eq!(built[0], "serve");
assert_eq!(built[1], "/srv/www");
assert!(built.contains(&"--foreground".to_string()));
assert!(built.contains(&"--spa".to_string()));
assert!(built.contains(&"--listing".to_string()));
assert!(built.contains(&"--hidden".to_string()));
assert!(
built.contains(&"--follow-symlinks".to_string()),
"a sheep that quietly drops this on restart silently reopens the safe default"
);
assert!(built.windows(2).any(|w| w == ["--port", "9000"]));
assert!(
built.windows(2).any(|w| w == ["--bind", "0.0.0.0"]),
"a sheep that quietly binds loopback is a silent downgrade"
);
assert!(
built.windows(2).any(|w| w == ["--auth", "/srv/creds"]),
"absolute, or the sheep crash-loops after a green registration"
);
assert!(
!built.contains(&"--name".to_string()),
"registration-time only"
);
assert!(
!built.contains(&"--fold".to_string()),
"registration-time only"
);
}
#[test]
fn the_registered_command_line_parses_back_to_the_same_arguments() {
use crate::cli::{Cli, Commands};
use clap::Parser;
let original = full_args();
let built = sheep_args(
Path::new("/srv/www"),
Some(Path::new("/srv/creds")),
&original,
);
let mut argv = vec!["shep".to_string()];
argv.extend(built);
let cli = Cli::try_parse_from(argv).expect("the line shep registers must parse");
let Commands::Serve(parsed) = cli.command else {
panic!("expected serve")
};
assert_eq!(
parsed,
ServeArgs {
root: PathBuf::from("/srv/www"),
auth: Some(PathBuf::from("/srv/creds")),
foreground: true,
name: None,
fold: None,
..original
}
);
}
#[test]
fn a_non_loopback_bind_produces_a_notice_that_names_the_address() {
use std::net::{IpAddr, Ipv4Addr};
assert!(
exposure_notice(
IpAddr::V4(Ipv4Addr::LOCALHOST),
false,
Path::new("/srv/www")
)
.is_none()
);
let notice = exposure_notice("0.0.0.0".parse().unwrap(), false, Path::new("/srv/www"))
.expect("a wider bind must say so");
assert!(notice.contains("0.0.0.0"), "{notice}");
assert!(notice.contains("/srv/www"), "{notice}");
assert!(
notice.contains("readable"),
"no auth: say what that means: {notice}"
);
let with_auth = exposure_notice("0.0.0.0".parse().unwrap(), true, Path::new("/srv/www"))
.expect("still a wider bind");
assert!(!with_auth.contains("readable"), "{with_auth}");
}
#[test]
fn follow_symlinks_produces_a_notice_that_names_the_race() {
assert!(follow_symlinks_notice(false).is_none());
let notice = follow_symlinks_notice(true).expect("the flag must say so");
assert!(notice.contains("--follow-symlinks"), "{notice}");
assert!(
notice.contains("race") || notice.contains("TOCTOU"),
"{notice}"
);
}
}