graylog-deploy/create-graylog-lxc.sh
byrsapty d98a63c0f1 Automate the AppArmor fix with a narrowly-scoped sudoers rule
Adds bootstrap-host.sh (one-time, run as root on a fresh Proxmox host) and
fix-lxc-apparmor.sh, the fixed-content script it installs. The sudoers
rule it wires up only ever invokes that one root-owned script with a VMID
argument - deliberately not a broader rule like `tee -a <conf>` or
`sh -c '...'`, since those only restrict the command's own argv, not
stdin/heredoc content, letting the caller write arbitrary lines to any
200-299 container's config instead of just this one fixed line.

create-graylog-lxc.sh now tries `sudo -n fix-lxc-apparmor.sh` first and
falls back to the existing manual instructions if that sudoers rule isn't
present yet - fully backward compatible with hosts that haven't run
bootstrap-host.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 22:17:02 +03:00

235 lines
9.2 KiB
Bash

#!/usr/bin/env bash
# Runs on the Proxmox host as the claude-deploy user (restricted sudo: only
# pct create/set/start/stop/exec/status/list and pveam update/list/download
# for VMIDs 200-299). Creates the Graylog LXC container and triggers the
# in-container install. Idempotent: safe to re-run.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---- defaults (override via flags or env) ----------------------------------
VMID="${VMID:-}"
CT_HOSTNAME="${CT_HOSTNAME:-graylog}"
CORES="${CORES:-4}"
MEMORY_MB="${MEMORY_MB:-8192}"
SWAP_MB="${SWAP_MB:-512}"
DISK_GB="${DISK_GB:-50}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local-btrfs}"
ROOTFS_STORAGE="${ROOTFS_STORAGE:-EX-Ceph}"
BRIDGE="${BRIDGE:-vmbr0}"
VLAN_TAG="${VLAN_TAG:-}"
IP_CIDR="${IP_CIDR:-}"
GATEWAY="${GATEWAY:-}"
NAMESERVER="${NAMESERVER:-1.1.1.1}"
SEARCHDOMAIN="${SEARCHDOMAIN:-}"
TIMEZONE="${TIMEZONE:-Europe/Kyiv}"
DEBIAN_TEMPLATE_GLOB="${DEBIAN_TEMPLATE_GLOB:-debian-12-standard_*_amd64.tar.zst}"
GRAYLOG_EXTERNAL_URI="${GRAYLOG_EXTERNAL_URI:-}"
DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}"
# Colors only when stderr is an actual terminal (never garble log files/pipes).
if [ -t 2 ]; then
C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[1;31m'
else
C_RESET=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
fi
log() { echo "${C_CYAN}[create-graylog-lxc]${C_RESET} $*" >&2; }
ok() { echo "${C_GREEN}[create-graylog-lxc] ✓${C_RESET} $*" >&2; }
skip() { echo "${C_YELLOW}[create-graylog-lxc] ⏭${C_RESET} $*" >&2; }
die() { echo "${C_RED}[create-graylog-lxc] ✗ ERROR:${C_RESET} $*" >&2; exit 1; }
usage() {
cat <<EOF
Usage: $0 --ip 10.254.254.202/24 --gw 10.254.254.235 --vlan 1254 [options]
Required:
--ip <cidr> Static IP for the container, e.g. 10.254.254.202/24
--gw <ip> Gateway IP
--external-uri <url> Public URL used for GRAYLOG_HTTP_EXTERNAL_URI, e.g. http://93.171.241.5:9000/
Optional (sane defaults shown):
--vmid <200-299> default: first free VMID in 200-299
--hostname <name> default: graylog
--cores <n> default: 4
--memory <MB> default: 8192
--swap <MB> default: 512
--disk <GB> default: 50
--bridge <name> default: vmbr0
--vlan <tag> default: none (untagged)
--nameserver <ip> default: 1.1.1.1
--searchdomain <domain> default: (none)
--timezone <tz> default: Europe/Kyiv
--template-storage <s> default: local-btrfs (must support content type vztmpl)
--rootfs-storage <s> default: EX-Ceph (container disk)
--discord-webhook <url> optional, passed through to install-graylog.sh
Everything can also be set via environment variables of the same name in
UPPER_SNAKE_CASE (see top of script).
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--vmid) VMID="$2"; shift 2 ;;
--hostname) CT_HOSTNAME="$2"; shift 2 ;;
--cores) CORES="$2"; shift 2 ;;
--memory) MEMORY_MB="$2"; shift 2 ;;
--swap) SWAP_MB="$2"; shift 2 ;;
--disk) DISK_GB="$2"; shift 2 ;;
--bridge) BRIDGE="$2"; shift 2 ;;
--vlan) VLAN_TAG="$2"; shift 2 ;;
--ip) IP_CIDR="$2"; shift 2 ;;
--gw) GATEWAY="$2"; shift 2 ;;
--nameserver) NAMESERVER="$2"; shift 2 ;;
--searchdomain) SEARCHDOMAIN="$2"; shift 2 ;;
--timezone) TIMEZONE="$2"; shift 2 ;;
--template-storage) TEMPLATE_STORAGE="$2"; shift 2 ;;
--rootfs-storage) ROOTFS_STORAGE="$2"; shift 2 ;;
--external-uri) GRAYLOG_EXTERNAL_URI="$2"; shift 2 ;;
--discord-webhook) DISCORD_WEBHOOK_URL="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown argument: $1 (see --help)" ;;
esac
done
[ -n "$IP_CIDR" ] || { usage; die "--ip is required"; }
[ -n "$GATEWAY" ] || { usage; die "--gw is required"; }
[ -n "$GRAYLOG_EXTERNAL_URI" ] || { usage; die "--external-uri is required"; }
# ---------------------------------------------------------------------------
pick_free_vmid() {
local used id
used="$(sudo /usr/sbin/pct list | awk 'NR>1{print $1}')"
for id in $(seq 200 299); do
if ! echo "$used" | grep -qx "$id"; then
echo "$id"
return
fi
done
die "no free VMID in range 200-299"
}
[ -n "$VMID" ] || VMID="$(pick_free_vmid)"
case "$VMID" in
2[0-9][0-9]) ;;
*) die "VMID must be in range 200-299, got: $VMID" ;;
esac
CONTAINER_EXISTS=0
if sudo /usr/sbin/pct list | awk 'NR>1{print $1}' | grep -qx "$VMID"; then
CONTAINER_EXISTS=1
skip "container VMID $VMID already exists, reusing it"
fi
# ---------------------------------------------------------------------------
ensure_template() {
log "Checking for Debian 12 template on storage '$TEMPLATE_STORAGE'..."
local found
found="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" 2>/dev/null | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)"
if [ -n "$found" ]; then
TEMPLATE_VOLID="$found"
skip "template already cached: $TEMPLATE_VOLID"
return
fi
log "No cached template found, downloading (this can take a minute)..."
sudo /usr/bin/pveam update
local latest
latest="$(sudo /usr/bin/pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
[ -n "$latest" ] || die "could not find a debian-12-standard template in the pveam catalog"
sudo /usr/bin/pveam download "$TEMPLATE_STORAGE" "$latest"
TEMPLATE_VOLID="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)"
[ -n "$TEMPLATE_VOLID" ] || die "template download reported success but volume not found"
ok "downloaded template: $TEMPLATE_VOLID"
}
create_container() {
local net0="name=eth0,bridge=$BRIDGE,ip=${IP_CIDR},gw=${GATEWAY}"
[ -n "$VLAN_TAG" ] && net0="${net0},tag=${VLAN_TAG}"
log "Creating container $VMID ($CT_HOSTNAME) from $TEMPLATE_VOLID ..."
sudo /usr/sbin/pct create "$VMID" "$TEMPLATE_VOLID" \
--hostname "$CT_HOSTNAME" \
--cores "$CORES" \
--memory "$MEMORY_MB" \
--swap "$SWAP_MB" \
--rootfs "${ROOTFS_STORAGE}:${DISK_GB}" \
--net0 "$net0" \
--unprivileged 1 \
--features nesting=1,keyctl=1 \
--onboot 1 \
--nameserver "$NAMESERVER" \
${SEARCHDOMAIN:+--searchdomain "$SEARCHDOMAIN"} \
--timezone "$TIMEZONE" \
--description "Graylog centralized log server"
ok "container $VMID created"
}
start_container_and_wait_net() {
log "Starting container $VMID..."
sudo /usr/sbin/pct start "$VMID"
log "Waiting for network..."
local i
for i in $(seq 1 20); do
if sudo /usr/sbin/pct exec "$VMID" -- ping -c1 -W2 "$(echo "$GATEWAY")" >/dev/null 2>&1; then
ok "network is up"
return
fi
sleep 2
done
die "container did not get network connectivity within ~40s"
}
check_apparmor_hint() {
# If bootstrap-host.sh has been run on this host, the narrow sudoers rule
# for fix-lxc-apparmor.sh exists and we can just fix this ourselves.
# Otherwise fall back to the same manual instructions as before - this
# must never hard-fail the script over an optional convenience path.
if sudo -n /usr/local/sbin/fix-lxc-apparmor.sh "$VMID" 2>/dev/null; then
ok "AppArmor profile fixed automatically (bootstrap-host.sh was run on this host)"
return
fi
log "NOTE: if Docker fails inside the container with a sysctl/AppArmor permission error,"
log " the host admin must run:"
log " echo 'lxc.apparmor.profile: unconfined' >> /etc/pve/lxc/${VMID}.conf && pct reboot ${VMID}"
log " (or run bootstrap-host.sh once as root to automate this for good)"
}
copy_install_payload() {
log "Copying install payload into the container (/opt/graylog-deploy)..."
sudo /usr/sbin/pct exec "$VMID" -- mkdir -p /opt/graylog-deploy
tar czf - -C "$SCRIPT_DIR" rules streams pipelines alerts dashboards install-graylog.sh setup-forgejo-runner.sh docker-compose.yml nftables.conf \
| sudo /usr/sbin/pct exec "$VMID" -- bash -c 'tar xzf - -C /opt/graylog-deploy'
sudo /usr/sbin/pct exec "$VMID" -- chmod +x /opt/graylog-deploy/install-graylog.sh /opt/graylog-deploy/setup-forgejo-runner.sh
}
run_install() {
log "Running install-graylog.sh inside the container..."
# GRAYLOG_ADMIN_PASSWORD is only needed on a re-run after the one-time
# credentials file was deleted (as instructed in the README); forward it
# through if the operator set it, otherwise install-graylog.sh reads the
# one-time file itself on a fresh install.
sudo /usr/sbin/pct exec "$VMID" -- env \
"GRAYLOG_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI" \
"DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL" \
${GRAYLOG_ADMIN_PASSWORD:+"GRAYLOG_ADMIN_PASSWORD=$GRAYLOG_ADMIN_PASSWORD"} \
bash /opt/graylog-deploy/install-graylog.sh
}
# ---------------------------------------------------------------------------
if [ "$CONTAINER_EXISTS" -eq 0 ]; then
ensure_template
create_container
start_container_and_wait_net
check_apparmor_hint
else
sudo /usr/sbin/pct status "$VMID" | grep -q running || sudo /usr/sbin/pct start "$VMID"
fi
copy_install_payload
run_install
echo >&2
echo "${C_GREEN}==================================================${C_RESET}" >&2
echo "${C_GREEN} DONE - VMID=$VMID, Web UI: $GRAYLOG_EXTERNAL_URI${C_RESET}" >&2
echo "${C_GREEN}==================================================${C_RESET}" >&2