From 9d2650c7d0ae3420f79d4e78c496dd2232b40820 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 04:42:03 +0000 Subject: [PATCH] Modernize Docker-Prep for VM-Setup pinned launches Align with VM-Setup hardening: honor ephemeral env vars, verify Scripts checksums, add shell-security CI, pin Portainer to lts, and publish releases so VM-Setup can sync pins. Co-authored-by: Michael --- .github/workflows/release.yml | 69 ++++++++ .github/workflows/shell-security.yml | 30 ++++ .gitignore | 4 + README.md | 51 +++++- Scripts/.checksums.sha256 | 4 + Scripts/DockerGroup.sh | 3 + Scripts/UserCreation.sh | 4 + Scripts/portainer-install.sh | 59 +++++-- Scripts/serverSetup.sh | 10 +- install.sh | 247 +++++++++++++++++++-------- tests/security-checks.sh | 61 +++++++ tools/generate-checksums.sh | 34 ++++ 12 files changed, 485 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/shell-security.yml create mode 100644 .gitignore create mode 100644 Scripts/.checksums.sha256 create mode 100755 tests/security-checks.sh create mode 100755 tools/generate-checksums.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0606a13 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Resolve version + id: version + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version_from_script=$(awk -F= '/^VERSION=/{ gsub(/"/, "", $2); print $2; exit }' install.sh) + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "script_version=$version_from_script" >> "$GITHUB_OUTPUT" + if [[ "$tag" != "v${version_from_script}" ]]; then + echo "Tag $tag does not match install.sh VERSION=$version_from_script" >&2 + exit 1 + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + notes_file=$(mktemp) + cat > "$notes_file" < + EOF + gh release create "$TAG" \ + --title "Docker-Prep ${TAG}" \ + --notes-file "$notes_file" + + - name: Notify VM-Setup pin sync + env: + GH_TOKEN: ${{ secrets.VM_SETUP_DISPATCH_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "VM_SETUP_DISPATCH_TOKEN not configured; skipping repository_dispatch." + exit 0 + fi + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + "/repos/Narehood/VM-Setup/dispatches" \ + -f event_type='docker-prep-release' \ + -f "client_payload[tag]=${TAG}" diff --git a/.github/workflows/shell-security.yml b/.github/workflows/shell-security.yml new file mode 100644 index 0000000..6e5e346 --- /dev/null +++ b/.github/workflows/shell-security.yml @@ -0,0 +1,30 @@ +name: Shell security checks + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install analysis tools + run: sudo apt-get update && sudo apt-get install -y shellcheck ripgrep + + - name: Validate syntax and security policy + run: bash tests/security-checks.sh + + - name: Run ShellCheck + run: | + shellcheck --severity=error \ + install.sh \ + Scripts/*.sh \ + tests/*.sh \ + tools/*.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e98633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.docker-prep-pin-update.md +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md index 193e374..2796e2f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Detects your OS, installs the official Docker Engine, creates dedicated users with correct permissions,
and optionally deploys Portainer for immediate container management. -[Features](#-features) • [Quick Start](#-quick-start) • [Capabilities](#-capabilities) • [Notes](#%EF%B8%8F-important-notes) +[Features](#-features) • [Quick Start](#-quick-start) • [Capabilities](#-capabilities) • [VM-Setup](#-vm-setup-integration) • [Notes](#%EF%B8%8F-important-notes) @@ -57,6 +57,7 @@ bash install.sh | **User Management** | Creates a dedicated Docker user or configures existing users for rootless access | | **Permission Fix** | Automatically handles group assignments (`usermod -aG docker`) | | **Portainer Ready** | Option to instantly deploy [Portainer](https://www.portainer.io/) via the script | +| **Checksum Gate** | Verifies `Scripts/*.sh` against `Scripts/.checksums.sha256` before execution | | **Secure Defaults** | Ensures proper service enabling and user permission handling | --- @@ -67,10 +68,53 @@ The script guides you through a simplified menu to perform the following: | Action | Description | | :--- | :--- | -| **Install Docker Engine** | Updates repositories, installs dependencies, and sets up the Docker daemon | +| **Install Docker Engine** | Downloads the official installer to a temp file, then runs it (never `curl \| sh`) | | **User Configuration** | Creates a new user specifically for Docker or adds your current user | | **Security Groups** | Adds the selected user to the `docker` group for non-root command execution | -| **Portainer Deployment** | Pulls and runs the Portainer CE container on port 9443 | +| **Portainer Deployment** | Pulls and runs Portainer CE pinned to the `lts` image tag on port 9443 | + +--- + +## 🔗 VM-Setup Integration + +[VM-Setup](https://github.com/Narehood/VM-Setup) launches Docker-Prep from a pinned revision in a temporary checkout. + +| Variable | Purpose | +| :--- | :--- | +| `DOCKER_PREP_EPHEMERAL=1` | Marks the launch as temporary; disables self-update | +| `DOCKER_PREP_REVISION=` | Displays the pinned commit in the UI | + +Example: + +```bash +DOCKER_PREP_EPHEMERAL=1 DOCKER_PREP_REVISION= bash ./install.sh +``` + +VM-Setup syncs its pin when this repository publishes a GitHub Release. + +### Publishing a release + +1. Bump `VERSION` in `install.sh` (for example `2.4.0`). +2. Commit the change. +3. Tag and push: `git tag v2.4.0 && git push origin v2.4.0` +4. The Release workflow creates the GitHub Release and, when `VM_SETUP_DISPATCH_TOKEN` is configured, notifies VM-Setup via `repository_dispatch` (`docker-prep-release`). + +--- + +## 🔒 Integrity tooling + +Regenerate the trusted checksum manifest after changing any file under `Scripts/`: + +```bash +bash tools/generate-checksums.sh +``` + +Local validation (matches CI): + +```bash +bash tests/security-checks.sh +shellcheck --severity=error install.sh Scripts/*.sh tests/*.sh tools/*.sh +``` --- @@ -81,6 +125,7 @@ The script guides you through a simplified menu to perform the following: | **Root Access** | Script must be run as root or with `sudo` privileges | | **Re-Login Required** | Log out and back in after adding a user to the Docker group | | **Portainer** | If installed, accessible at `https://:9443` | +| **Ephemeral launches** | When launched from VM-Setup, update the pin there instead of using menu self-update | --- diff --git a/Scripts/.checksums.sha256 b/Scripts/.checksums.sha256 new file mode 100644 index 0000000..220e66b --- /dev/null +++ b/Scripts/.checksums.sha256 @@ -0,0 +1,4 @@ +7765e55bf2bd3e269ac078f7e1a10c013a0c04a6c53282f372746342cd326181 DockerGroup.sh +ff5479ac784e1e93c4fb7094cbba4d75e2188fb7fd10294166f26376052caa68 portainer-install.sh +bd647935533e2354db2ba0c2386ba3ea6c6222feed629c55048bcc0eae872f8a serverSetup.sh +dece1be99a98476bbcebdc745123a265b7bf313c8bf3191fb5ac98c2011fb52b UserCreation.sh diff --git a/Scripts/DockerGroup.sh b/Scripts/DockerGroup.sh index a5ad6cb..6f22a7c 100644 --- a/Scripts/DockerGroup.sh +++ b/Scripts/DockerGroup.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -euo pipefail # Docker Group Management Script # Adds a user to the docker group for rootless container management @@ -7,6 +8,7 @@ # DIRECTORY ANCHOR SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC2034 SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "${BASH_SOURCE[0]}")" # VISUAL STYLING @@ -49,6 +51,7 @@ fi detect_os() { OS="unknown" if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 source /etc/os-release OS="$ID" elif [[ -f /etc/redhat-release ]]; then diff --git a/Scripts/UserCreation.sh b/Scripts/UserCreation.sh index ec52fba..ad17db7 100644 --- a/Scripts/UserCreation.sh +++ b/Scripts/UserCreation.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -euo pipefail # User Creation Script # Creates a new user with optional docker group membership @@ -7,6 +8,8 @@ # DIRECTORY ANCHOR SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" +# shellcheck disable=SC2034 +SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" # VISUAL STYLING RED='\033[0;31m' @@ -48,6 +51,7 @@ fi detect_os() { OS="unknown" if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 source /etc/os-release OS="${ID,,}" elif [[ -f /etc/redhat-release ]]; then diff --git a/Scripts/portainer-install.sh b/Scripts/portainer-install.sh index 4d42708..a47cb74 100644 --- a/Scripts/portainer-install.sh +++ b/Scripts/portainer-install.sh @@ -1,12 +1,16 @@ #!/bin/bash +set -euo pipefail # Portainer CE Installation Script # Deploys Portainer using the official LTS compose file -# Version: 1.1.0 +# Version: 1.2.0 +# DESCRIPTION: Install Portainer CE using the official LTS compose file # DIRECTORY ANCHOR SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" +readonly PORTAINER_IMAGE="portainer/portainer-ce:lts" +SUDO="" # VISUAL STYLING RED='\033[0;31m' @@ -155,31 +159,51 @@ check_existing_portainer() { return 0 } +# pin_portainer_image rewrites floating Portainer image tags to the pinned LTS tag. +pin_portainer_image() { + local compose_file="$1" + + print_info "Pinning Portainer image to ${PORTAINER_IMAGE}..." + if ! $SUDO grep -qE 'image:[[:space:]]*"?portainer/portainer-ce' "$compose_file"; then + print_error "Compose file does not reference official Portainer CE image." + return 1 + fi + + $SUDO sed -i -E 's|(image:[[:space:]]*"?)portainer/portainer-ce(:[A-Za-z0-9._-]+)?("?)|\1'"${PORTAINER_IMAGE}"'\3|' "$compose_file" + if ! $SUDO grep -qE 'image:[[:space:]]*"?portainer/portainer-ce:lts"?$' "$compose_file"; then + print_error "Failed to pin Portainer image tag." + return 1 + fi + + print_success "Portainer image pinned." + return 0 +} + # validate_compose_file validates a Docker Compose file for Portainer by checking that the file exists and is non-empty, contains a Portainer service and the official `portainer/portainer-ce` image reference, and has valid YAML syntax according to `docker compose config`; returns 0 on success and 1 on failure. validate_compose_file() { local compose_file="$1" print_info "Validating compose file..." - if [ ! -f "$compose_file" ]; then + if [[ ! -f "$compose_file" ]]; then print_error "Compose file not found." return 1 fi - if [ ! -s "$compose_file" ]; then + if [[ ! -s "$compose_file" ]]; then print_error "Compose file is empty." return 1 fi # Check for expected Portainer service definition - if ! grep -q "portainer" "$compose_file"; then + if ! $SUDO grep -q "portainer" "$compose_file"; then print_error "Compose file does not contain expected Portainer service." return 1 fi - # Check for portainer image reference - if ! grep -qE "portainer/portainer-ce" "$compose_file"; then - print_error "Compose file does not reference official Portainer CE image." + # Check for pinned portainer image reference + if ! $SUDO grep -qE "image:[[:space:]]*\"?portainer/portainer-ce:lts\"?" "$compose_file"; then + print_error "Compose file does not reference pinned Portainer CE LTS image." return 1 fi @@ -202,8 +226,8 @@ deploy_portainer() { echo "" print_info "Deploying Portainer CE (LTS)..." - if [ ! -w "/opt" ]; then - if [ "$EUID" -ne 0 ]; then + if [[ ! -w "/opt" ]]; then + if [[ "$EUID" -ne 0 ]]; then print_warn "Root privileges required to create $compose_dir" read -rp " Use sudo for directory creation? (Y/n): " use_sudo use_sudo="${use_sudo:-y}" @@ -220,8 +244,7 @@ deploy_portainer() { fi print_info "Creating directory: $compose_dir" - $SUDO mkdir -p "$compose_dir" - if [ $? -ne 0 ]; then + if ! $SUDO mkdir -p "$compose_dir"; then print_error "Failed to create directory." return 1 fi @@ -236,6 +259,11 @@ deploy_portainer() { return 1 fi + if ! pin_portainer_image "$compose_file"; then + $SUDO rm -f "$compose_file" + return 1 + fi + # Validate the downloaded file if ! validate_compose_file "$compose_file"; then print_error "Downloaded compose file failed validation." @@ -247,16 +275,13 @@ deploy_portainer() { print_info "Starting Portainer containers..." echo "" - cd "$compose_dir" || return 1 - - if $SUDO docker compose -f "$compose_file" up -d; then - echo "" - print_success "Portainer deployed successfully!" - else + if ! $SUDO docker compose -f "$compose_file" up -d; then print_error "Failed to deploy Portainer." return 1 fi + echo "" + print_success "Portainer deployed successfully!" return 0 } diff --git a/Scripts/serverSetup.sh b/Scripts/serverSetup.sh index 5468fa9..f832245 100644 --- a/Scripts/serverSetup.sh +++ b/Scripts/serverSetup.sh @@ -1,7 +1,9 @@ #!/bin/sh +# DESCRIPTION: Distro-aware Docker Engine installer with optional Portainer CE +set -eu -# --- DIRECTORY ANCHOR --- -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# --- CONSTANTS --- +PORTAINER_IMAGE="portainer/portainer-ce:lts" # --- VISUAL STYLING --- RED='\033[0;31m' @@ -34,6 +36,7 @@ fi # --- OS DETECTION --- detect_os() { if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 . /etc/os-release OS=$ID VERSION=$VERSION_ID @@ -82,6 +85,7 @@ install_docker_apt() { chmod a+r /etc/apt/keyrings/docker.gpg if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 . /etc/os-release DISTRO_ID=$ID @@ -208,7 +212,7 @@ case "$install_portainer" in --name=portainer --restart=always \ -v /var/run/docker.sock:/var/run/docker.sock \ -v portainer_data:/data \ - portainer/portainer-ce:latest + "$PORTAINER_IMAGE" IP_ADDRESS=$(hostname -I | awk '{print $1}') diff --git a/install.sh b/install.sh index 546ad95..70cfe87 100644 --- a/install.sh +++ b/install.sh @@ -16,7 +16,18 @@ WHITE='\033[1;37m' NC='\033[0m' UI_WIDTH=86 -VERSION="2.3.0" +VERSION="2.4.0" + +# Ephemeral launch contract used by VM-Setup's pinned Docker-Prep launcher. +# When set, this checkout is temporary: self-updates are disabled and the +# revision display prefers DOCKER_PREP_REVISION over a branch name. +DOCKER_PREP_EPHEMERAL="${DOCKER_PREP_EPHEMERAL:-0}" +DOCKER_PREP_REVISION="${DOCKER_PREP_REVISION:-}" +CHECKSUM_FILE="$SCRIPT_DIR/Scripts/.checksums.sha256" + +is_ephemeral() { + [[ "$DOCKER_PREP_EPHEMERAL" == "1" || "$DOCKER_PREP_EPHEMERAL" == "true" ]] +} # Handle Ctrl+C gracefully trap 'echo -e "\n${GREEN}Goodbye!${NC}"; exit 0' INT @@ -55,9 +66,33 @@ truncate_string() { fi } -# get_current_branch prints the current Git branch name or "unknown" if unavailable. -get_current_branch() { - git branch --show-current 2>/dev/null || echo "unknown" +# get_revision_label prints the revision shown in the stats panel. +get_revision_label() { + if is_ephemeral && [[ -n "$DOCKER_PREP_REVISION" ]]; then + echo "pin ${DOCKER_PREP_REVISION:0:12}" + return 0 + fi + + if ! command -v git >/dev/null 2>&1 || [[ ! -d "$SCRIPT_DIR/.git" ]]; then + echo "unknown" + return 0 + fi + + local branch + branch=$(git -C "$SCRIPT_DIR" branch --show-current 2>/dev/null || true) + if [[ -n "$branch" ]]; then + echo "$branch" + return 0 + fi + + local short_rev + short_rev=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true) + if [[ -n "$short_rev" ]]; then + echo "detached $short_rev" + return 0 + fi + + echo "unknown" } show_header() { @@ -68,16 +103,21 @@ show_header() { echo -e "${BLUE}╚════██║ ╚██╔╝ ╚════██║ ██║ ██╔══╝ ██║╚██╔╝██║ ╚════██║██╔══╝ ██║ ██║ ██║██╔═══╝ ${NC}" echo -e "${BLUE}███████║ ██║ ███████║ ██║ ███████╗██║ ╚═╝ ██║ ███████║███████╗ ██║ ╚██████╔╝██║ ${NC}" echo -e "${BLUE}╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ${NC}" - print_centered "VERSION $VERSION | DOCKER HOST PREPARATION" "$CYAN" + if is_ephemeral; then + print_centered "VERSION $VERSION | EPHEMERAL PINNED LAUNCH" "$CYAN" + else + print_centered "VERSION $VERSION | DOCKER HOST PREPARATION" "$CYAN" + fi print_line "=" "$BLUE" } show_stats() { # OS Detection local distro="Unknown" - if [ -f /etc/os-release ]; then + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 . /etc/os-release - if [ "$ID" = "alpine" ]; then + if [[ "$ID" == "alpine" ]]; then distro="Alpine ${VERSION_ID:-}" else distro="${PRETTY_NAME:-$ID}" @@ -162,9 +202,9 @@ show_stats() { gateway=$(truncate_string "${gateway:-N/A}" 20) fi - # Current branch - local current_branch - current_branch=$(truncate_string "$(get_current_branch)" 30) + # Revision / branch label + local revision_label + revision_label=$(truncate_string "$(get_revision_label)" 30) # Docker status local docker_status="Not Installed" @@ -205,84 +245,162 @@ show_stats() { print_line "-" "$BLUE" printf " ${YELLOW}%-11s${NC} : %-30b ${YELLOW}%-11s${NC} : %b\n" "Docker" "$docker_status" "Compose" "$compose_status" print_line "-" "$BLUE" - printf " ${YELLOW}%-11s${NC} : %-30s\n" "Branch" "$current_branch" + printf " ${YELLOW}%-11s${NC} : %-30s\n" "Revision" "$revision_label" print_line "=" "$BLUE" } +# verify_script_checksum verifies a Scripts/ entry against Scripts/.checksums.sha256. +verify_script_checksum() { + local script_path="$1" + local script_name + script_name=$(basename "$script_path") + + if [[ ! -f "$CHECKSUM_FILE" ]]; then + print_error "Trusted checksum manifest is missing: $CHECKSUM_FILE" + return 1 + fi + + if ! command -v sha256sum >/dev/null 2>&1; then + print_error "sha256sum is required for integrity verification." + return 1 + fi + + local expected_hash match_count + match_count=$(awk -v name="$script_name" '$2 == name { count++ } END { print count+0 }' "$CHECKSUM_FILE") + if [[ "$match_count" -ne 1 ]]; then + print_error "Checksum manifest must contain exactly one entry for $script_name" + return 1 + fi + expected_hash=$(awk -v name="$script_name" '$2 == name { print $1 }' "$CHECKSUM_FILE") + + if [[ ! "$expected_hash" =~ ^[[:xdigit:]]{64}$ ]]; then + print_error "Invalid checksum entry for $script_name" + return 1 + fi + + local actual_hash + actual_hash=$(sha256sum "$script_path" 2>/dev/null | awk '{print $1}' || true) + + if [[ "$expected_hash" != "$actual_hash" ]]; then + print_error "Checksum verification FAILED for $script_name" + print_error "Expected: $expected_hash" + print_error "Got: ${actual_hash:-}" + print_error "Refusing to execute modified or corrupted script." + return 1 + fi + + print_success "Checksum verified for $script_name" + return 0 +} + check_for_updates() { echo "" print_status "Checking for updates..." + if is_ephemeral; then + print_warn "This is an ephemeral pinned launch from VM-Setup." + print_status "Self-updates are disabled here. Update the Docker-Prep pin in VM-Setup instead." + if [[ -n "$DOCKER_PREP_REVISION" ]]; then + print_status "Pinned revision: $DOCKER_PREP_REVISION" + fi + sleep 2 + return 0 + fi + if ! command -v git >/dev/null 2>&1; then print_error "Git is not installed." sleep 2 return 1 fi - if [ ! -d "$SCRIPT_DIR/.git" ]; then + if [[ ! -d "$SCRIPT_DIR/.git" ]]; then print_warn "Not a git repository. Skipping update check." sleep 2 return 1 fi - if ! git fetch --quiet 2>/dev/null; then + local branch + branch=$(git -C "$SCRIPT_DIR" branch --show-current 2>/dev/null || true) + if [[ -z "$branch" ]]; then + print_warn "Detached HEAD checkout. Skipping self-update." + print_status "Check out a branch (for example main) to enable updates." + sleep 2 + return 1 + fi + + if ! git -C "$SCRIPT_DIR" fetch --quiet 2>/dev/null; then print_error "Failed to fetch from remote." sleep 2 return 1 fi local local_rev remote_rev - local_rev=$(git rev-parse @ 2>/dev/null) + local_rev=$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true) - if ! remote_rev=$(git rev-parse '@{u}' 2>/dev/null); then - print_error "No upstream branch configured." + if ! remote_rev=$(git -C "$SCRIPT_DIR" rev-parse '@{u}' 2>/dev/null); then + print_warn "No upstream branch configured for '$branch'." + print_status "Set upstream with: git branch --set-upstream-to=origin/$branch" sleep 2 return 1 fi - if [ "$local_rev" = "$remote_rev" ]; then + if [[ "$local_rev" == "$remote_rev" ]]; then print_success "Menu is up to date." sleep 1 - else - print_warn "New version available." - read -rp "Download and apply updates? (y/N): " pull_choice - pull_choice="${pull_choice:-n}" - if [[ "$pull_choice" =~ ^[Yy]$ ]]; then - if git pull --quiet; then - print_success "Updated successfully. Restarting..." - sleep 1 - exec bash "$SCRIPT_PATH" - else - print_error "Update failed. Try 'git pull' manually." - sleep 2 - fi - else - print_status "Update skipped." + return 0 + fi + + print_warn "New version available." + read -rp "Download and apply updates? (y/N): " pull_choice + pull_choice="${pull_choice:-n}" + if [[ "$pull_choice" =~ ^[Yy]$ ]]; then + if git -C "$SCRIPT_DIR" pull --ff-only --quiet; then + print_success "Updated successfully. Restarting..." sleep 1 + exec bash "$SCRIPT_PATH" + else + print_error "Update failed. Try 'git pull --ff-only' manually." + sleep 2 + return 1 fi + else + print_status "Update skipped." + sleep 1 fi } execute_script() { local script_name="$1" - local script_path="Scripts/$script_name" + local script_path="$SCRIPT_DIR/Scripts/$script_name" echo "" - if [ ! -f "$script_path" ]; then + if [[ ! -f "$script_path" ]]; then print_error "Script '$script_name' not found in 'Scripts/' directory." pause return 1 fi - if [ ! -r "$script_path" ]; then + if [[ -L "$script_path" ]]; then + print_error "Refusing to execute symlinked script: $script_path" + pause + return 1 + fi + + if [[ ! -r "$script_path" ]]; then print_error "Script '$script_name' not readable." pause return 1 fi + if ! verify_script_checksum "$script_path"; then + print_error "Script verification failed. Aborting." + pause + return 1 + fi + # Check if executable - if [ ! -x "$script_path" ]; then + if [[ ! -x "$script_path" ]]; then print_warn "Script is not executable." read -rp " Make it executable? (Y/n): " response response="${response:-y}" @@ -301,7 +419,7 @@ execute_script() { # Execute in subshell to maintain directory context ( - cd Scripts || exit 1 + cd "$SCRIPT_DIR/Scripts" || exit 1 bash "$script_name" ) local exit_code=$? @@ -309,12 +427,12 @@ execute_script() { echo "" print_line "-" "$BLUE" - if [ $exit_code -ne 0 ]; then + if [[ $exit_code -ne 0 ]]; then print_warn "Script exited with code: $exit_code" fi read -rp "Press [Enter] to return to menu or type 'exit': " next_action - if [ "$next_action" = "exit" ]; then + if [[ "$next_action" == "exit" ]]; then echo -e "\n${GREEN}Goodbye!${NC}" exit 0 fi @@ -324,24 +442,10 @@ install_docker() { echo "" print_status "Installing Docker Engine..." - # Detect package manager and OS - local pkg_manager="" - local os_id="" - - if [ -f /etc/os-release ]; then - . /etc/os-release - os_id="$ID" - fi - - if command -v apt-get >/dev/null 2>&1; then - pkg_manager="apt" - elif command -v dnf >/dev/null 2>&1; then - pkg_manager="dnf" - elif command -v yum >/dev/null 2>&1; then - pkg_manager="yum" - elif command -v apk >/dev/null 2>&1; then - pkg_manager="apk" - else + if ! command -v apt-get >/dev/null 2>&1 \ + && ! command -v dnf >/dev/null 2>&1 \ + && ! command -v yum >/dev/null 2>&1 \ + && ! command -v apk >/dev/null 2>&1; then print_error "Unsupported package manager." pause return 1 @@ -361,8 +465,9 @@ install_docker() { fi fi + local SUDO="" # Check for root - if [ "$EUID" -ne 0 ]; then + if [[ "$EUID" -ne 0 ]]; then print_warn "This operation requires root privileges." read -rp " Run with sudo? (Y/n): " use_sudo use_sudo="${use_sudo:-y}" @@ -371,21 +476,21 @@ install_docker() { pause return 0 fi - local SUDO="sudo" - else - local SUDO="" + SUDO="sudo" fi print_status "Using official Docker installation script..." print_line "-" "$BLUE" - # Use Docker's convenience script - if curl -fsSL https://get.docker.com -o /tmp/get-docker.sh; then - $SUDO sh /tmp/get-docker.sh - local exit_code=$? - rm -f /tmp/get-docker.sh + # Download the installer to a temp file, then execute that file. + local installer_path + installer_path=$(mktemp "${TMPDIR:-/tmp}/get-docker.XXXXXXXX.sh") + if curl -fsSL https://get.docker.com -o "$installer_path"; then + local exit_code=0 + $SUDO sh "$installer_path" || exit_code=$? + rm -f "$installer_path" - if [ $exit_code -eq 0 ]; then + if [[ $exit_code -eq 0 ]]; then print_success "Docker installed successfully!" # Enable and start Docker @@ -398,6 +503,7 @@ install_docker() { print_error "Docker installation failed." fi else + rm -f "$installer_path" print_error "Failed to download Docker installation script." fi @@ -532,9 +638,14 @@ declare -A MENU_OPTIONS=( # show_menu displays the interactive Docker configuration menu with numbered options for installing Docker, adding a user to the docker group, installing Portainer, viewing Docker system info, checking for updates, and returning to the main menu. show_menu() { + local update_label="Check for Updates" + if is_ephemeral; then + update_label="Pinned Launch Info" + fi + echo -e "${WHITE}DOCKER CONFIGURATION${NC}" printf " ${CYAN}1.${NC} %-43s ${CYAN}4.${NC} %s\n" "Install Docker Engine" "Docker System Info" - printf " ${CYAN}2.${NC} %-43s ${CYAN}5.${NC} %s\n" "Add User to Docker Group" "Check for Updates" + printf " ${CYAN}2.${NC} %-43s ${CYAN}5.${NC} %s\n" "Add User to Docker Group" "$update_label" printf " ${CYAN}3.${NC} %s\n" "Install Portainer" echo "" printf " ${CYAN}0.${NC} ${RED}%s${NC}\n" "Return to Main Menu" diff --git a/tests/security-checks.sh b/tests/security-checks.sh new file mode 100755 index 0000000..0d7ea1e --- /dev/null +++ b/tests/security-checks.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +for script in install.sh Scripts/*.sh tests/*.sh tools/*.sh; do + bash -n "$script" +done + +if [[ ! -f Scripts/.checksums.sha256 ]]; then + echo "Missing Scripts/.checksums.sha256" >&2 + exit 1 +fi + +( + cd Scripts + sha256sum --check --strict .checksums.sha256 +) + +if rg -n 'curl[^|]*\|[[:space:]]*(ba)?sh' --glob '*.sh' .; then + echo "Direct curl-to-shell execution is forbidden." >&2 + exit 1 +fi + +if ! grep -q 'DOCKER_PREP_EPHEMERAL' install.sh; then + echo "install.sh must honor DOCKER_PREP_EPHEMERAL." >&2 + exit 1 +fi + +if ! grep -q 'DOCKER_PREP_REVISION' install.sh; then + echo "install.sh must honor DOCKER_PREP_REVISION." >&2 + exit 1 +fi + +if ! grep -q 'is_ephemeral' install.sh; then + echo "install.sh must define is_ephemeral for pinned launches." >&2 + exit 1 +fi + +if ! grep -q 'verify_script_checksum' install.sh; then + echo "install.sh must verify Scripts checksums before execution." >&2 + exit 1 +fi + +if ! grep -Eq '^VERSION="2\.[0-9]+\.[0-9]+"' install.sh; then + echo "install.sh must define a semver VERSION string." >&2 + exit 1 +fi + +if rg -n 'git clean[[:space:]]+-[^[:space:]]*f' install.sh; then + echo "Broad destructive git clean is forbidden." >&2 + exit 1 +fi + +if rg -n 'portainer/portainer-ce:latest' Scripts/*.sh; then + echo "Portainer image must not use the bare :latest tag." >&2 + exit 1 +fi + +echo "Security checks passed." diff --git a/tools/generate-checksums.sh b/tools/generate-checksums.sh new file mode 100755 index 0000000..cf6feff --- /dev/null +++ b/tools/generate-checksums.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regenerates Scripts/.checksums.sha256 using GNU two-space sha256sum format. + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +scripts_dir="$repo_root/Scripts" +manifest="$scripts_dir/.checksums.sha256" +temporary=$(mktemp "$scripts_dir/.checksums.sha256.XXXXXXXX") + +cleanup() { + rm -f -- "$temporary" +} +trap cleanup EXIT + +shopt -s nullglob +scripts=("$scripts_dir"/*.sh) +if [[ ${#scripts[@]} -eq 0 ]]; then + echo "No Scripts/*.sh files found; manifest was not changed." >&2 + exit 1 +fi + +for script in "${scripts[@]}"; do + sha256sum "$script" | awk -v name="$(basename "$script")" '{ printf "%s %s\n", $1, name }' +done | sort -k2 > "$temporary" + +if [[ ! -s "$temporary" ]]; then + echo "Checksum generation produced an empty manifest." >&2 + exit 1 +fi + +mv -- "$temporary" "$manifest" +trap - EXIT +echo "Updated $manifest"