#!/bin/bash # certctl Agent Install Script # Detects OS (Linux/macOS) and architecture, downloads binary from GitHub Releases, # installs to system path, configures service (systemd/launchd), and prompts for config. set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Configuration GITHUB_REPO="certctl-io/certctl" RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download" INSTALL_DIR="/usr/local/bin" SERVICE_NAME="certctl-agent" # Detect OS and architecture detect_platform() { local os="$(uname -s)" local arch="$(uname -m)" case "$os" in Linux*) OS_TYPE="linux" ;; Darwin*) OS_TYPE="darwin" ;; *) echo -e "${RED}Error: Unsupported OS: $os${NC}" exit 1 ;; esac case "$arch" in x86_64) ARCH_TYPE="amd64" ;; aarch64|arm64) ARCH_TYPE="arm64" ;; *) echo -e "${RED}Error: Unsupported architecture: $arch${NC}" exit 1 ;; esac } # Print usage information usage() { cat <&2 exit 1 fi shift 2 ;; --server-url=*) SERVER_URL="${1#*=}" shift ;; --api-key) API_KEY="${2:-}" if [[ -z "$API_KEY" ]]; then echo -e "${RED}Error: --api-key requires a value${NC}" >&2 exit 1 fi shift 2 ;; --api-key=*) API_KEY="${1#*=}" shift ;; --agent-id) AGENT_ID="${2:-}" if [[ -z "$AGENT_ID" ]]; then echo -e "${RED}Error: --agent-id requires a value${NC}" >&2 exit 1 fi shift 2 ;; --agent-id=*) AGENT_ID="${1#*=}" shift ;; --no-start) NO_START=true shift ;; *) echo -e "${RED}Error: Unknown option: $1${NC}" >&2 usage exit 1 ;; esac done } # Ensure stdin is interactive before prompting. When the script is piped via # curl|bash, stdin is the pipe from curl, so `read` hits EOF immediately and # set -e aborts the script silently. Reopen stdin from the controlling terminal # (/dev/tty) if available; otherwise print a helpful error pointing at the # flag-based non-interactive install. ensure_interactive_input() { # If all required config is already provided via flags, no prompting needed. if [[ -n "${SERVER_URL:-}" && -n "${API_KEY:-}" ]]; then return fi # Already interactive — nothing to do. if [[ -t 0 ]]; then return fi # Piped stdin — try to reopen from the controlling terminal. Actually # attempt to open /dev/tty inside a subshell: the device node may exist # even when the process has no controlling terminal (ENXIO on open), so # `[[ -r /dev/tty ]]` is not reliable. if ( exec /dev/null; then exec &2 exit 1 } # Check if running as root/sudo on Linux check_privileges() { if [[ "$OS_TYPE" == "linux" && "$EUID" -ne 0 ]]; then echo -e "${RED}Error: This script must be run as root on Linux. Try: sudo $0${NC}" exit 1 fi } # Download agent binary from GitHub Releases # IMPORTANT: main() captures this function's stdout via `binary_path=$(download_binary)`, # so every status/error message MUST go to stderr (>&2). Only the final # `echo "$temp_file"` is allowed on stdout — that's the return value. # # We deliberately do NOT register an EXIT trap to clean up $temp_file: because # of the command substitution, this function runs in a subshell, and any EXIT # trap set here fires when the subshell exits — which is *before* install_binary # gets a chance to cp the file. Cleanup on success is install_binary's job # (after the cp), and cleanup on curl failure is handled inline below. download_binary() { local binary_name="certctl-agent-${OS_TYPE}-${ARCH_TYPE}" local download_url="${RELEASE_URL}/${binary_name}" echo -e "${YELLOW}Downloading certctl agent (${OS_TYPE}-${ARCH_TYPE})...${NC}" >&2 if ! command -v curl &> /dev/null; then echo -e "${RED}Error: curl is required but not installed${NC}" >&2 exit 1 fi local temp_file temp_file=$(mktemp) if ! curl -sSL -f "$download_url" -o "$temp_file" >&2; then rm -f "$temp_file" echo -e "${RED}Error: Failed to download binary from $download_url${NC}" >&2 echo "Make sure the latest release exists on GitHub with the binary asset for ${OS_TYPE}-${ARCH_TYPE}." >&2 exit 1 fi chmod +x "$temp_file" echo "$temp_file" } # Install binary to system path install_binary() { local binary_path="$1" echo -e "${YELLOW}Installing to $INSTALL_DIR/$SERVICE_NAME...${NC}" if [[ "$OS_TYPE" == "linux" ]]; then cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME" else # macOS: use sudo if not already running as root if [[ "$EUID" -ne 0 ]]; then sudo cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME" else cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME" fi fi chmod +x "$INSTALL_DIR/$SERVICE_NAME" echo -e "${GREEN}Binary installed: $INSTALL_DIR/$SERVICE_NAME${NC}" # Clean up the temp file created by download_binary. We can't use an EXIT # trap inside download_binary because it runs in a subshell (command # substitution), so the trap would fire before we got here. Doing it # explicitly after the successful cp is the simplest correct pattern. rm -f "$binary_path" } # Prompt for configuration. Any value supplied via flag is honored as-is # and we only prompt for the missing pieces. `read || true` prevents set -e # from aborting the script on EOF — instead the empty check below fires the # proper "required" error message. prompt_for_config() { if [[ -z "${SERVER_URL:-}" ]]; then echo "" echo -e "${YELLOW}Enter certctl server URL (e.g., https://certctl.example.com):${NC}" read -r SERVER_URL || true if [[ -z "${SERVER_URL:-}" ]]; then echo -e "${RED}Error: Server URL is required${NC}" >&2 echo "Hint: pass --server-url to run non-interactively." >&2 exit 1 fi fi if [[ -z "${API_KEY:-}" ]]; then echo -e "${YELLOW}Enter certctl API key:${NC}" read -rs API_KEY || true echo "" if [[ -z "${API_KEY:-}" ]]; then echo -e "${RED}Error: API key is required${NC}" >&2 echo "Hint: pass --api-key to run non-interactively." >&2 exit 1 fi fi if [[ -z "${AGENT_ID:-}" ]]; then local default_agent_id default_agent_id="$(hostname)" # If stdin is still piped (no /dev/tty was available but SERVER_URL + # API_KEY arrived via flags), skip the prompt entirely and use the # default — no need to block on an optional value. if [[ -t 0 ]]; then echo -e "${YELLOW}Enter agent ID (default: $default_agent_id):${NC}" read -r AGENT_ID || true fi if [[ -z "${AGENT_ID:-}" ]]; then AGENT_ID="$default_agent_id" fi fi } # Create configuration directory and env file (Linux) setup_linux_config() { local config_dir="/etc/certctl" local config_file="$config_dir/agent.env" local key_dir="/var/lib/certctl/keys" echo -e "${YELLOW}Creating configuration directory...${NC}" # Create /etc/certctl with restrictive permissions mkdir -p "$config_dir" chmod 755 "$config_dir" # Create key storage directory with 0700 permissions mkdir -p "$key_dir" chmod 700 "$key_dir" # Write agent configuration (overwrite if exists) cat > "$config_file" < "$config_file" < "$service_file" <<'EOF' [Unit] Description=certctl Agent - Certificate Lifecycle Management Documentation=https://github.com/certctl-io/certctl After=network-online.target Wants=network-online.target [Service] Type=simple User=root Restart=on-failure RestartSec=10s StandardOutput=journal StandardError=journal # Load environment from /etc/certctl/agent.env EnvironmentFile=/etc/certctl/agent.env # Command to start the agent ExecStart=/usr/local/bin/certctl-agent [Install] WantedBy=multi-user.target EOF chmod 644 "$service_file" echo -e "${GREEN}Service file created: $service_file${NC}" # Reload systemd daemon systemctl daemon-reload } # Create and enable launchd plist (macOS only) setup_launchd_service() { local plist_file="$HOME/Library/LaunchAgents/com.certctl.agent.plist" local config_file="$HOME/.certctl/agent.env" local launcher_script="$HOME/.certctl/launcher.sh" local home_dir="$HOME" echo -e "${YELLOW}Creating launchd service file...${NC}" mkdir -p "$(dirname "$plist_file")" # Create wrapper script that sources env file before executing agent cat > "$launcher_script" <<'LAUNCHER_SCRIPT' #!/bin/bash set -a source "$HOME/.certctl/agent.env" set +a exec /usr/local/bin/certctl-agent LAUNCHER_SCRIPT chmod 755 "$launcher_script" # Create plist that references the launcher script cat > "$plist_file" < Label com.certctl.agent ProgramArguments $home_dir/.certctl/launcher.sh EnvironmentVariables PATH /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin HOME $home_dir KeepAlive RunAtLoad StandardErrorPath $home_dir/.certctl/agent.log StandardOutPath $home_dir/.certctl/agent.log EOF chmod 644 "$plist_file" echo -e "${GREEN}Service file created: $plist_file${NC}" echo -e "${GREEN}Launcher script created: $launcher_script${NC}" } # Start the agent service start_service() { if [[ "${NO_START:-false}" == "true" ]]; then echo -e "${YELLOW}Service not started (--no-start flag used)${NC}" return fi echo -e "${YELLOW}Starting certctl agent service...${NC}" if [[ "$OS_TYPE" == "linux" ]]; then systemctl enable "$SERVICE_NAME" systemctl start "$SERVICE_NAME" sleep 2 if systemctl is-active --quiet "$SERVICE_NAME"; then echo -e "${GREEN}Service started successfully${NC}" else echo -e "${RED}Warning: Service may not have started. Check logs with: systemctl status $SERVICE_NAME${NC}" fi else # macOS: load launchd service for current user launchctl load "$HOME/Library/LaunchAgents/com.certctl.agent.plist" 2>/dev/null || true sleep 1 echo -e "${GREEN}Service loaded into launchd${NC}" fi } # Print success message with next steps print_summary() { echo "" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}certctl Agent Installation Complete${NC}" echo -e "${GREEN}========================================${NC}" echo "" echo "Configuration:" if [[ "$OS_TYPE" == "linux" ]]; then echo " Config file: /etc/certctl/agent.env" echo " Key storage: /var/lib/certctl/keys" echo " Service: /etc/systemd/system/${SERVICE_NAME}.service" echo " View logs: journalctl -u ${SERVICE_NAME} -f" else echo " Config file: $HOME/.certctl/agent.env" echo " Key storage: $HOME/.certctl/keys" echo " Service: $HOME/Library/LaunchAgents/com.certctl.agent.plist" echo " View logs: tail -f $HOME/.certctl/agent.log" fi echo "" echo "Next steps:" echo " 1. Verify the service is running" if [[ "$OS_TYPE" == "linux" ]]; then echo " systemctl status ${SERVICE_NAME}" else echo " launchctl list | grep certctl" fi echo "" echo " 2. Visit your certctl dashboard: $SERVER_URL" echo " 3. The agent should appear in the fleet overview within 30 seconds" echo "" } # Main installation flow main() { parse_args "$@" detect_platform check_privileges echo -e "${GREEN}certctl Agent Installer${NC}" echo "Detected platform: ${OS_TYPE}-${ARCH_TYPE}" echo "" ensure_interactive_input prompt_for_config # Download and install binary local binary_path binary_path=$(download_binary) install_binary "$binary_path" # Setup OS-specific configuration if [[ "$OS_TYPE" == "linux" ]]; then setup_linux_config setup_systemd_service else setup_macos_config setup_launchd_service fi # Start the service start_service # Print summary print_summary } main "$@"