From 5cffb0a51932b0eba645a3a2e1903305d30d9cc5 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:18:36 +0800 Subject: [PATCH] feat: add nginx reload and validation wrappers with sudo permissions --- .../s6/cont-init.d/20-install-reload-wrapper | 170 ++++++++++++++++++ apps/agent/justfile | 2 + apps/agent/src/commands/reload.rs | 13 +- apps/agent/src/commands/validate.rs | 133 +++++++++++++- apps/agent/src/commands/write_config.rs | 4 +- apps/agent/src/routes.rs | 4 +- apps/container/src/main.rs | 10 +- 7 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper create mode 100644 apps/agent/justfile diff --git a/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper b/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper new file mode 100644 index 0000000..dff28f0 --- /dev/null +++ b/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper @@ -0,0 +1,170 @@ +#!/bin/sh +set -eu + +# This init script installs a minimal nginx reload wrapper and a sudoers +# entry so the `yanpm-agent` user can perform a controlled reload via sudo. + +WRAPPER_PATH="/usr/local/sbin/yanpm-nginx-reload" +SUDOERS_PATH="/etc/sudoers.d/yanpm-agent" +AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}" + +# validate wrapper +VALIDATE_PATH="/usr/local/sbin/yanpm-nginx-validate" +# validate file wrapper +VALIDATE_FILE_PATH="/usr/local/sbin/yanpm-nginx-validate-file" + +echo "[cont-init.d] install-reload-wrapper: setting up nginx reload helper" + +# find nginx binary +NGINX_BIN="$(command -v nginx || true)" +if [ -z "${NGINX_BIN}" ]; then + echo "Warning: nginx binary not found in PATH; wrapper will still be created but may fail at runtime." >&2 + NGINX_BIN="/usr/sbin/nginx" +fi + +# Create wrapper +mkdir -p /usr/local/sbin /etc/sudoers.d + +cat > "${WRAPPER_PATH}" <<- 'EOF' +#!/bin/sh +exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -s reload +EOF + +# Replace placeholder with actual path +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${WRAPPER_PATH}" || true + +chmod 0750 "${WRAPPER_PATH}" +chown root:root "${WRAPPER_PATH}" || true + +# +# +# + +# Create validate wrapper +cat > "${VALIDATE_PATH}" <<- 'EOF' +#!/bin/sh +exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -t +EOF + +# Replace placeholder with actual path in validate wrapper +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_PATH}" || true + +chmod 0750 "${VALIDATE_PATH}" +chown root:root "${VALIDATE_PATH}" || true + +# +# +# + +# Create validate file wrapper (secure) +cat > "${VALIDATE_FILE_PATH}" <<-'EOF' +#!/bin/sh +set -eu + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +INPUT="$1" + +# Resolve absolute path +if command -v readlink >/dev/null 2>&1; then + TARGET="$(readlink -f -- "$INPUT" 2>/dev/null || true)" +elif command -v realpath >/dev/null 2>&1; then + TARGET="$(realpath -- "$INPUT" 2>/dev/null || true)" +else + echo "Error: no path resolver (readlink/realpath) available" >&2 + exit 3 +fi + +if [ -z "$TARGET" ]; then + echo "Error: cannot resolve path: $INPUT" >&2 + exit 4 +fi + +# Must be a regular file and not a symlink +if [ ! -f "$TARGET" ] || [ -L "$TARGET" ]; then + echo "Error: ${TARGET} is not a regular file" >&2 + exit 5 +fi + +# must be created by agent user +AGENT_UID="$(id -u yanpm-agent 2>/dev/null || true)" +if [ -z "$AGENT_UID" ]; then + echo "Error: yanpm-agent user not found" >&2 + exit 6 +fi + +FILE_UID="$(stat -c %u -- "$TARGET" 2>/dev/null || true)" +if [ "$FILE_UID" != "$AGENT_UID" ]; then + echo "Error: ${TARGET} not owned by yanpm-agent user" >&2 + exit 7 +fi + +# Ensure file is not world-writable; allow typical 664 (rw-rw-r--) +if command -v stat >/dev/null 2>&1; then + MODE="$(stat -c %a -- "$TARGET" 2>/dev/null || true)" + if [ -n "$MODE" ]; then + OTHERS=$(( MODE % 10 )) + if [ $(( OTHERS & 2 )) -ne 0 ]; then + echo "Error: ${TARGET} is world-writable" >&2 + exit 8 + fi + fi +elif command -v find >/dev/null 2>&1; then + if find "$TARGET" -maxdepth 0 -perm /002 -print -quit >/dev/null 2>&1; then + echo "Error: ${TARGET} is world-writable" >&2 + exit 8 + fi +fi + +exec "@NGINX_BIN@" -c "$TARGET" -t +EOF + +# Replace placeholder with actual path in validate file wrapper +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_FILE_PATH}" || true +chmod 0750 "${VALIDATE_FILE_PATH}" +chown root:root "${VALIDATE_FILE_PATH}" || true + +echo "Created wrapper: ${WRAPPER_PATH} (owned by root, mode 750)" + +# +# +# + +# Ensure sudoers entry exists allowing the agent to run only this wrapper as root +if command -v sudo >/dev/null 2>&1; then + echo "sudo present; creating sudoers entry" + cat > "${SUDOERS_PATH}" <<- EOF +# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password +${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH} +EOF + chmod 0440 "${SUDOERS_PATH}" || true + echo "Wrote sudoers entry: ${SUDOERS_PATH}" +else + echo "sudo not found; attempting to install" + if command -v apk >/dev/null 2>&1; then + apk add --no-cache sudo || true + elif command -v apt-get >/dev/null 2>&1; then + apt-get update || true + apt-get install -y sudo || true + elif command -v yum >/dev/null 2>&1; then + yum install -y sudo || true + else + echo "No known package manager to install sudo; please ensure sudo is available in the image." >&2 + fi + + if command -v sudo >/dev/null 2>&1; then + cat > "${SUDOERS_PATH}" <<- EOF +# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password +${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH} +EOF + chmod 0440 "${SUDOERS_PATH}" || true + echo "Installed sudo and wrote sudoers entry: ${SUDOERS_PATH}" + else + echo "Failed to install sudo; the agent will not be able to reload nginx via sudo." >&2 + fi +fi + +exit 0 diff --git a/apps/agent/justfile b/apps/agent/justfile new file mode 100644 index 0000000..9a11a0e --- /dev/null +++ b/apps/agent/justfile @@ -0,0 +1,2 @@ +build-docker: + docker build -t yanpm/agent:latest . \ No newline at end of file diff --git a/apps/agent/src/commands/reload.rs b/apps/agent/src/commands/reload.rs index 601c6a3..9d0976f 100644 --- a/apps/agent/src/commands/reload.rs +++ b/apps/agent/src/commands/reload.rs @@ -93,6 +93,17 @@ impl ReloadCommand { } // reload the running nginx master process (no -c) so it reloads its configured main config - run_cmd("nginx", &["-s", "reload"], 10).await + // Prefer the restricted sudo wrapper if available, fall back to direct nginx reload. + // TODO: allow configuring the path to the wrapper + match run_cmd("sudo", &["-n", "/usr/local/sbin/yanpm-nginx-reload"], 10).await { + Ok(res) => Ok(res), + Err(e) => { + error!( + "sudo reload wrapper failed, falling back to direct nginx reload: {}", + e + ); + run_cmd("nginx", &["-s", "reload"], 10).await + } + } } } diff --git a/apps/agent/src/commands/validate.rs b/apps/agent/src/commands/validate.rs index ed02987..eac8f46 100644 --- a/apps/agent/src/commands/validate.rs +++ b/apps/agent/src/commands/validate.rs @@ -1,3 +1,5 @@ +use tracing::{error, info, warn}; + use crate::commands::{run::run_cmd, write_config::INTERNAL_CONFIG_FOLDER_NAME}; use std::path::PathBuf; @@ -17,7 +19,51 @@ impl ValidateCommand { pub async fn validate_all( &self, ) -> Result<(i32, String), Box> { - run_cmd("nginx", &["-t"], 10).await + // Try a normal config test first. If it fails due to pid permission + // errors (common when running unprivileged against /run/nginx.pid), + // retry with a writable pid override so validation can succeed. + match run_cmd("nginx", &["-t"], 10).await { + Ok(res) => Ok(res), + Err(e) => { + info!( + "nginx -t failed: {}. Trying with privileged wrapper or writable pid override.", + e + ); + let es = e.to_string(); + if es.contains("/run/nginx.pid") && es.contains("Permission denied") { + // Try privileged validate wrapper if available (allows the agent to run + // nginx -t via sudo without modifying the main config). + match run_cmd( + "sudo", + // TODO: allow configuring the path to the wrapper + &["-n", "/usr/local/sbin/yanpm-nginx-validate"], + 10, + ) + .await + { + Ok(res) => return Ok(res), + Err(e) => { + warn!( + "Privileged validate wrapper failed: {}. Falling back to writable pid override.", + e + ); + // Fallback to the existing writable-pid override if sudo wrapper + // isn't available or fails. + let pid_path = format!( + "{}/yanpm-validate-{}.pid", + std::env::temp_dir().display(), + std::process::id() + ); + let g_arg = format!("pid {};", pid_path); + let args_vec = ["-t".to_string(), "-g".to_string(), g_arg]; + let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); + return run_cmd("nginx", args_ref.as_slice(), 10).await; + } + } + } + Err(e) + } + } } pub async fn validate( @@ -37,11 +83,84 @@ impl ValidateCommand { return Err(format!("Config file not found: {}", full_path.display()).into()); } - run_cmd( - "nginx", - &["-t", "-c", full_path.to_str().ok_or("invalid config path")?], - 10, - ) - .await + // Create a temporary wrapper nginx config that provides the required + // top-level sections (`events` and `http`) and includes the fragment. + let fragment_path = full_path.to_str().ok_or("invalid config path")?.to_string(); + + let mut tmp_path = std::env::temp_dir(); + let tmp_name = format!("yanpm-validate-{}-{}.conf", timestamp, std::process::id()); + tmp_path.push(tmp_name); + + let wrapper = format!( + "worker_processes 1;\nevents {{ worker_connections 1024; }}\nhttp {{\n include {};\n}}\n", + fragment_path + ); + + // Write the temporary wrapper file + tokio::fs::write(&tmp_path, wrapper).await?; + let tmp_path_str = tmp_path + .to_str() + .ok_or("invalid temp config path")? + .to_string(); + + // Run the test against the wrapper, telling nginx to place its pid + // somewhere writable so the config test doesn't fail with permission + // errors when running as an unprivileged user. + let result = match run_cmd("nginx", &["-t", "-c", &tmp_path_str], 10).await { + Ok(res) => Ok(res), + Err(e) => { + info!( + "nginx -t failed: {}. Trying with privileged wrapper or writable pid override.", + e + ); + let es = e.to_string(); + if es.contains("/run/nginx.pid") && es.contains("Permission denied") { + // Try privileged validate wrapper if available (allows the agent to run + // nginx -t via sudo without modifying the main config). + match run_cmd( + "sudo", + // TODO: allow configuring the path to the wrapper + &[ + "-n", + "/usr/local/sbin/yanpm-nginx-validate-file", + &tmp_path_str, + ], + 10, + ) + .await + { + Ok(res) => return Ok(res), + Err(e) => { + warn!( + "Privileged validate wrapper failed: {}. Falling back to writable pid override.", + e + ); + let pid_path = format!( + "{}/yanpm-validate-{}.pid", + std::env::temp_dir().display(), + std::process::id() + ); + let g_arg = format!("pid {};", pid_path); + + let args_vec = [ + "-t".to_string(), + "-c".to_string(), + tmp_path_str.clone(), + "-g".to_string(), + g_arg, + ]; + let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); + + return run_cmd("nginx", args_ref.as_slice(), 10).await; + } + } + } + Err(e) + } + }; + + let _ = tokio::fs::remove_file(&tmp_path).await; + + result } } diff --git a/apps/agent/src/commands/write_config.rs b/apps/agent/src/commands/write_config.rs index 350a4e7..30c7243 100644 --- a/apps/agent/src/commands/write_config.rs +++ b/apps/agent/src/commands/write_config.rs @@ -2,6 +2,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::io::AsyncWriteExt; +use tracing::info; use crate::commands::run::to_file_name; @@ -26,6 +27,7 @@ impl WriteConfigCommand { let path = self.nginx_config_dir.clone(); // ensure main config dir exists tokio::fs::create_dir_all(&path).await?; + info!("Writing config to {:?}", path.join(&filename)); // create YANPM subdir where fragment files live let yanpm_dir = path.join(INTERNAL_CONFIG_FOLDER_NAME); @@ -62,7 +64,7 @@ impl WriteConfigCommand { // set explicit permissions (rw-r-----) tokio::fs::set_permissions(&final_path, std::fs::Permissions::from_mode(0o640)).await?; - + info!("Config written and permissions set for {:?}", final_path); Ok(()) } } diff --git a/apps/agent/src/routes.rs b/apps/agent/src/routes.rs index 77c8733..2577b1b 100644 --- a/apps/agent/src/routes.rs +++ b/apps/agent/src/routes.rs @@ -43,7 +43,7 @@ pub async fn validate( } }; - let (_code, _output) = match nginx_controller + let resp = match nginx_controller .validate(¶ms.config_name, params.timestamp) .await { @@ -54,7 +54,7 @@ pub async fn validate( } }; - (axum::http::StatusCode::OK,).into_response() + (axum::http::StatusCode::OK, axum::Json(resp)).into_response() } #[derive(Deserialize)] diff --git a/apps/container/src/main.rs b/apps/container/src/main.rs index 9cb6b2c..e75ef54 100644 --- a/apps/container/src/main.rs +++ b/apps/container/src/main.rs @@ -20,6 +20,12 @@ struct Args { db_type: String, // agent related + /// agent image name + #[arg(long, default_value = "yanpm/agent", env = "AGENT_IMAGE_NAME")] + agent_image: String, + /// agent image tag + #[arg(long, default_value = "latest", env = "AGENT_IMAGE_TAG")] + agent_image_tag: String, /// force build agent image #[arg(long, default_value_t = false, env = "AGENT_FORCE_BUILD")] agent_force_build: bool, @@ -172,8 +178,8 @@ async fn parse_args() -> ParsedArgs { db_type: args.db_type, agent_container_config: Some(AgentContainerConfig { // TODO: allow customization of these fields via CLI args - image: "yanpm-agent".to_string(), - tag: "latest".to_string(), + image: args.agent_image, + tag: args.agent_image_tag, container_name: format!("yanpm-agent-container-{}", time), dockerfile_path, force_build: args.agent_force_build,