graylog-deploy/install-graylog.sh
byrsapty 609ac74574 Fix two bugs found live: pipeline/rule update gap, and alert7's over-broad query
- step_pipeline_rules() and step_pipelines() only ever created new
  rules/pipelines by title, never updated existing ones whose content
  changed under the same title. Confirmed live this session: 5 new rules
  were added to rules/*.json and created fine in Graylog, but "Servers
  Parsing"'s stage list was never updated to actually call them, since
  the pipeline already existed. Both functions now compare by 'source'
  content and PUT-update if it differs, matching the pattern already used
  elsewhere (e.g. step_inputs()'s timezone self-heal).
- alert7's query (vendor:accel-ppp) was too broad: it also matches
  routine traffic tagged only by the generic accelppp_interface_tag
  fallback rule, which sets vendor but never event_type - producing false
  "(Empty Value)" group_by matches on ordinary volume. Confirmed live.
  Fixed to vendor:accel-ppp AND event_type:* - severity_tag was
  considered instead but rejected, since accelppp_router_address_error
  (the rule that inspired this alert) never set it either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:28:50 +03:00

565 lines
25 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)..."
local index_set_id existing
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="$(gcurl GET /streams)"
# $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 id
id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((s['id'] for s in d['streams'] if s['title']=='$title'),''))")"
if [ -n "$id" ]; then
skip "stream '$title' already exists ($id)"
echo "$id"
return
fi
local sed_args=(-e "s/__DEFAULT_INDEX_SET_ID__/$index_set_id/")
local expr
for expr in "$@"; do sed_args+=(-e "$expr"); done
local body
body="$(sed "${sed_args[@]}" "$tmpl")"
id="$(gcurl POST /streams "$body" | python3 -c "import json,sys;print(json.load(sys.stdin)['stream_id'])")"
ok "created stream '$title' ($id)"
gcurl POST "/streams/$id/resume" "" >/dev/null
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/")"
}
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.
local existing_titles
existing_titles="$(gcurl GET /views | python3 -c "import json,sys;print('\n'.join(v['title'] for v in json.load(sys.stdin)['views']))")"
local search_file view_file title id search_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'])")"
if echo "$existing_titles" | grep -qx "$title"; then
skip "dashboard '$title' already exists"
continue
fi
body="$(sed -e "s/__NETWORK_STREAM_ID__/$NETWORK_STREAM_ID/g" -e "s/__SERVERS_STREAM_ID__/$SERVERS_STREAM_ID/g" "$search_file")"
search_id="$(gcurl POST /views/search "$body" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")"
[ -n "$search_id" ] || die "search creation failed for dashboard '$title'"
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
}
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)..."
local existing_defs
existing_defs="$(gcurl GET /events/definitions)"
local f title id
for f in "$SCRIPT_DIR"/alerts/alert*.json; do
[ -f "$f" ] || continue
title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")"
id="$(echo "$existing_defs" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((e['id'] for e in d['event_definitions'] if e['title']=='$title'),''))")"
if [ -n "$id" ]; then
skip "alert '$title' already exists ($id)"
continue
fi
local body
body="$(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")"
id="$(gcurl POST /events/definitions "{\"entity\": ${body}, \"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)"
done
}
# ---------------------------------------------------------------------------
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 "$@"