diff --git a/Cargo.lock b/Cargo.lock index 5826b40..d4b7e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2029,6 +2029,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4824,6 +4836,7 @@ version = "0.1.0" dependencies = [ "axum", "clap", + "nix", "serde", "serde_json", "tokio", diff --git a/apps/agent/Cargo.toml b/apps/agent/Cargo.toml index 53c344f..7e254c6 100644 --- a/apps/agent/Cargo.toml +++ b/apps/agent/Cargo.toml @@ -12,3 +12,4 @@ serde_json = { version = "1.0.145", features = ["std"] } serde = { version = "1.0.228", features = ["std", "derive"] } tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] } clap = { version = "4", features = ["derive"] } +nix = { version = "0.30.1", features = ["user", "fs"] } diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile index 6703c92..2501668 100644 --- a/apps/agent/Dockerfile +++ b/apps/agent/Dockerfile @@ -23,6 +23,9 @@ ENV S6_KEEP_ENV=1 ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d ENV YANPM_AGENT_SOCK_PERM=660 +ENV YANPM_AGENT_SOCK_GID="" +ENV YANPM_AGENT_UID=1000 +ENV YANPM_AGENT_GID=1000 WORKDIR /app @@ -36,23 +39,18 @@ RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && rm /tmp/s6-overlay-noarch.ta 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 +# Runtime user creation handled by s6 cont-init (see /etc/cont-init.d) +# create directory for yanpm agent socket; ownership will be fixed at container start +RUN mkdir -p /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 ./docker/s6/cont-init.d /etc/cont-init.d +RUN chmod +x /etc/services.d/*/run && chmod +x /etc/cont-init.d/* 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 +RUN chmod +x /app/yanpm-agent # s6-overlay provides /init as the init process ENTRYPOINT ["/init"] diff --git a/apps/agent/docker/s6/cont-init.d/10-create-app-user b/apps/agent/docker/s6/cont-init.d/10-create-app-user new file mode 100644 index 0000000..7e195fe --- /dev/null +++ b/apps/agent/docker/s6/cont-init.d/10-create-app-user @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +YANPM_AGENT_UID="${YANPM_AGENT_UID:-1000}" +YANPM_AGENT_GID="${YANPM_AGENT_GID:-1000}" +# If a specific socket GID is requested, prefer that for the app group +YANPM_AGENT_GID_EFFECTIVE="${YANPM_AGENT_SOCK_GID:-${YANPM_AGENT_GID}}" +YANPM_AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}" +YANPM_AGENT_GROUP="${YANPM_AGENT_GROUP:-yanpm-agent}" + +# Ensure group exists with desired GID +if grep -qE "^${YANPM_AGENT_GROUP}:" /etc/group 2>/dev/null; then + existing_gid=$(awk -F: -v g="${YANPM_AGENT_GROUP}" '$1==g{print $3}' /etc/group) + if [ "${existing_gid}" != "${YANPM_AGENT_GID_EFFECTIVE}" ]; then + delgroup "${YANPM_AGENT_GROUP}" || true + addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}" + fi +else + addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}" +fi + +# Ensure user exists with desired UID and primary group +if grep -qE "^${YANPM_AGENT_USER}:" /etc/passwd 2>/dev/null; then + existing_uid=$(awk -F: -v u="${YANPM_AGENT_USER}" '$1==u{print $3}' /etc/passwd) + if [ "${existing_uid}" != "${YANPM_AGENT_UID}" ]; then + deluser "${YANPM_AGENT_USER}" || true + adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}" + fi +else + adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}" +fi + +# Add app user to nginx group to allow reading configs +addgroup "${YANPM_AGENT_USER}" nginx || true +# Ensure runtime directories exist and fix ownership + +mkdir -p /var/run/yanpm /app +if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /var/run/yanpm 2>/dev/null; then + echo "chown: /var/run/yanpm -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /var/run/yanpm to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. This is common for bind-mounted host volumes or rootless Docker." >&2 +fi + +if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app/yanpm-agent 2>/dev/null; then + echo "chown: /app/yanpm-agent -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /app/yanpm-agent to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. Binary will still be used if permissions allow." >&2 +fi + +if chown "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app 2>/dev/null; then + echo "chown: /app -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /app to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}." >&2 +fi + +echo "App user and group setup complete. UID:${YANPM_AGENT_UID} GID:${YANPM_AGENT_GID_EFFECTIVE}" + +exit 0 diff --git a/apps/agent/docker/s6/services.d/agent/run b/apps/agent/docker/s6/services.d/agent/run index bb9d2c2..1d5a11c 100644 --- a/apps/agent/docker/s6/services.d/agent/run +++ b/apps/agent/docker/s6/services.d/agent/run @@ -1,5 +1,5 @@ #!/bin/sh -# Run the agent as the unprivileged 'app' user +# Run the agent as the unprivileged 'yanpm-agent' user cd /app echo "Starting yanpm-agent..." -exec s6-setuidgid app ./yanpm-agent +exec s6-setuidgid yanpm-agent ./yanpm-agent diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index de41b4c..b626fb4 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -18,12 +18,15 @@ use crate::routes::{status, validate, validate_and_reload, write_config}; const SOCK_ARG: &str = "sock"; const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; const SOCK_PERM_ARG: &str = "sock_perm"; +const SOCK_GID_ARG: &str = "sock_gid"; const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; const SOCK_PERM_ENV: &str = "YANPM_AGENT_SOCK_PERM"; const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; +const SOCK_GID_ENV: &str = "YANPM_AGENT_SOCK_GID"; const SOCK_DEFAULT: &str = "./yanpm-agent.sock"; const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; const SOCK_PERM_DEFAULT: &str = "660"; +const SOCK_GID_DEFAULT: &str = ""; #[tokio::main] async fn main() -> Result<(), Box> { @@ -39,7 +42,7 @@ async fn main() -> Result<(), Box> { let args = Command::new("yanpm-agent") .arg( - Arg::new("sock") + Arg::new(SOCK_ARG) .short('s') .long("sock") .value_name("SOCK_PATH") @@ -47,7 +50,7 @@ async fn main() -> Result<(), Box> { .required(false), ) .arg( - Arg::new("nginx_config_dir") + Arg::new(NGINX_CONFIG_DIR_ARG) .short('d') .long("nginx-config-dir") .value_name("NGINX_CONFIG_DIR") @@ -55,16 +58,23 @@ async fn main() -> Result<(), Box> { .required(false), ) .arg( - Arg::new("sock_perm") + Arg::new(SOCK_PERM_ARG) .long("sock-perm") .value_name("SOCK_PERM") .help("Permissions to set on the unix socket (in octal), e.g. 660") .required(false), ) + .arg( + Arg::new(SOCK_GID_ARG) + .long("sock-gid") + .value_name("SOCK_GID") + .help("GID to set on the unix socket, default: current user's primary group") + .required(false), + ) .about("YANPM Agent Daemon") .get_matches(); - let (sock, nginx_config_dir, sock_perm) = get_args(&args).await?; + let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?; let path = PathBuf::from(&sock); if let Some(dir) = path.parent() { @@ -123,6 +133,27 @@ async fn main() -> Result<(), Box> { ); } + // set socket gid to sock_gid (best-effort) + if !sock_gid.is_empty() { + use nix::unistd::{Gid, chown}; + if let Err(err) = chown( + &path, + None, + Some(Gid::from_raw( + sock_gid + .parse() + .map_err(|e| format!("Failed to parse socket GID {}: {}", sock_gid, e)) + .unwrap_or_else(|_| nix::unistd::getgid().as_raw()), + )), + ) { + error!( + "Warning: failed to set GID on socket {}: {}", + path.display(), + err + ); + } + } + let scheduler = Arc::new(tokio_cron_scheduler::JobScheduler::new().await?); let app = Router::new() @@ -145,7 +176,7 @@ async fn main() -> Result<(), Box> { async fn get_args( args: &clap::ArgMatches, -) -> Result<(String, String, u32), Box> { +) -> Result<(String, String, u32, String), Box> { let sock = args .get_one::(SOCK_ARG) .cloned() @@ -164,6 +195,13 @@ async fn get_args( std::env::var(SOCK_PERM_ENV).unwrap_or_else(|_| SOCK_PERM_DEFAULT.to_string()) }); + let sock_gid = args + .get_one::(SOCK_GID_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(SOCK_GID_ENV).unwrap_or_else(|_| SOCK_GID_DEFAULT.to_string()) + }); + if sock_perm.len() != 3 || !sock_perm.chars().all(|c| ('0'..='7').contains(&c)) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -177,8 +215,8 @@ async fn get_args( if sock_perm.chars().last().unwrap() > '0' { warn!( - "Socket permission string {} allows others to access the socket. This may be a security risk.", - sock_perm + "Socket permission string {} allows others to access the socket. This may be a security risk. Consider setting {} to a desired group and using a socket permission string that does not allow others to access the socket.", + sock_perm, SOCK_GID_ENV ); }; @@ -186,5 +224,6 @@ async fn get_args( sock, nginx_config_dir, u32::from_str_radix(&sock_perm, 8).expect("Failed to parse socket permission string"), + sock_gid, )) }