diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..95097d5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,57 @@ +name: Lint + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: "./scripts" + severity: warning + + validate-env-example: + name: Validate env.example completeness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check all template variables are documented in env.example + run: | + # Extract variables used in templates (${VAR} form) + tmpl_vars=$(grep -hoP '\$\{\K[A-Z_]+(?=\})' config/*.tmpl | sort -u) + + # Extract variable names defined in env.example + doc_vars=$(grep -oP '^[A-Z_]+(?==)' config/env.example | sort -u) + + missing="" + for var in $tmpl_vars; do + if ! echo "$doc_vars" | grep -qx "$var"; then + missing="$missing $var" + fi + done + + if [[ -n "$missing" ]]; then + echo "ERROR: The following template variables are not documented in config/env.example:" + echo "$missing" | tr ' ' '\n' | grep -v '^$' + exit 1 + fi + echo "All template variables are documented." + + validate-sudoers: + name: Validate sudoers syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check sudoers drop-in with visudo + run: visudo -cf config/sudoers.d/domain-groups diff --git a/LinuxConfig b/LinuxConfig deleted file mode 100644 index 3e46d40..0000000 --- a/LinuxConfig +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# This block defines the variable the user of the script needs to input -# when deploying using this script. -# -# -# -# HOSTNAME= -# -# -# FQDN = - -# Sets the variable $IPADDR to the IP address the new server receives. -IPADDR=$(/sbin/ifconfig eth0 | awk '/inet / { print $2 }' | sed 's/addr://') - -# Updates the packages on the system from distro repositories. -apt-get update -apt-get upgrade -y - -# Sets the hostname. -echo $HOSTNAME > /etc/hostname -hostname -F /etc/hostname - -# Sets the FQDN in the hosts file. -echo $IPADDR $FQDN $HOSTNAME >> /etc/hosts - -# Configures unattended-upgrades for automatic updates. -sudo apt install unattended-upgrades - -# Creates a non root account -adduser [username] - -# Adds non root account to sudoers -adduser [username] sudo - -# Limits SSH to IPv4 -echo 'AddressFamily inet' | sudo tee -a /etc/ssh/sshd_config - -# Restarts SSH for changes to take effect -sudo systemctl restart sshd - -# Creates a .ssh directory and sets appropriate permissions -mkdir -p ~/.ssh && chmod -R 700 ~/.ssh/ - -# Sets permissions for public key directory and file -sudo chmod -R 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys - -# TODO disable remote root, restart SSH, install fail2ban, configure a HID. diff --git a/README.md b/README.md index 481a269..b36903a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,45 @@ # LinuxConfig -Basic *nix config script. Sets hostname, FQDN, IP address, updates packages, creates a non root sudoer, limits SSH to IPv4, restarts sshd, creates a .ssh directory and sets permissions, and finally sets permissions on public key directory and file. + +A modular server build pipeline for bootstrapping RHEL-based Linux hosts into an Active Directory environment with centralized subscription management. + +## Pipeline stages + +| Script | Description | +|--------|-------------| +| `scripts/base-setup.sh` | Hostname, package updates, SSH hardening, admin user creation | +| `scripts/ad-join.sh` | AD domain join, Kerberos, SSSD, and sudo configuration | +| `scripts/satellite-register.sh` | Red Hat Satellite registration and remote management tooling | +| `scripts/ad-rejoin-cron.sh` | Daily cron job to recover domain membership if lost | + +Run all stages in sequence via: + +```bash +sudo -E ./scripts/deploy.sh +``` + +Individual scripts can be run standalone for partial deployments or re-runs of a specific stage. + +## Configuration + +Copy `config/env.example` and fill in values for your environment: + +```bash +cp config/env.example config/env +# edit config/env +set -a && source config/env && set +a +sudo -E ./scripts/deploy.sh +``` + +For CI/CD, configure values as GitHub Actions secrets and variables (see `config/env.example` for the full list and which should be treated as secrets). + +## Config templates + +`config/krb5.conf.tmpl` and `config/sssd.conf.tmpl` are applied via `envsubst` during the AD join stage — edit these to adjust Kerberos or SSSD behaviour without touching the scripts themselves. + +## Linting + +The included GitHub Actions workflow (`.github/workflows/lint.yml`) runs on every push and pull request: + +- **ShellCheck** — static analysis on all scripts +- **sudoers validation** — `visudo -c` on the sudoers drop-in +- **Template coverage** — verifies all template variables are documented in `env.example` diff --git a/config/env.example b/config/env.example new file mode 100644 index 0000000..ffd81d3 --- /dev/null +++ b/config/env.example @@ -0,0 +1,34 @@ +# Environment variable reference for LinuxConfig deployment scripts. +# +# Copy this file, fill in real values, and source it before running scripts +# locally — or configure these as GitHub Actions secrets/variables (see below). +# +# GitHub Actions secrets (sensitive — set under Settings > Secrets): +# AD_JOIN_PASS +# SATELLITE_ACTIVATION_KEY +# +# GitHub Actions variables (non-sensitive — set under Settings > Variables): +# All others below + +# ── Base setup (base-setup.sh) ─────────────────────────────────────────────── +HOSTNAME=myserver +FQDN=myserver.example.com +ADMIN_USER=sysadmin +ADMIN_SSH_KEY= # Optional: paste public key value here + +# ── Active Directory (ad-join.sh, ad-rejoin-cron.sh) ──────────────────────── +AD_DOMAIN=example.com +AD_SERVER=dc-01.example.com +KRB5_REALM=EXAMPLE.COM # Must be uppercase +AD_JOIN_USER=linux_join # Service account with domain join rights +AD_JOIN_PASS=CHANGEME # SECRET — do not commit real value + +# ── Satellite (satellite-register.sh) ─────────────────────────────────────── +SATELLITE_SERVER=satellite.example.com +SATELLITE_ORG=MyOrg +SATELLITE_ACTIVATION_KEY=CHANGEME # SECRET — do not commit real value +SATELLITE_ENV=Library # Optional: Satellite content view environment + +# ── AD rejoin cron (ad-rejoin-cron.sh) ────────────────────────────────────── +WHEEL_USER= # Optional: local user to add to wheel group +REJOIN_SCRIPT=/usr/local/sbin/ad-rejoin.sh # Default install path diff --git a/config/krb5.conf.tmpl b/config/krb5.conf.tmpl new file mode 100644 index 0000000..9d19235 --- /dev/null +++ b/config/krb5.conf.tmpl @@ -0,0 +1,34 @@ +# Kerberos 5 configuration +# Variables substituted at deploy time via envsubst: +# AD_DOMAIN, AD_SERVER, KRB5_REALM + +# To opt out of the system crypto-policies configuration of krb5, remove the +# symlink at /etc/krb5.conf.d/crypto-policies which will not be recreated. +includedir /etc/krb5.conf.d/ + +[logging] + default = FILE:/var/log/krb5libs.log + kdc = FILE:/var/log/krb5kdc.log + admin_server = FILE:/var/log/kadmind.log + +[libdefaults] + dns_lookup_realm = true + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + rdns = false + pkinit_anchors = FILE:/etc/pki/tls/certs/ca-bundle.crt + spake_preauth_groups = edwards25519 + dns_canonicalize_hostname = fallback + qualify_shortname = "" + default_ccache_name = KEYRING:persistent:%{uid} + +[realms] +${KRB5_REALM} = { + kdc = ${AD_SERVER} + admin_server = ${AD_SERVER} +} + +[domain_realm] +.${AD_DOMAIN} = ${KRB5_REALM} +${AD_DOMAIN} = ${KRB5_REALM} diff --git a/config/sssd.conf.tmpl b/config/sssd.conf.tmpl new file mode 100644 index 0000000..85e1537 --- /dev/null +++ b/config/sssd.conf.tmpl @@ -0,0 +1,23 @@ +# SSSD configuration +# Variables substituted at deploy time via envsubst: +# AD_DOMAIN, AD_SERVER, KRB5_REALM + +[sssd] +domains = ${AD_DOMAIN} +config_file_version = 2 +services = nss, pam + +[domain/${AD_DOMAIN}] +ad_server = ${AD_SERVER} +ad_domain = ${AD_DOMAIN} +krb5_realm = ${KRB5_REALM} +realmd_tags = manages-system joined-with-adcli +id_provider = ad +access_provider = ad + +cache_credentials = True +krb5_store_password_if_offline = True +ldap_id_mapping = True +use_fully_qualified_names = False +fallback_homedir = /home/%u +default_shell = /bin/bash diff --git a/config/sudoers.d/domain-groups b/config/sudoers.d/domain-groups new file mode 100644 index 0000000..7070319 --- /dev/null +++ b/config/sudoers.d/domain-groups @@ -0,0 +1,22 @@ +## Command aliases for domain group sudo rules + +Cmnd_Alias RESTRICTED = /bin/vi /etc/sudoers, \ + /bin/su - root, \ + /bin/su - , \ + /usr/sbin/visudo + +Cmnd_Alias SHELLS = /bin/sh, \ + /bin/ksh, \ + /bin/bash, \ + /bin/zsh, \ + /bin/csh, \ + /bin/tcsh, \ + /usr/bin/login, \ + /usr/bin/su + +## Domain group permissions +# LinuxAdmins: full sudo except shell escalation and sudoers editing +"%LinuxAdmins" ALL=(ALL) ALL, !SHELLS, !RESTRICTED + +# Domain Admins: unrestricted sudo +"%domain admins" ALL=(ALL) NOPASSWD: ALL diff --git a/scripts/ad-join.sh b/scripts/ad-join.sh new file mode 100644 index 0000000..d84cfc5 --- /dev/null +++ b/scripts/ad-join.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -euo pipefail +trap 'echo "ERROR: script failed at line $LINENO" >&2' ERR + +# Joins the host to Active Directory and configures Kerberos, SSSD, and sudo. +# +# Required variables: +# AD_DOMAIN AD domain name (e.g. example.com) +# AD_SERVER AD domain controller FQDN (e.g. dc-01.example.com) +# KRB5_REALM Kerberos realm (uppercase) (e.g. EXAMPLE.COM) +# AD_JOIN_USER AD account used to join (e.g. linux_join) +# AD_JOIN_PASS Password for AD_JOIN_USER + +: "${AD_DOMAIN:?AD_DOMAIN must be set}" +: "${AD_SERVER:?AD_SERVER must be set}" +: "${KRB5_REALM:?KRB5_REALM must be set}" +: "${AD_JOIN_USER:?AD_JOIN_USER must be set}" +: "${AD_JOIN_PASS:?AD_JOIN_PASS must be set}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Update packages +yum update -y + +# Crypto policy required for AD Kerberos compatibility +update-crypto-policies --set DEFAULT:AD-SUPPORT + +# Verify LDAP SRV records exist before attempting join +dig -t SRV "_ldap._tcp.${AD_DOMAIN}" +short | grep -q '.' \ + || { echo "ERROR: No LDAP SRV records found for ${AD_DOMAIN}" >&2; exit 1; } + +# Install required packages +yum install -y realmd adcli sssd sssd-tools oddjob oddjob-mkhomedir \ + krb5-workstation krb5-libs authselect + +# Apply Kerberos config from template +envsubst < "${SCRIPT_DIR}/../config/krb5.conf.tmpl" > /etc/krb5.conf + +# Join domain (idempotent) +if ! realm list | grep -q "domain-name: ${AD_DOMAIN}"; then + echo "Joining domain ${AD_DOMAIN}..." + echo "$AD_JOIN_PASS" | realm join --user="$AD_JOIN_USER" "$AD_SERVER" +else + echo "Already joined to ${AD_DOMAIN}, skipping join." +fi + +# Apply SSSD config from template +envsubst < "${SCRIPT_DIR}/../config/sssd.conf.tmpl" > /etc/sssd/sssd.conf +chown root:root /etc/sssd/sssd.conf +chmod 600 /etc/sssd/sssd.conf + +# Select SSSD auth profile with home directory creation +authselect select sssd with-mkhomedir --force + +# Enable and start SSSD +systemctl enable sssd.service +systemctl restart sssd.service + +# Install sudoers drop-in (visudo validates before install) +visudo -cf "${SCRIPT_DIR}/../config/sudoers.d/domain-groups" +install -m 0440 "${SCRIPT_DIR}/../config/sudoers.d/domain-groups" \ + /etc/sudoers.d/domain-groups + +echo "AD join complete for domain ${AD_DOMAIN}" diff --git a/scripts/ad-rejoin-cron.sh b/scripts/ad-rejoin-cron.sh new file mode 100644 index 0000000..04aba9c --- /dev/null +++ b/scripts/ad-rejoin-cron.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -euo pipefail +trap 'echo "ERROR: script failed at line $LINENO" >&2' ERR + +# Installs a daily cron job that rejoins the host to Active Directory if the +# domain membership is lost. Also adds a service account to the wheel group. +# +# Required variables: +# AD_DOMAIN AD domain name (e.g. example.com) +# AD_JOIN_USER AD account used to rejoin (e.g. linux_join) +# AD_JOIN_PASS Password for AD_JOIN_USER +# +# Optional variables: +# WHEEL_USER Local user to add to wheel group +# REJOIN_SCRIPT Path to install the rejoin script (default: /usr/local/sbin/ad-rejoin.sh) + +: "${AD_DOMAIN:?AD_DOMAIN must be set}" +: "${AD_JOIN_USER:?AD_JOIN_USER must be set}" +: "${AD_JOIN_PASS:?AD_JOIN_PASS must be set}" + +REJOIN_SCRIPT="${REJOIN_SCRIPT:-/usr/local/sbin/ad-rejoin.sh}" + +# Install required packages +yum install -y oddjob oddjob-mkhomedir sssd adcli realmd + +# Write the rejoin script (credentials come from environment at install time; +# the script stores them locally with root-only permissions) +cat > "$REJOIN_SCRIPT" </dev/null || true +sleep 1 +echo "\$AD_JOIN_PASS" | realm join --user="\$AD_JOIN_USER" "\$AD_DOMAIN" +sleep 1 + +sed -i 's/use_fully_qualified_names = True/use_fully_qualified_names = False/' /etc/sssd/sssd.conf +sed -i 's|fallback_homedir = /home/%u@%d|fallback_homedir = /home/%u|' /etc/sssd/sssd.conf + +systemctl stop sssd +systemctl start sssd +systemctl daemon-reload +EOF + +chown root:root "$REJOIN_SCRIPT" +chmod 700 "$REJOIN_SCRIPT" + +# Install cron job (idempotent) +CRON_JOB="0 0 * * * ${REJOIN_SCRIPT}" +if ! crontab -l 2>/dev/null | grep -qF "$REJOIN_SCRIPT"; then + ( crontab -l 2>/dev/null; echo "$CRON_JOB" ) | crontab - + echo "Cron job installed: ${CRON_JOB}" +else + echo "Cron job already installed, skipping." +fi + +# Add optional wheel user +if [[ -n "${WHEEL_USER:-}" ]]; then + usermod -aG wheel "$WHEEL_USER" + echo "Added ${WHEEL_USER} to wheel group." +fi + +# Run once immediately to verify domain membership +"$REJOIN_SCRIPT" + +echo "AD rejoin cron setup complete." diff --git a/scripts/base-setup.sh b/scripts/base-setup.sh new file mode 100644 index 0000000..0650d76 --- /dev/null +++ b/scripts/base-setup.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -euo pipefail +trap 'echo "ERROR: script failed at line $LINENO" >&2' ERR + +# Base system setup: hostname, packages, SSH hardening, admin user. +# +# Required variables: +# HOSTNAME Short hostname for the server +# FQDN Fully qualified domain name +# ADMIN_USER Non-root sudoer account to create +# +# Optional variables: +# ADMIN_SSH_KEY Public key to install in the admin user's authorized_keys + +: "${HOSTNAME:?HOSTNAME must be set}" +: "${FQDN:?FQDN must be set}" +: "${ADMIN_USER:?ADMIN_USER must be set}" + +# Detect primary IPv4 address (works with predictable interface names) +IPADDR=$(ip -4 addr show scope global | awk '/inet / { print $2 }' | cut -d/ -f1 | head -1) + +# Update packages +apt-get update +apt-get upgrade -y + +# Set hostname +echo "$HOSTNAME" > /etc/hostname +hostname -F /etc/hostname + +# Add FQDN to /etc/hosts (idempotent) +if ! grep -qF "$FQDN" /etc/hosts; then + echo "$IPADDR $FQDN $HOSTNAME" >> /etc/hosts +fi + +# Configure unattended-upgrades +apt-get install -y unattended-upgrades + +# Create admin user (idempotent) +if ! id "$ADMIN_USER" &>/dev/null; then + adduser --disabled-password --gecos "" "$ADMIN_USER" +fi +adduser "$ADMIN_USER" sudo + +# SSH hardening (idempotent) +if ! grep -q 'AddressFamily inet' /etc/ssh/sshd_config; then + echo 'AddressFamily inet' >> /etc/ssh/sshd_config +fi + +if ! grep -q 'PermitRootLogin no' /etc/ssh/sshd_config; then + echo 'PermitRootLogin no' >> /etc/ssh/sshd_config +fi + +systemctl restart sshd + +# Set up .ssh directory for admin user +SSH_DIR="/home/${ADMIN_USER}/.ssh" +mkdir -p "$SSH_DIR" +chmod 700 "$SSH_DIR" + +if [[ -n "${ADMIN_SSH_KEY:-}" ]]; then + echo "$ADMIN_SSH_KEY" > "${SSH_DIR}/authorized_keys" + chmod 600 "${SSH_DIR}/authorized_keys" +fi + +chown -R "${ADMIN_USER}:${ADMIN_USER}" "$SSH_DIR" + +echo "Base setup complete for ${HOSTNAME} (${FQDN})" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..1c7b4f7 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail +trap 'echo "ERROR: deploy failed at line $LINENO" >&2' ERR + +# Full server deployment pipeline. +# Sources config/env.example variable names; values must be present in the +# environment before running (injected by CI or sourced from a local env file). +# +# Individual scripts can also be run standalone for partial deployments +# or re-runs of a specific stage. +# +# Usage: +# sudo -E ./scripts/deploy.sh +# +# Stages (can be skipped by setting SKIP_=true): +# SKIP_BASE_SETUP Skip base-setup.sh +# SKIP_AD_JOIN Skip ad-join.sh +# SKIP_SATELLITE Skip satellite-register.sh +# SKIP_AD_REJOIN_CRON Skip ad-rejoin-cron.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +run_stage() { + local name="$1" + local script="$2" + local skip_var="$3" + + if [[ "${!skip_var:-false}" == "true" ]]; then + echo "--- Skipping: ${name} (${skip_var}=true)" + return + fi + + echo "" + echo "━━━ Stage: ${name} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + bash "${SCRIPT_DIR}/${script}" + echo "━━━ Done: ${name}" +} + +run_stage "Base setup" base-setup.sh SKIP_BASE_SETUP +run_stage "AD join" ad-join.sh SKIP_AD_JOIN +run_stage "Satellite" satellite-register.sh SKIP_SATELLITE +run_stage "AD rejoin cron" ad-rejoin-cron.sh SKIP_AD_REJOIN_CRON + +echo "" +echo "Deployment complete." diff --git a/scripts/satellite-register.sh b/scripts/satellite-register.sh new file mode 100644 index 0000000..fe7d1b9 --- /dev/null +++ b/scripts/satellite-register.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail +trap 'echo "ERROR: script failed at line $LINENO" >&2' ERR + +# Registers the host with a Red Hat Satellite 6/7 server using +# subscription-manager, then installs remote host management tooling. +# +# Remote management tooling is selected automatically based on what the +# Satellite server makes available: +# - rhc + rhc-worker-playbook (Satellite 6.11+, preferred) +# - katello-agent + goferd (Satellite 6.7-6.10, legacy fallback) +# +# Required variables: +# SATELLITE_SERVER Satellite server FQDN (e.g. satellite.example.com) +# SATELLITE_ORG Satellite organization name (e.g. MyOrg) +# SATELLITE_ACTIVATION_KEY Activation key name (e.g. rhel9-base) +# +# Optional variables: +# SATELLITE_ENV Content view environment (default: Library) + +: "${SATELLITE_SERVER:?SATELLITE_SERVER must be set}" +: "${SATELLITE_ORG:?SATELLITE_ORG must be set}" +: "${SATELLITE_ACTIVATION_KEY:?SATELLITE_ACTIVATION_KEY must be set}" + +SATELLITE_ENV="${SATELLITE_ENV:-Library}" + +# Install the Satellite CA certificate. This configures subscription-manager +# to trust the Satellite server and points /etc/rhsm/rhsm.conf at it. +rpm -Uvh --force \ + "http://${SATELLITE_SERVER}/pub/katello-ca-consumer-latest.noarch.rpm" + +# Register with Satellite (idempotent). +if subscription-manager status 2>/dev/null | grep -q 'Overall Status: Current'; then + echo "System already registered and current, skipping registration." +else + subscription-manager register \ + --org="${SATELLITE_ORG}" \ + --activationkey="${SATELLITE_ACTIVATION_KEY}" \ + --environment="${SATELLITE_ENV}" \ + --force +fi + +# Install remote management tooling. Prefer rhc (Satellite 6.11+); fall back +# to katello-agent if rhc is not available from this Satellite server. +if yum info rhc &>/dev/null 2>&1; then + echo "Installing rhc (Satellite 6.11+)..." + yum install -y rhc rhc-worker-playbook + rhc connect \ + --activation-key="${SATELLITE_ACTIVATION_KEY}" \ + --organization="${SATELLITE_ORG}" +else + echo "rhc not available, installing katello-agent (Satellite 6.7-6.10)..." + yum install -y katello-agent + systemctl enable --now goferd +fi + +# Confirm final subscription state +subscription-manager refresh +subscription-manager status + +echo "Satellite registration complete (org: ${SATELLITE_ORG}, key: ${SATELLITE_ACTIVATION_KEY})"