graylog-deploy/install-graylog.sh
byrsapty d76f1e766b Fix the same update-gap bug in step_streams()/step_dashboard() as rules/pipelines/alerts
Both only checked title existence, so editing a stream's rules or a
dashboard's widgets under the same title silently skipped the update
forever. step_dashboard() now diffs the search's queries content
(delete+recreate view+search on change - no subscribers/history to lose,
unlike alerts or streams); step_streams() diffs rules individually by
(field,type,value,inverted) and PUT-updates metadata in place, since
delete+recreating a stream would briefly break live message routing.

Confirmed live on a local test deployment: a false "changed" positive
from search_types list-reordering and from search_servers.json's own
query-format inconsistency (bare string vs the {query_string,type} object
Graylog actually stores) had to be fixed before step_dashboard() settled
into a stable no-op; step_streams() was verified with a full inert
rule-add/remove and a metadata-change round-trip against the live API.
2026-07-23 21:00:06 +03:00

785 lines
35 KiB
Bash

#!/usr/bin/env bash
# Runs INSIDE the Graylog LXC container as root (invoked via `pct exec <vmid> -- bash install-graylog.sh`).
# Idempotent: safe to re-run after a partial failure.
set -euo pipefail
# The container inherits LANG=en_US.UTF-8 from the calling shell (pct exec),
# but that locale is never generated here - just noisy "Setting locale
# failed" warnings from perl/apt-listchanges on every apt-get call. C.UTF-8
# is glibc-builtin (no locale-gen needed) and silences them.
export LC_ALL=C.UTF-8 LANG=C.UTF-8
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="/opt/graylog"
GRAYLOG_EXTERNAL_URI="${GRAYLOG_EXTERNAL_URI:?Set GRAYLOG_EXTERNAL_URI, e.g. http://93.171.241.5:9000/}"
DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}"
ADMIN_USER="admin"
# 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}[install-graylog]${C_RESET} $*" >&2; }
ok() { echo "${C_GREEN}[install-graylog] ✓${C_RESET} $*" >&2; }
skip() { echo "${C_YELLOW}[install-graylog] ⏭${C_RESET} $*" >&2; }
die() { echo "${C_RED}[install-graylog] ✗ ERROR:${C_RESET} $*" >&2; exit 1; }
require_root() {
[ "$(id -u)" -eq 0 ] || die "must run as root inside the container (use pct exec)"
}
# ---------------------------------------------------------------------------
step_packages() {
log "Installing base packages (curl, gnupg, python3, jq, nftables)..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg python3 jq nftables >/dev/null
}
step_disable_ipv6() {
# Workaround: on this hosting network, IPv6 paths to some registries
# (docker.io) get intercepted and served a mismatched TLS cert. Disabling
# IPv6 in the container forces IPv4-only egress and avoids it.
if [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || echo 0)" = "1" ]; then
skip "IPv6 already disabled"
return
fi
log "Disabling IPv6 (registry TLS workaround for this network)..."
cat > /etc/sysctl.d/99-disable-ipv6.conf <<'EOF'
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
sysctl -p /etc/sysctl.d/99-disable-ipv6.conf >/dev/null
ok "IPv6 disabled"
}
step_docker_install() {
if command -v docker >/dev/null 2>&1; then
skip "Docker already installed ($(docker --version))"
return
fi
log "Installing Docker Engine from the official apt repo..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $VERSION_CODENAME stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null
systemctl enable --now docker >/dev/null
ok "Docker Engine installed"
}
step_check_max_map_count() {
local current
current="$(cat /proc/sys/vm/max_map_count)"
if [ "$current" -lt 262144 ]; then
die "vm.max_map_count=$current (<262144), required by OpenSearch. This is a HOST-level kernel \
parameter and cannot be changed from inside an unprivileged LXC. Ask the Proxmox host admin to run: \
echo 'vm.max_map_count=262144' >> /etc/sysctl.d/99-opensearch.conf && sysctl -p /etc/sysctl.d/99-opensearch.conf \
Then re-run this script."
fi
ok "vm.max_map_count=$current (>= 262144)"
}
step_docker_smoke_test() {
log "Testing Docker can actually run a container (AppArmor check)..."
if docker run --rm hello-world >/tmp/docker-test.log 2>&1; then
ok "Docker smoke test passed"
return
fi
if grep -q "ip_unprivileged_port_start" /tmp/docker-test.log; then
die "Docker containers fail to start due to unprivileged-LXC AppArmor confinement. \
This must be fixed on the PROXMOX HOST (not from inside the container) by the host admin: \
echo 'lxc.apparmor.profile: unconfined' >> /etc/pve/lxc/<VMID>.conf && pct reboot <VMID> \
Then re-run this script."
fi
cat /tmp/docker-test.log >&2
die "docker run hello-world failed for an unrecognized reason; see output above."
}
# ---------------------------------------------------------------------------
step_compose_files() {
mkdir -p "$INSTALL_DIR"
cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml"
# Same timezone detection used by step_inputs() for Syslog input parsing.
# GRAYLOG_ROOT_TIMEZONE governs the display timezone of the built-in
# read-only "admin" user - it CANNOT be changed via the Users REST API
# (confirmed live: PUT /api/users/admin with a timezone field fails with
# "state should be: hexString has 24 characters"), only via this env var.
local tz
tz="$(cat /etc/timezone 2>/dev/null || echo UTC)"
if [ -f "$INSTALL_DIR/.env" ]; then
if grep -q '^GRAYLOG_ROOT_TIMEZONE=' "$INSTALL_DIR/.env"; then
skip ".env already exists, keeping existing secrets"
else
echo "GRAYLOG_ROOT_TIMEZONE=$tz" >> "$INSTALL_DIR/.env"
ok ".env existed but was missing GRAYLOG_ROOT_TIMEZONE - added it ($tz)"
fi
return
fi
log "Generating fresh secrets into $INSTALL_DIR/.env ..."
local secret admin_pass admin_sha2
secret="$(openssl rand -hex 48)"
# GRAYLOG_ADMIN_PASSWORD lets the operator pin a known password up front
# instead of always getting a random one - useful for CI, where it means
# the Forgejo secret is set once and never goes stale, rather than having
# to be re-synced every time a fresh install randomly generates a new
# password. Falls back to random if not set, same as before.
admin_pass="${GRAYLOG_ADMIN_PASSWORD:-$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c20)}"
admin_sha2="$(echo -n "$admin_pass" | sha256sum | cut -d' ' -f1)"
cat > "$INSTALL_DIR/.env" <<EOF
GRAYLOG_PASSWORD_SECRET=$secret
GRAYLOG_ROOT_PASSWORD_SHA2=$admin_sha2
GRAYLOG_HTTP_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI
GRAYLOG_ROOT_TIMEZONE=$tz
EOF
chmod 600 "$INSTALL_DIR/.env"
# Only shown once, at generation time - not re-printed on later runs.
cat > "$INSTALL_DIR/.admin_credentials_ONE_TIME" <<EOF
Graylog admin user: $ADMIN_USER
Graylog admin password: $admin_pass
(This file is only written once, at first install. install-graylog.sh prints
the password above and deletes this file automatically once the run
finishes successfully. If the script dies before that, store it yourself,
then remove the file: rm $INSTALL_DIR/.admin_credentials_ONE_TIME)
EOF
chmod 600 "$INSTALL_DIR/.admin_credentials_ONE_TIME"
ok "Admin password generated (shown and deleted at the end of this run if the whole script succeeds)."
}
step_compose_up() {
log "Pulling images (retrying up to 5x - this registry intermittently serves a bad TLS cert)..."
local i
for i in 1 2 3 4 5; do
if (cd "$INSTALL_DIR" && docker compose pull); then
break
fi
log "Pull attempt $i failed, retrying..."
sleep 3
[ "$i" -eq 5 ] && die "docker compose pull failed after 5 attempts."
done
log "Starting the stack..."
(cd "$INSTALL_DIR" && docker compose up -d)
log "Waiting for graylog-server to become healthy (up to 5 minutes)..."
local waited=0
while true; do
status="$(docker inspect --format '{{.State.Health.Status}}' graylog-server 2>/dev/null || echo starting)"
[ "$status" = "healthy" ] && break
waited=$((waited + 5))
[ "$waited" -ge 300 ] && die "graylog-server did not become healthy within 5 minutes. Check: docker logs graylog-server"
sleep 5
done
ok "Stack is healthy"
}
# ---------------------------------------------------------------------------
GRAYLOG_API="http://localhost:9000/api"
# Resolves the admin password into the global ADMIN_PASSWORD exactly once.
# Must be called as a plain statement (never via `x=$(resolve_admin_password)`)
# so that `die`'s `exit` terminates the real script instead of a subshell.
ADMIN_PASSWORD=""
resolve_admin_password() {
[ -n "$ADMIN_PASSWORD" ] && return
if [ -f "$INSTALL_DIR/.admin_credentials_ONE_TIME" ]; then
ADMIN_PASSWORD="$(grep -m1 -oP '(?<=^Graylog admin password: ).*' "$INSTALL_DIR/.admin_credentials_ONE_TIME")"
elif [ -n "${GRAYLOG_ADMIN_PASSWORD:-}" ]; then
ADMIN_PASSWORD="$GRAYLOG_ADMIN_PASSWORD"
else
die "Cannot find admin password: $INSTALL_DIR/.admin_credentials_ONE_TIME was deleted. \
Re-run with GRAYLOG_ADMIN_PASSWORD=<password> set."
fi
}
gcurl() {
local method="$1" path="$2" data="${3:-}"
resolve_admin_password
if [ -n "$data" ]; then
curl -s -u "${ADMIN_USER}:${ADMIN_PASSWORD}" -H 'X-Requested-By: cli' -H 'Content-Type: application/json' \
-X "$method" "${GRAYLOG_API}${path}" -d "$data"
else
curl -s -u "${ADMIN_USER}:${ADMIN_PASSWORD}" -H 'X-Requested-By: cli' -X "$method" "${GRAYLOG_API}${path}"
fi
}
wait_for_api_ready() {
# Docker's healthcheck can report the container "healthy" a few seconds
# before Graylog's REST API is actually ready to serve authenticated
# requests (indices/auth subsystems still initializing) - confirmed live:
# the very first gcurl call (step_index_retention) intermittently got an
# empty response, which then crashed the downstream `python3 -c
# "json.load(sys.stdin)"` with "Expecting value: line 1 column 1".
log "Waiting for the Graylog REST API to accept authenticated requests..."
local waited=0
while true; do
gcurl GET /system/indices/index_sets | python3 -c "import json,sys;json.load(sys.stdin)" 2>/dev/null && break
waited=$((waited + 5))
[ "$waited" -ge 180 ] && die "Graylog REST API did not respond with valid JSON within 180s of the container reporting healthy."
sleep 5
done
ok "Graylog REST API is ready"
}
step_index_retention() {
# Graylog's factory default (as of 7.1) keeps 30-40 days of data across up
# to 20 indices - reasonable in general, but risky on a small disk (this
# deployment: 50GB) combined with unverified log volume (some sources,
# e.g. accel-ppp at debug level, can be very chatty). Tightened to a
# 14-21 day window / 15 indices for a larger safety margin. Revisit once
# real production volume has been observed for a few weeks.
log "Tightening default index set retention (idempotent)..."
local idx_id current
idx_id="$(gcurl GET /system/indices/index_sets | python3 -c "import json,sys;d=json.load(sys.stdin);print(next(s['id'] for s in d['index_sets'] if s.get('default')))")"
current="$(gcurl GET "/system/indices/index_sets/$idx_id")"
if echo "$current" | python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d['rotation_strategy']['index_lifetime_max']=='P21D' else 1)" 2>/dev/null; then
skip "index retention already tightened (P14D/P21D, max 15 indices)"
return
fi
gcurl PUT "/system/indices/index_sets/$idx_id" "$(python3 -c "
import json
d = json.loads('''$current''')
d['rotation_strategy']['index_lifetime_min'] = 'P14D'
d['rotation_strategy']['index_lifetime_max'] = 'P21D'
d['retention_strategy']['max_number_of_indices'] = 15
d['data_tiering']['index_lifetime_min'] = 'P14D'
d['data_tiering']['index_lifetime_max'] = 'P21D'
d['writable'] = True
print(json.dumps(d))
")" >/dev/null
ok "index retention tightened to P14D/P21D, max 15 indices"
}
step_inputs() {
log "Creating Syslog UDP inputs (idempotent)..."
local existing tz
existing="$(gcurl GET /system/inputs)"
# RFC3164 syslog (which is what most network gear/accel-ppp send) has no
# timezone in its timestamp ("Jul 22 09:15:13"). Without this setting,
# Graylog defaults to treating that bare timestamp as UTC - so a device
# logging in local Kyiv time (UTC+3) shows up 3 hours in the future.
# Confirmed live: a raw test packet with "Jul 22 09:15:13" was stored as
# 09:15:13Z UTC (wrong) until this was set; afterwards it correctly
# stored as 06:15:13Z UTC (09:15:13 Kyiv time). Uses the container's own
# configured timezone so it stays correct regardless of where this is
# deployed.
tz="$(cat /etc/timezone 2>/dev/null || echo UTC)"
ensure_syslog_input() {
local port="$1" title="$2" var_name="$3"
local id current_tz
id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['id'] for i in d['inputs'] if i['attributes'].get('port')==$port),''))")"
local body="{
\"title\": \"$title\",
\"type\": \"org.graylog2.inputs.syslog.udp.SyslogUDPInput\",
\"global\": true,
\"configuration\": {\"bind_address\":\"0.0.0.0\",\"port\":$port,\"recv_buffer_size\":262144,
\"number_worker_threads\":2,\"force_rdns\":false,\"allow_override_date\":true,
\"store_full_message\":false,\"expand_structured_data\":true,\"charset_name\":\"UTF-8\",
\"timezone\":\"$tz\"}
}"
if [ -z "$id" ]; then
id="$(gcurl POST /system/inputs "$body" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")"
ok "Created $title: $id"
else
current_tz="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['attributes'].get('timezone') for i in d['inputs'] if i['attributes'].get('port')==$port),''))")"
if [ "$current_tz" != "$tz" ]; then
gcurl PUT "/system/inputs/$id" "$body" >/dev/null
ok "$title already existed - fixed timezone ($current_tz -> $tz)"
else
skip "$title already exists: $id"
fi
fi
printf -v "$var_name" '%s' "$id"
}
# Port 514 is the standard syslog port and the one most network gear
# actually sends to (confirmed live: BDCOM switches here can't be pointed
# at a custom port). Port 1514 is kept as a secondary input for any
# equipment that *can* be configured with a non-standard destination port.
ensure_syslog_input 514 "Network Equipment Syslog (standard port 514)" NETWORK_INPUT_ID_514
ensure_syslog_input 1514 "Network Equipment Syslog (Juniper-ZTE-DLink)" NETWORK_INPUT_ID_1514
ensure_syslog_input 5140 "Servers Syslog (RADIUS-accel-ppp)" SERVERS_INPUT_ID
}
step_pipeline_rules() {
log "Importing pipeline rules from $SCRIPT_DIR/rules/*.json (idempotent)..."
# Compares by 'source' (not just title) so a rule whose regex/logic
# changed under the same title gets PUT-updated instead of silently
# skipped - this bit us live this session (rule11's session-correlation
# regex, rule18's severity fix) before this check existed.
local existing
existing="$(gcurl GET /system/pipelines/rule)"
local f title id source current_source result
for f in "$SCRIPT_DIR"/rules/*.json; do
title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")"
source="$(python3 -c "import json;print(json.load(open('$f'))['source'])")"
id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((r['id'] for r in d if r['title']=='$title'),''))")"
if [ -n "$id" ]; then
current_source="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((r['source'] for r in d if r['id']=='$id'),''))")"
if [ "$current_source" = "$source" ]; then
skip "rule '$title' already up to date"
continue
fi
result="$(gcurl PUT "/system/pipelines/rule/$id" "$(cat "$f")")"
if echo "$result" | python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d.get('errors') is None else 1)"; then
ok "updated rule '$title' ($id) - source changed"
else
die "rule '$title' failed to compile: $result"
fi
continue
fi
result="$(gcurl POST /system/pipelines/rule "$(cat "$f")")"
if echo "$result" | python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d.get('errors') is None else 1)"; then
ok "created rule '$title'"
else
die "rule '$title' failed to compile: $result"
fi
done
}
step_pipelines() {
log "Importing pipelines from $SCRIPT_DIR/pipelines/*.json (idempotent)..."
# Same "compare by content, PUT-update if changed" fix as
# step_pipeline_rules() - an existing pipeline's title never changes
# even when its stage list gains new rules, so a plain title-exists
# check would silently skip the update forever. Confirmed live this
# session: 5 new rules were added to rules/*.json and created fine in
# Graylog, but "Servers Parsing" kept its old stage list since this
# function only ever checked title existence before this fix.
local existing
existing="$(gcurl GET /system/pipelines/pipeline)"
local f title id source current_source result
for f in "$SCRIPT_DIR"/pipelines/*.json; do
title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")"
source="$(python3 -c "import json;print(json.load(open('$f'))['source'])")"
id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((p['id'] for p in d if p['title']=='$title'),''))")"
if [ -n "$id" ]; then
current_source="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((p['source'] for p in d if p['id']=='$id'),''))")"
if [ "$current_source" = "$source" ]; then
skip "pipeline '$title' already up to date ($id)"
else
gcurl PUT "/system/pipelines/pipeline/$id" "$(cat "$f")" >/dev/null
ok "updated pipeline '$title' ($id) - stage list changed"
fi
if [ "$title" = "Network Equipment Parsing" ]; then NETWORK_PIPELINE_ID="$id"; fi
if [ "$title" = "Servers Parsing" ]; then SERVERS_PIPELINE_ID="$id"; fi
continue
fi
result="$(gcurl POST /system/pipelines/pipeline "$(cat "$f")")"
id="$(echo "$result" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")"
[ -n "$id" ] || die "pipeline '$title' failed to create: $result"
ok "created pipeline '$title' ($id)"
if [ "$title" = "Network Equipment Parsing" ]; then NETWORK_PIPELINE_ID="$id"; fi
if [ "$title" = "Servers Parsing" ]; then SERVERS_PIPELINE_ID="$id"; fi
done
}
step_streams() {
log "Creating streams from $SCRIPT_DIR/streams/*.json (idempotent)..."
# Same "compare content, don't just check the title exists" fix as
# step_pipeline_rules()/step_pipelines()/step_alerts()/step_dashboard() - a
# stream's title never changes when its rules do, so a plain title-exists
# check would silently skip the update forever. Unlike a dashboard, a
# stream's rules live in their own sub-resource
# (POST/PUT/DELETE /streams/{id}/rules/{ruleId}), not a single comparable
# field - and delete+recreating the whole stream would briefly break live
# message routing (alerts/dashboards go blind until it's reconnected to
# its pipeline). So changed rules are diffed individually - identity is
# (field, type, value, inverted) since desired rules carry no id - missing
# ones are created, extra ones are deleted; title/description/matching_type/
# index_set_id are PUT-updated in place same as a pipeline's 'source'.
local index_set_id existing_file
index_set_id="$(gcurl GET /system/indices/index_sets | python3 -c "import json,sys;d=json.load(sys.stdin);print(next(s['id'] for s in d['index_sets'] if s.get('default')))")"
existing_file="$(mktemp)"
gcurl GET /streams > "$existing_file"
# $2.. are extra `sed -e` substitution expressions applied on top of the
# index-set-id one, so each template can carry however many input-id
# placeholders it needs.
render_and_create() {
local tmpl="$1" title="$2"; shift 2
local sed_args=(-e "s/__DEFAULT_INDEX_SET_ID__/$index_set_id/")
local expr
for expr in "$@"; do sed_args+=(-e "$expr"); done
local desired_file id
desired_file="$(mktemp)"
sed "${sed_args[@]}" "$tmpl" > "$desired_file"
id="$(python3 -c "
import json
d = json.load(open('$existing_file'))
print(next((s['id'] for s in d['streams'] if s['title'] == '$title'), ''))
")"
if [ -n "$id" ]; then
local plan_file
plan_file="$(mktemp)"
python3 -c "
import json
d = json.load(open('$existing_file'))
current = next(s for s in d['streams'] if s['id'] == '$id')
desired = json.load(open('$desired_file'))['entity']
meta_keys = ['title', 'description', 'matching_type', 'index_set_id', 'remove_matches_from_default_stream']
metadata_changed = any(current.get(k) != desired.get(k) for k in meta_keys)
metadata_body = {k: desired[k] for k in meta_keys if k in desired}
def rule_key(r):
return (r['field'], r['type'], r['value'], r['inverted'])
current_rules = current.get('rules') or []
desired_rules = desired.get('rules') or []
current_by_key = {rule_key(r): r['id'] for r in current_rules}
desired_key_set = {rule_key(r) for r in desired_rules}
rules_create = [r for r in desired_rules if rule_key(r) not in current_by_key]
rules_delete = [rid for k, rid in current_by_key.items() if k not in desired_key_set]
json.dump({
'metadata_changed': metadata_changed,
'metadata_body': metadata_body,
'rules_create': rules_create,
'rules_delete': rules_delete,
}, open('$plan_file', 'w'))
"
local metadata_changed rules_create_count rules_delete_count
metadata_changed="$(python3 -c "import json;print(json.load(open('$plan_file'))['metadata_changed'])")"
rules_create_count="$(python3 -c "import json;print(len(json.load(open('$plan_file'))['rules_create']))")"
rules_delete_count="$(python3 -c "import json;print(len(json.load(open('$plan_file'))['rules_delete']))")"
if [ "$metadata_changed" = "True" ]; then
gcurl PUT "/streams/$id" "$(python3 -c "import json;print(json.dumps(json.load(open('$plan_file'))['metadata_body']))")" >/dev/null
fi
if [ "$rules_delete_count" -gt 0 ]; then
local rid
while IFS= read -r rid; do
[ -n "$rid" ] || continue
gcurl DELETE "/streams/$id/rules/$rid" >/dev/null
done < <(python3 -c "import json;print('\n'.join(json.load(open('$plan_file'))['rules_delete']))")
fi
if [ "$rules_create_count" -gt 0 ]; then
local rule_body
while IFS= read -r rule_body; do
gcurl POST "/streams/$id/rules" "$rule_body" >/dev/null
done < <(python3 -c "
import json
for r in json.load(open('$plan_file'))['rules_create']:
print(json.dumps(r))
")
fi
if [ "$metadata_changed" = "True" ] || [ "$rules_create_count" -gt 0 ] || [ "$rules_delete_count" -gt 0 ]; then
ok "updated stream '$title' ($id) - $rules_create_count rule(s) added, $rules_delete_count removed$([ "$metadata_changed" = "True" ] && echo ", metadata changed")"
else
skip "stream '$title' already up to date ($id)"
fi
rm -f "$plan_file" "$desired_file"
echo "$id"
return
fi
id="$(gcurl POST /streams "$(cat "$desired_file")" | python3 -c "import json,sys;print(json.load(sys.stdin)['stream_id'])")"
ok "created stream '$title' ($id)"
gcurl POST "/streams/$id/resume" "" >/dev/null
rm -f "$desired_file"
echo "$id"
}
NETWORK_STREAM_ID="$(render_and_create "$SCRIPT_DIR/streams/stream1_network.json" "Network Equipment" \
"s/__NETWORK_INPUT_ID_514__/$NETWORK_INPUT_ID_514/" \
"s/__NETWORK_INPUT_ID_1514__/$NETWORK_INPUT_ID_1514/")"
SERVERS_STREAM_ID="$(render_and_create "$SCRIPT_DIR/streams/stream2_servers.json" "Servers" \
"s/__SERVERS_INPUT_ID__/$SERVERS_INPUT_ID/")"
rm -f "$existing_file"
}
step_connect_pipelines() {
log "Connecting pipelines to streams..."
gcurl POST /system/pipelines/connections/to_stream \
"{\"stream_id\":\"$NETWORK_STREAM_ID\",\"pipeline_ids\":[\"$NETWORK_PIPELINE_ID\"]}" >/dev/null
gcurl POST /system/pipelines/connections/to_stream \
"{\"stream_id\":\"$SERVERS_STREAM_ID\",\"pipeline_ids\":[\"$SERVERS_PIPELINE_ID\"]}" >/dev/null
ok "Pipelines connected to streams"
}
step_firewall() {
cp "$SCRIPT_DIR/nftables.conf" /etc/nftables.conf
systemctl enable nftables >/dev/null 2>&1 || true
nft -f /etc/nftables.conf
ok "nftables applied (only 9000/tcp, 514+1514/udp, 5140/udp open)"
}
step_dashboard() {
# Three focused dashboards instead of one combined view - each targets a
# different reader (landing/alerts overview, network-equipment-only,
# servers-only) so opening Graylog goes straight to what's relevant
# instead of one big mixed-stream page.
#
# Same "compare content, don't just check the title exists" fix as
# step_pipeline_rules()/step_pipelines()/step_alerts() - a dashboard's
# title never changes when a widget/query in search_*.json does, so a
# plain title-exists check silently skips the update forever. Unlike
# rules/pipelines/alerts there's no safe in-place PUT that keeps a view
# and its search in sync here, so a changed dashboard is deleted and
# recreated instead - low risk since dashboards have no subscribers or
# history the way alerts or streams do. Compares only the 'queries' key
# our search_*.json files author, same reasoning as step_alerts(): GET
# fills in extra defaults (owner, created_at, requires, ...) that never
# appear in our files, so a whole-object compare would always say
# "changed".
local existing_views_file
existing_views_file="$(mktemp)"
gcurl GET /views > "$existing_views_file"
local search_file view_file title existing_id existing_search_id desired_search_file current_search_file search_id id body
for search_file in "$SCRIPT_DIR"/dashboards/search_*.json; do
view_file="${search_file/search_/view_}"
[ -f "$view_file" ] || die "missing $view_file for $search_file"
title="$(python3 -c "import json;print(json.load(open('$view_file'))['entity']['title'])")"
desired_search_file="$(mktemp)"
sed -e "s/__NETWORK_STREAM_ID__/$NETWORK_STREAM_ID/g" -e "s/__SERVERS_STREAM_ID__/$SERVERS_STREAM_ID/g" "$search_file" > "$desired_search_file"
existing_id="$(python3 -c "
import json
d = json.load(open('$existing_views_file'))
print(next((v['id'] for v in d['views'] if v['title'] == '$title'), ''))
")"
if [ -n "$existing_id" ]; then
existing_search_id="$(python3 -c "
import json
d = json.load(open('$existing_views_file'))
print(next(v['search_id'] for v in d['views'] if v['id'] == '$existing_id'))
")"
current_search_file="$(mktemp)"
gcurl GET "/views/search/$existing_search_id" > "$current_search_file"
if python3 -c "
import json, sys
def sort_search_types(q):
# search_types order isn't semantically meaningful (widgets are matched
# to view widgets by 'id', not position) but Graylog doesn't return them
# in the same order they were created in - confirmed live 2026-07-23:
# this caused a false 'changed' positive for every single dashboard on
# every single run, identical to the list-order bug already worked
# around in step_alerts().
q = dict(q)
if 'search_types' in q:
q['search_types'] = sorted(q['search_types'], key=lambda st: st.get('id', ''))
return q
def filter_to(desired_obj, current_obj):
# Keep only the keys/structure our search_*.json files actually author -
# confirmed live 2026-07-23: a 'messages'-type search_type (the message
# list widget on the Overview dashboard) comes back from GET with a pile
# of extra default keys (decorators, fields, filter, name, timerange,
# stream_categories, ...) that never appear in our file, so a whole-
# object compare always said 'changed' for that one dashboard even with
# sort_search_types() already applied - same reasoning as step_alerts().
if isinstance(desired_obj, dict):
cur = current_obj if isinstance(current_obj, dict) else {}
return {k: filter_to(v, cur.get(k)) for k, v in desired_obj.items()}
if isinstance(desired_obj, list):
cur = current_obj if isinstance(current_obj, list) else []
return [filter_to(dv, cur[i] if i < len(cur) else None) for i, dv in enumerate(desired_obj)]
return current_obj
current = json.load(open('$current_search_file'))
desired = json.load(open('$desired_search_file'))
current_q = [sort_search_types(q) for q in current.get('queries', [])]
desired_q = [sort_search_types(q) for q in desired.get('queries', [])]
sys.exit(0 if filter_to(desired_q, current_q) == desired_q else 1)
"; then
skip "dashboard '$title' already up to date ($existing_id)"
rm -f "$desired_search_file" "$current_search_file"
continue
fi
rm -f "$current_search_file"
gcurl DELETE "/views/$existing_id" >/dev/null
gcurl DELETE "/views/search/$existing_search_id" >/dev/null
ok "removed stale dashboard '$title' ($existing_id) - content changed, recreating"
fi
search_id="$(gcurl POST /views/search "$(cat "$desired_search_file")" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")"
[ -n "$search_id" ] || die "search creation failed for dashboard '$title'"
rm -f "$desired_search_file"
body="$(sed -e "s/__NETWORK_STREAM_ID__/$NETWORK_STREAM_ID/g" -e "s/__SERVERS_STREAM_ID__/$SERVERS_STREAM_ID/g" -e "s/__SEARCH_ID__/$search_id/" "$view_file")"
id="$(gcurl POST /views "$body" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")"
[ -n "$id" ] || die "dashboard '$title' failed to create"
ok "created dashboard '$title' ($id)"
done
rm -f "$existing_views_file"
}
step_alerts() {
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
skip "Discord notification and alerts (DISCORD_WEBHOOK_URL not set)"
return
fi
if [ ! -f "$SCRIPT_DIR/alerts/notification_discord.json" ]; then
skip "Alerts (no alerts/notification_discord.json in this checkout)"
return
fi
log "Creating Discord notification (idempotent)..."
local existing_notifs notif_id
existing_notifs="$(gcurl GET /events/notifications)"
notif_id="$(echo "$existing_notifs" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((n['id'] for n in d['notifications'] if n['title']=='Discord - Critical Alerts'),''))")"
if [ -n "$notif_id" ]; then
skip "Discord notification already exists ($notif_id)"
else
local webhook_slack body
webhook_slack="${DISCORD_WEBHOOK_URL%/}/slack"
body="$(sed "s#__DISCORD_WEBHOOK_URL_SLACK__#${webhook_slack}#" "$SCRIPT_DIR/alerts/notification_discord.json")"
notif_id="$(gcurl POST /events/notifications "{\"entity\": ${body}, \"share_request\": {\"selected_grantee_capabilities\": {}}}" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")"
ok "created Discord notification ($notif_id)"
fi
log "Creating alert (event) definitions from $SCRIPT_DIR/alerts/*.json (idempotent)..."
# Same "compare content, PUT-update if changed" fix as step_pipeline_rules()/
# step_pipelines() - an alert's title never changes when its query/threshold
# does, so a plain title-exists check would silently skip the update
# forever.
#
# Uses files instead of interpolating JSON into python -c strings (fragile
# with quotes/backslashes), and compares only the keys OUR file authors in
# "config" - not the whole object. Confirmed live this is necessary:
# Graylog's GET response fills in extra defaults (query_parameters, filters,
# use_cron_scheduling, cron_expression, cron_timezone, ...) that never
# appear in our alert JSON files, so a naive whole-object comparison always
# reports "changed" and PUTs every single alert on every single run, not
# just the one actually edited.
local existing_defs_file
existing_defs_file="$(mktemp)"
gcurl GET /events/definitions > "$existing_defs_file"
local f title id body_file
for f in "$SCRIPT_DIR"/alerts/alert*.json; do
[ -f "$f" ] || continue
title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")"
body_file="$(mktemp)"
sed \
-e "s/__NETWORK_STREAM_ID__/$NETWORK_STREAM_ID/" \
-e "s/__SERVERS_STREAM_ID__/$SERVERS_STREAM_ID/" \
-e "s/__DISCORD_NOTIFICATION_ID__/$notif_id/" \
"$f" > "$body_file"
id="$(python3 -c "
import json
d = json.load(open('$existing_defs_file'))
print(next((e['id'] for e in d['event_definitions'] if e['title'] == '$title'), ''))
")"
if [ -n "$id" ]; then
if python3 -c "
import json, sys
def normalize(v):
# List order isn't semantically meaningful here (e.g. 'streams' with
# two entries) but a plain dict/list == comparison is order-sensitive -
# confirmed live this caused a false 'changed' positive specifically
# for the one alert with a 2-element streams list, every single run.
if isinstance(v, list):
return sorted(v, key=str)
return v
existing = json.load(open('$existing_defs_file'))
current_full = next(e['config'] for e in existing['event_definitions'] if e['id'] == '$id')
desired = json.load(open('$body_file'))['config']
filtered_current = {k: normalize(current_full.get(k)) for k in desired}
desired_normalized = {k: normalize(v) for k, v in desired.items()}
sys.exit(0 if filtered_current == desired_normalized else 1)
"; then
skip "alert '$title' already up to date ($id)"
rm -f "$body_file"
continue
fi
python3 -c "
import json
d = json.load(open('$body_file'))
d['id'] = '$id'
print(json.dumps(d))
" > "${body_file}.put"
gcurl PUT "/events/definitions/$id" "$(cat "${body_file}.put")" >/dev/null
gcurl PUT "/events/definitions/$id/schedule" "" >/dev/null
ok "updated alert '$title' ($id) - config changed"
rm -f "$body_file" "${body_file}.put"
continue
fi
id="$(gcurl POST /events/definitions "{\"entity\": $(cat "$body_file"), \"share_request\": {\"selected_grantee_capabilities\": {}}}" \
| python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")"
[ -n "$id" ] || die "alert '$title' failed to create"
gcurl PUT "/events/definitions/$id/schedule" "" >/dev/null
ok "created and enabled alert '$title' ($id)"
rm -f "$body_file"
done
rm -f "$existing_defs_file"
}
# ---------------------------------------------------------------------------
main() {
require_root
step_packages
step_disable_ipv6
step_docker_install
step_check_max_map_count
step_docker_smoke_test
step_firewall
step_compose_files
step_compose_up
# Must be a plain statement (not `x=$(...)`) so a failure here truly
# aborts the script - gcurl() is always called from inside command
# substitutions downstream, where `die`'s `exit` would only kill a subshell.
resolve_admin_password
wait_for_api_ready
step_index_retention
step_inputs
step_pipeline_rules
step_pipelines
step_streams
step_connect_pipelines
step_dashboard
step_alerts
echo >&2
echo "${C_GREEN}==================================================${C_RESET}" >&2
echo "${C_GREEN} DONE - Graylog is up${C_RESET}" >&2
echo "${C_GREEN}==================================================${C_RESET}" >&2
echo " Web UI: $GRAYLOG_EXTERNAL_URI" >&2
echo " Config: $INSTALL_DIR/docker-compose.yml and $INSTALL_DIR/.env" >&2
if [ -f "$INSTALL_DIR/.admin_credentials_ONE_TIME" ]; then
echo " Admin user: $ADMIN_USER" >&2
echo " Admin password: $ADMIN_PASSWORD" >&2
echo " (shown once above - store it now; the one-time file is being deleted)" >&2
rm -f "$INSTALL_DIR/.admin_credentials_ONE_TIME"
fi
echo "${C_GREEN}==================================================${C_RESET}" >&2
}
main "$@"