diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 0000000..ebf4b93 --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,56 @@ +FROM rust:1.92-alpine3.23 AS builder + +# Install build deps and binutils (for strip) +RUN apk add --no-cache build-base musl-dev openssl-dev pkgconfig ca-certificates curl binutils +WORKDIR /app + +# Copy manifest first to leverage Docker layer caching for dependencies +COPY ./Cargo.toml ./ +RUN cargo fetch --locked || true + +COPY ./src ./src + +# Build the release binary and strip it to reduce size +RUN cargo build --release --bin yanpm-agent && \ + strip target/release/yanpm-agent || true + +FROM nginx:mainline-alpine3.23 AS base + +# Expose typical HTTP ports used by nginx +EXPOSE 80 443 + +ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock +ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d + +WORKDIR /app + +# Install ca-certificates for TLS and minimal tools +RUN apk add --no-cache ca-certificates curl + +# Install s6-overlay +ENV S6_OVERLAY_VERSION=v3.2.1.0 +ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp +RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && rm /tmp/s6-overlay-noarch.tar.xz +ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp/s6-overlay.tar.xz +RUN tar -C / -Jxpf /tmp/s6-overlay.tar.xz && rm /tmp/s6-overlay.tar.xz + +# Create non-root user for agent and set permissions +RUN addgroup -S app && adduser -S -G app app + +# add user to nginx group to allow reading of nginx configs +RUN adduser app nginx +# create directory for yanpm agent socket +RUN mkdir -p /var/run/yanpm && chown -R app:app /var/run/yanpm + +# Copy s6 service definitions (created in repo under s6/) into image +COPY ./docker/s6/services.d /etc/services.d +RUN chmod +x /etc/services.d/*/run + +COPY --from=builder /app/target/release/yanpm-agent ./yanpm-agent + +RUN chown -R app:app /app/yanpm-agent \ + && chmod +x /app/yanpm-agent \ + && chown app:app /app + +# s6-overlay provides /init as the init process +ENTRYPOINT ["/init"] diff --git a/apps/agent/docker/s6/services.d/agent/run b/apps/agent/docker/s6/services.d/agent/run new file mode 100644 index 0000000..fb30033 --- /dev/null +++ b/apps/agent/docker/s6/services.d/agent/run @@ -0,0 +1,4 @@ +#!/bin/sh +# Run the agent as the unprivileged 'app' user +cd /app +exec s6-setuidgid app ./yanpm-agent diff --git a/apps/agent/docker/s6/services.d/nginx/run b/apps/agent/docker/s6/services.d/nginx/run new file mode 100644 index 0000000..7aa2d44 --- /dev/null +++ b/apps/agent/docker/s6/services.d/nginx/run @@ -0,0 +1,3 @@ +#!/bin/sh +# Run nginx in foreground (s6 will supervise it) +exec nginx -g 'daemon off;' diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index f977490..1c83666 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -5,7 +5,7 @@ mod routes; use axum::routing::get; use axum::{Router, routing::post}; -use clap::Parser; +use clap::{Arg, Command}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; @@ -15,33 +15,89 @@ use tracing::{error, info}; use crate::commands::NginxService; use crate::routes::{status, validate, validate_and_reload, write_config}; -#[derive(Parser)] -struct Args { - /// Unix socket path to bind the daemon to - sock: String, - - /// Directory where generated nginx config files will be written - #[arg(long, default_value = "/etc/nginx/conf.d")] - nginx_config_dir: PathBuf, -} +const SOCK_ARG: &str = "sock"; +const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; +const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; +const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; +const SOCK_DEFAULT: &str = "./yanpm-agent.sock"; +const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; #[tokio::main] async fn main() -> Result<(), Box> { - let args = Args::parse(); - let sock = args.sock; + let args = Command::new("yanpm-agent") + .arg( + Arg::new("sock") + .short('s') + .long("sock") + .value_name("SOCK_PATH") + .help("Unix socket path to bind the agent daemon to") + .required(false), + ) + .arg( + Arg::new("nginx_config_dir") + .short('d') + .long("nginx-config-dir") + .value_name("NGINX_CONFIG_DIR") + .help("Directory where generated nginx config files will be written") + .required(false), + ) + .about("YANPM Agent Daemon") + .get_matches(); + + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .with_level(true) + .with_timer(tracing_subscriber::fmt::time::SystemTime) + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + + let sock = args + .get_one::(SOCK_ARG) + .cloned() + .unwrap_or_else(|| std::env::var(SOCK_ENV).unwrap_or_else(|_| SOCK_DEFAULT.to_string())); + let nginx_config_dir = args + .get_one::(NGINX_CONFIG_DIR_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(NGINX_CONFIG_DIR_ENV) + .unwrap_or_else(|_| NGINX_CONFIG_DIR_DEFAULT.to_string()) + }); let path = PathBuf::from(&sock); if let Some(dir) = path.parent() { - tokio::fs::create_dir_all(dir).await?; + tokio::fs::create_dir_all(dir).await.unwrap_or_else(|err| { + error!( + "Warning: failed to create socket directory {}: {}", + dir.display(), + err + ) + }); // permissive; set tighter perms in production via image/build steps - tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)).await?; + tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)) + .await + .unwrap_or_else(|err| { + error!( + "Warning: failed to set permissions on socket directory {}: {}", + dir.display(), + err + ) + }); } // If an existing path exists at the socket location, ensure it's a socket match tokio::fs::metadata(&path).await { Ok(md) => { use std::os::unix::fs::FileTypeExt; if md.file_type().is_socket() { - tokio::fs::remove_file(&path).await?; + tokio::fs::remove_file(&path).await.unwrap_or_else(|err| { + error!( + "Warning: failed to remove existing socket file {}: {}", + path.display(), + err + ) + }); } else { return Err( format!("Socket path {} exists and is not a socket", path.display()).into(), @@ -49,11 +105,13 @@ async fn main() -> Result<(), Box> { } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), + Err(e) => { + return Err(format!("Failed to stat socket path {}: {}", path.display(), e).into()); + } } // bind using tokio's UnixListener (avoids converting a blocking std listener) - let listener = UnixListener::bind(&path)?; + let listener = UnixListener::bind(&path).expect("Failed to bind to unix socket"); // set socket perms to 0660 (best-effort) if let Err(err) = tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)).await @@ -72,12 +130,14 @@ async fn main() -> Result<(), Box> { .route("/validate_and_reload", post(validate_and_reload)) .route("/validate", post(validate)) .route("/write_config", post(write_config)) - .with_state(NginxService::new(scheduler.clone(), args.nginx_config_dir).await?); + .with_state(NginxService::new(scheduler.clone(), PathBuf::from(nginx_config_dir)).await?); scheduler.start().await?; info!("Starting yanpm-daemon on unix socket: {}", sock); - axum::serve::serve(listener, app).await?; + axum::serve::serve(listener, app) + .await + .expect("Failed to start axum server"); Ok(()) }