Към съдържанието
bub.im

FreeBSD 15 – Zabbix Server setup

cpx November 7, 2026 35 min четене FreeBSD/BSDZABBIX/10051
zabbix-freebsd15-installer-v6.shbash
#!/bin/sh
# FreeBSD 15: install the newest *complete packaged* Zabbix full stack.
# Uses pkg only; no /usr/ports, git, make, or compilation is required.
# Installs Zabbix server + classic agent + PHP frontend + MySQL 8.4 + nginx.
# NEW INSTALL ONLY on a dedicated machine/jail; not an upgrade procedure.
# Database bootstrap: temporary MySQL binlog-trust override with restoration.
# Optional LAN binding to precisely one assigned NIC IPv4; MySQL stays local.
# v6: safe, persistent LAN-scoped firewall integration for PF and IPFW.
# Retains v5 FreeBSD service-name and recovery corrections.
# BSD-2-Clause permission for reuse. No warranty; test on a snapshot-backed VM.
set -eu
umask 077
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

DOMAIN=zabbix
PUBLIC_WEB=0
PUBLIC_TRAPPER=0
CERT_SOURCE=
KEY_SOURCE=
PHP_FLAVOR=php84
MYSQL_PACKAGE=mysql84-server
SECRETS_DIR=/root/.zabbix-installer
STATE_DIR=/var/db/zabbix-installer
DB_NAME=zabbix
DB_USER=zabbix
MARKER='Managed by install-zabbix-latest-freebsd.sh'
STAMP=$(date +%Y%m%d-%H%M%S)
DB_NEW=0
SELF_SIGNED=0
REPO_POLICY=latest
REQUIRE_MAJOR=
CHECK_ONLY=0
RESET_INCOMPLETE_DB=0
RESUME_SERVICES=0
REPAIR_SERVICES=0
LAN_MODE=0
LAN_IP=
LAN_IFACE=
MYSQL_TRUST_CHANGED=0
MYSQL_TRUST_ORIGINAL=
MYSQL_TRUST_MARKER="$STATE_DIR/mysql-trust-original"
REPO_OVERRIDE=/usr/local/etc/pkg/repos/zz-zabbix-installer.conf
FIREWALL_BACKEND=auto
FIREWALL_ONLY=0
FIREWALL_DRY_RUN=0
FIREWALL_SUBNET=
FIREWALL_SELECTED=none
FIREWALL_IPFW_SCRIPT=/usr/local/etc/zabbix-monitoring-ipfw.sh
PF_BEGIN='# BEGIN ZABBIX-MONITORING-LAN (installer managed)'
PF_END='# END ZABBIX-MONITORING-LAN (installer managed)' 

usage() {
  cat <<'EOF'
Usage: sh zabbix-freebsd15-installer-v6.sh [options]

Installs the newest complete Zabbix binary package family in your pkg catalogs.
Defaults to FreeBSD-ports LATEST, with no Ports Collection and no Git.
This MAY choose Zabbix 7.4 until Zabbix 8 binaries are published.

  --domain HOSTNAME        Dashboard hostname and local agent Hostname (zabbix)
  --lan-auto               Bind HTTPS and Zabbix trapper to an auto-detected LAN NIC/IP
  --lan-ip IPv4             Bind HTTPS and Zabbix trapper to this assigned IPv4
  --lan-interface IFACE     Use the unique private IPv4 on this NIC (e.g. vtnet0)
  --public-web             Bind HTTPS on all IPv4 interfaces (not with --lan-*)
  --public-trapper         Bind Zabbix server TCP/10051 on all IPv4 interfaces (not with --lan-*)
  --cert FILE --key FILE   Provide existing PEM certificate and matching key
  --keep-repo              Keep current pkg repo instead of using latest
  --require-major N        Require a Zabbix major version (e.g. 8), else fail
  --require-8              Alias for --require-major 8
  --check-packages         Show available package selection, don't install
  --resume-services        Non-destructive finish after configuration; NO database changes
                           or pkg operations (repairs invalid rc.conf service names)
  --repair-services        Repair invalid rc.conf entries and enable/start installed
                           stack services only; NO pkg, DB, or Zabbix config changes
  --firewall auto|pf|ipfw|off  Auto-detect ACTIVE/configured PF or IPFW (default: auto)
  --firewall-subnet CIDR   Source subnet allowed to 443/10051 (default: NIC's subnet)
  --firewall-only          Modify firewall for an EXISTING LAN-bound deployment only;
                           no pkg, DB, credential, listener or service changes
  --firewall-dry-run       Preview firewall-only changes; requires --firewall-only
  (For LAN changes on an existing installation use zabbix-freebsd15-lan-bind.sh.)
  --reset-incomplete-db    Back up and recreate ONLY this installer's unfinished
                           Zabbix database (requires no Admin user/data)
  --packages-only          Accepted for compatibility; pkg is always used
  --ports-dir DIR          Accepted but ignored; a ports tree is NOT needed
  --help                   Show this help

Run as root on fresh FreeBSD 15 amd64 (14 also supported).
Default repository override is /usr/local/etc/pkg/repos/zz-zabbix-installer.conf
and affects the FreeBSD-ports repository globally. It does NOT edit base/kmods.
Use --keep-repo to avoid this change. No build-from-ports fallback is performed.

Safety: declines existing Zabbix/NGINX/PHP/MySQL deployments. Creates a local
MySQL database, generates random passwords, and uses local-only listeners by
default; --lan-* binds only loopback plus the selected LAN IPv4. When a LAN
binding is selected, an already enabled/configured PF or IPFW gets only LAN
TCP/443 and TCP/10051 permits; no firewall is enabled automatically.
No new SSH block rules are created. DNS, backup and TLS renewal remain yours.
EOF
}
log() { printf '\n[+] %s\n' "$*"; }
warn() { printf '[WARN] %s\n' "$*" >&2; }
die() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }
new_password() { openssl rand -hex 32; }
backup() {
  if [ -f "$1" ]; then
    cp -p "$1" "$SECRETS_DIR/$(basename "$1").$STAMP.bak" || die "Cannot back up $1"
  fi
}
managed_or_absent() {
  if [ -f "$1" ] && ! grep -Fq "$MARKER" "$1"; then
    die "Refusing to overwrite unmanaged configuration: $1"
  fi
}
site_name_valid() {
  printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.-]*$' || return 1
  case "$1" in *..*|.*|*.) return 1 ;; esac
  [ "${#1}" -le 253 ] || return 1
  return 0
}

valid_ipv4() {
  printf '%s\n' "$1" | awk -F. '
    NF != 4 {exit 1}
    { for (i=1; i<=4; i++) {
        if ($i !~ /^[0-9]+$/ || length($i)>3 || $i+0>255) exit 1
      }
      if ($1+0 == 0 || $1+0 == 127 || $1+0 >= 224 ||
          ($1+0 == 169 && $2+0 == 254) ||
          ($1+0 == 255)) exit 1
    }'
}
is_private() {
  case "$1" in
    10.*|192.168.*) return 0 ;;
    172.*)
      second=${1#172.}; second=${second%%.*}
      [ "$second" -ge 16 ] && [ "$second" -le 31 ] && return 0
      ;;
  esac
  return 1
}
ips_on_iface() { ifconfig "$1" inet 2>/dev/null | awk '$1 == "inet" {print $2}'; }
iface_valid() { printf '%s\n' "$1" | grep -Eq '^[[:alnum:]][[:alnum:]_.:-]*$'; }
private_ips_on_iface() {
  for p in $(ips_on_iface "$1"); do
    if valid_ipv4 "$p" && is_private "$p"; then printf '%s\n' "$p"; fi
  done
}
resolve_lan() {
  need_cmd ifconfig
  if [ -n "$LAN_IFACE" ]; then
    iface_valid "$LAN_IFACE" || die 'Invalid interface name'
    ifconfig "$LAN_IFACE" >/dev/null 2>&1 || die "Interface $LAN_IFACE does not exist"
    if [ -z "$LAN_IP" ]; then
      candidates=$(private_ips_on_iface "$LAN_IFACE")
      n=$(printf '%s\n' "$candidates" | awk 'NF {n++} END{print n+0}')
      [ "$n" -eq 1 ] || die "Expected one private IPv4 on $LAN_IFACE; found $n. Specify --ip."
      LAN_IP=$candidates
    fi
  fi
  if [ -n "$LAN_IP" ]; then
    valid_ipv4 "$LAN_IP" || die "Invalid/unusable IPv4: $LAN_IP"
    if [ -z "$LAN_IFACE" ]; then
      for candidate in $(ifconfig -l); do
        if ips_on_iface "$candidate" | grep -Fxq "$LAN_IP"; then
          [ -z "$LAN_IFACE" ] || die "IP $LAN_IP is on multiple interfaces; specify --interface"
          LAN_IFACE=$candidate
        fi
      done
    fi
    [ -n "$LAN_IFACE" ] || die "IP $LAN_IP is not assigned to a FreeBSD network interface"
    ips_on_iface "$LAN_IFACE" | grep -Fxq "$LAN_IP" || die "IP $LAN_IP is not assigned to $LAN_IFACE"
  else
    route_iface=$(route -n get default 2>/dev/null | awk '$1 == "interface:" {print $2; exit}' || :)
    if [ -n "$route_iface" ]; then
      candidates=$(private_ips_on_iface "$route_iface")
      n=$(printf '%s\n' "$candidates" | awk 'NF {n++} END {print n+0}')
      if [ "$n" -eq 1 ]; then LAN_IFACE=$route_iface; LAN_IP=$candidates; fi
    fi
    if [ -z "$LAN_IP" ]; then
      found=
      found_iface=
      for candidate in $(ifconfig -l); do
        for p in $(private_ips_on_iface "$candidate"); do
          [ -z "$found" ] || die 'Multiple private IPv4 addresses found. Specify --interface/--ip.'
          found=$p
          found_iface=$candidate
        done
      done
      [ -n "$found" ] || die 'No private LAN IPv4 found. Specify --ip explicitly.'
      LAN_IP=$found; LAN_IFACE=$found_iface
    fi
  fi
  if ! is_private "$LAN_IP"; then
    warn "Selected $LAN_IP is not RFC1918 private space; confirm this NIC is not Internet-facing."
  fi
  [ "$LAN_IP" != 127.0.0.1 ] || die 'Loopback is not a LAN address'
}

while [ "$#" -gt 0 ]; do
  case "$1" in
    --domain) [ "$#" -ge 2 ] || die '--domain requires a value'; DOMAIN=$2; shift 2 ;;
    --lan-auto) LAN_MODE=1; shift ;;
    --lan-ip) [ "$#" -ge 2 ] || die '--lan-ip requires IPv4'; LAN_MODE=1; LAN_IP=$2; shift 2 ;;
    --lan-interface) [ "$#" -ge 2 ] || die '--lan-interface requires IFACE'; LAN_MODE=1; LAN_IFACE=$2; shift 2 ;;
    --public-web) PUBLIC_WEB=1; shift ;;
    --public-trapper) PUBLIC_TRAPPER=1; shift ;;
    --cert) [ "$#" -ge 2 ] || die '--cert requires a file'; CERT_SOURCE=$2; shift 2 ;;
    --key) [ "$#" -ge 2 ] || die '--key requires a file'; KEY_SOURCE=$2; shift 2 ;;
    --keep-repo) REPO_POLICY=keep; shift ;;
    --require-major) [ "$#" -ge 2 ] || die '--require-major requires a number'; REQUIRE_MAJOR=$2; shift 2 ;;
    --require-8) REQUIRE_MAJOR=8; shift ;;
    --check-packages) CHECK_ONLY=1; shift ;;
    --resume-services) RESUME_SERVICES=1; shift ;;
    --repair-services) REPAIR_SERVICES=1; shift ;;
    --firewall) [ "$#" -ge 2 ] || die '--firewall requires auto, pf, ipfw or off'; FIREWALL_BACKEND=$2; shift 2 ;;
    --firewall-only) FIREWALL_ONLY=1; shift ;;
    --firewall-dry-run) FIREWALL_DRY_RUN=1; shift ;;
    --firewall-subnet) [ "$#" -ge 2 ] || die '--firewall-subnet requires a CIDR'; FIREWALL_SUBNET=$2; shift 2 ;;
    --reset-incomplete-db) RESET_INCOMPLETE_DB=1; shift ;;
    --packages-only) shift ;;
    --ports-dir) [ "$#" -ge 2 ] || die '--ports-dir requires a directory'; warn '--ports-dir ignored: pkg does not use /usr/ports'; shift 2 ;;
    --ports-only|--ports-fallback) die 'Ports builds have been removed. This installer is pkg-only.' ;;
    -h|--help) usage; exit 0 ;;
    *) die "Unknown option: $1" ;;
  esac
done

[ "$RESUME_SERVICES" -eq 0 ] || [ "$REPAIR_SERVICES" -eq 0 ] || \
  die '--resume-services and --repair-services are mutually exclusive'
[ "$REPAIR_SERVICES" -eq 0 ] || [ "$RESET_INCOMPLETE_DB" -eq 0 ] || \
  die '--repair-services cannot be combined with --reset-incomplete-db'
case "$FIREWALL_BACKEND" in auto|pf|ipfw|off) : ;; *) die '--firewall must be auto, pf, ipfw or off' ;; esac
if [ "$FIREWALL_DRY_RUN" -eq 1 ] && [ "$FIREWALL_ONLY" -eq 0 ]; then
  die '--firewall-dry-run requires --firewall-only'
fi
if [ "$FIREWALL_ONLY" -eq 1 ]; then
  [ "$REPAIR_SERVICES" -eq 0 ] && [ "$RESUME_SERVICES" -eq 0 ] && \
    [ "$RESET_INCOMPLETE_DB" -eq 0 ] && [ "$CHECK_ONLY" -eq 0 ] || \
    die '--firewall-only cannot be combined with service, database or pkg check modes'
elif [ "$REPAIR_SERVICES" -eq 1 ] || [ "$RESUME_SERVICES" -eq 1 ]; then
  [ -z "$FIREWALL_SUBNET" ] && [ "$FIREWALL_BACKEND" = auto ] || \
    die 'Use --firewall-only to manage the firewall of an existing installation'
fi
site_name_valid "$DOMAIN" || die 'Invalid --domain value (use a hostname, no spaces or URL)'
case "$REQUIRE_MAJOR" in ''|[0-9]|[0-9][0-9]) : ;; *) die '--require-major must be 1 or 2 decimal digits' ;; esac
if [ -n "$CERT_SOURCE" ] || [ -n "$KEY_SOURCE" ]; then
  [ -n "$CERT_SOURCE" ] && [ -n "$KEY_SOURCE" ] || die 'Supply both --cert and --key'
  [ -r "$CERT_SOURCE" ] && [ -r "$KEY_SOURCE" ] || die 'TLS certificate or key not readable'
fi
[ "$(id -u)" -eq 0 ] || die 'Run as root (su -)'
[ "$(uname -s)" = FreeBSD ] || die 'This installer requires FreeBSD'
OS_MAJOR=$(freebsd-version -u | cut -d. -f1)
case "$OS_MAJOR" in 14|15) : ;; *) die "Unsupported FreeBSD version $OS_MAJOR (supported 14 and 15)" ;; esac
[ "$(uname -m)" = amd64 ] || die 'Only FreeBSD amd64 is supported by this edition'
need_cmd openssl
if [ "$FIREWALL_ONLY" -eq 1 ] && [ "$LAN_MODE" -eq 0 ]; then
  if [ -s "$STATE_DIR/lan-binding" ]; then
    read -r LAN_IFACE LAN_IP < "$STATE_DIR/lan-binding" || :
  else
    warn 'No saved LAN binding found; detecting the LAN IP. Confirm it matches the existing Nginx and Zabbix listeners.'
  fi
  LAN_MODE=1
fi
if [ "$LAN_MODE" -eq 1 ] && [ "$REPAIR_SERVICES" -eq 1 ]; then
  die '--repair-services does not change LAN bindings; use zabbix-freebsd15-lan-bind.sh'
fi
if [ "$LAN_MODE" -eq 1 ] && [ "$RESUME_SERVICES" -eq 1 ]; then
  die '--resume-services does not change listeners; run zabbix-freebsd15-lan-bind.sh on an existing installation'
fi
if [ "$LAN_MODE" -eq 1 ]; then
  [ "$PUBLIC_WEB" -eq 0 ] && [ "$PUBLIC_TRAPPER" -eq 0 ] || \
    die '--lan-* cannot be combined with --public-web or --public-trapper'
  resolve_lan
  log "LAN interface: $LAN_IFACE, assigned IPv4: $LAN_IP"
fi

# Scoped firewall integration. Does not enable, disable, flush or replace firewall policy.
# Local MySQL (3306), PHP-FPM, passive-agent TCP/10050, and SSH are untouched.
fw_yes() {
  case "$1" in YES|yes|Yes|ON|on|on|1|TRUE|true) return 0 ;; *) return 1 ;; esac
}
fw_rcvalue() { sysrc -n "$1" 2>/dev/null || :; }
fw_pf_live() { command -v pfctl >/dev/null 2>&1 && pfctl -s info 2>/dev/null | grep -q '^Status: Enabled'; }
fw_ipfw_live() { command -v sysctl >/dev/null 2>&1 && [ "$(sysctl -n net.inet.ip.fw.enable 2>/dev/null || :)" = 1 ]; }
fw_detect() {
  if [ "$FIREWALL_BACKEND" = off ]; then FIREWALL_SELECTED=none; return; fi
  pf_enabled=0; ipfw_enabled=0
  if fw_yes "$(fw_rcvalue pf_enable)" || fw_pf_live; then pf_enabled=1; fi
  if fw_yes "$(fw_rcvalue firewall_enable)" || fw_ipfw_live; then ipfw_enabled=1; fi
  if [ "$FIREWALL_BACKEND" = auto ]; then
    [ "$pf_enabled" -eq 0 ] || [ "$ipfw_enabled" -eq 0 ] || \
      die 'Both PF and IPFW appear enabled. Choose explicitly with --firewall pf or ipfw.'
    FIREWALL_SELECTED=none
    [ "$pf_enabled" -eq 0 ] || FIREWALL_SELECTED=pf
    [ "$ipfw_enabled" -eq 0 ] || FIREWALL_SELECTED=ipfw
  else
    FIREWALL_SELECTED=$FIREWALL_BACKEND
  fi
  if [ "$FIREWALL_SELECTED" = none ]; then
    warn 'No active/configured PF or IPFW detected. No firewall was enabled or changed.'
  elif [ "$FIREWALL_SELECTED" = pf ] && [ "$pf_enabled" -eq 0 ]; then
    warn 'PF is not enabled. Preparing rules only; activate PF separately AFTER reviewing SSH access.'
  elif [ "$FIREWALL_SELECTED" = ipfw ] && [ "$ipfw_enabled" -eq 0 ]; then
    warn 'IPFW is not enabled. Preparing rules only; activate IPFW separately AFTER reviewing SSH access.'
  fi
}

# Calculate the assigned interface's IPv4 network (supports hex FreeBSD netmasks).
# Fail closed on ambiguous, missing or non-contiguous masks.
fw_interface_subnet() {
  ifconfig "$LAN_IFACE" inet | awk -v ip="$LAN_IP" '
    function hexdec(s,      i,n,v) {
      n=0; s=tolower(s)
      for (i=1;i<=length(s);i++) {
        v=index("0123456789abcdef",substr(s,i,1))-1
        if (v<0) exit 2
        n=n*16+v
      }
      return n
    }
    $1=="inet" && $2==ip {
      for (i=3;i<=NF;i++) if ($i=="netmask") {mask=$(i+1);break}
      if (mask !~ /^0x[0-9a-fA-F]+$/ || length(mask)!=10) exit 2
      if (split(ip,a,".")!=4) exit 2
      prefix=0; zero=0
      for (i=1;i<=4;i++) {
        m=hexdec(substr(mask,3+(i-1)*2,2)); net=0
        for (b=7;b>=0;b--) {
          bit=int(m/(2^b))%2
          if (bit && zero) exit 2
          if (bit) prefix++; else zero=1
          if (bit && int(a[i]/(2^b))%2) net+=2^b
        }
        network=(i==1 ? net : network "." net)
      }
      if (prefix<8 || prefix>32) exit 2
      print network "/" prefix
      found=1; exit
    }
    END {if (!found) exit 2}
  '
}
fw_validate_subnet() {
  # Require a canonical source CIDR; callers may specify an additional trusted
  # VLAN/subnet, which does NOT have to contain the server destination IP.
  # Avoid accidentally permitting all networks (0/0) or very broad ranges.
  printf '%s\n' "$FIREWALL_SUBNET" | awk -F/ '
    NF!=2 {exit 2}
    $2 !~ /^[0-9]+$/ || $2+0<16 || $2+0>32 {exit 2}
    {
      if (split($1,net,".")!=4) exit 2
      n=$2+0
      for (i=1;i<=4;i++) {
        if (net[i] !~ /^[0-9]+$/ || net[i]+0>255) exit 2
        for (b=7;b>=0;b--) {
          bit=int(net[i]/(2^b))%2
          if (n>0) n--
          else if (bit!=0) exit 2
        }
      }
    }
  ' || die "Invalid/noncanonical --firewall-subnet '$FIREWALL_SUBNET' (prefix must be /16 through /32)"
}
fw_prepare() {
  [ "$LAN_MODE" -eq 1 ] || { warn 'No LAN binding specified, skipping firewall configuration'; return 1; }
  [ -n "$FIREWALL_SUBNET" ] || FIREWALL_SUBNET=$(fw_interface_subnet) || \
    die 'Cannot infer interface subnet; specify --firewall-subnet e.g. 192.168.1.0/24'
  fw_validate_subnet
  fw_detect
  printf 'Firewall: backend=%s interface=%s destination=%s sources=%s tcp_ports=443,10051\n' \
    "$FIREWALL_SELECTED" "$LAN_IFACE" "$LAN_IP" "$FIREWALL_SUBNET"
  return 0
}

fw_pf() {
  need_cmd pfctl
  pf_conf=$(fw_rcvalue pf_rules)
  [ -n "$pf_conf" ] || pf_conf=/etc/pf.conf
  case "$pf_conf" in /*) : ;; *) die "Refusing non-absolute pf_rules path: $pf_conf" ;; esac
  [ -f "$pf_conf" ] || die "PF is configured but $pf_conf is missing. Create/review a PF policy first."
  [ ! -L "$pf_conf" ] || die "Refusing symlink PF rules file: $pf_conf"
  begin_count=$(grep -Fc "$PF_BEGIN" "$pf_conf" || :)
  end_count=$(grep -Fc "$PF_END" "$pf_conf" || :)
  [ "$begin_count" = 0 ] && [ "$end_count" = 0 ] || \
    { [ "$begin_count" = 1 ] && [ "$end_count" = 1 ]; } || \
    die 'Malformed or repeated managed PF firewall block; review manually.'
  rule="pass in quick on $LAN_IFACE inet proto tcp from $FIREWALL_SUBNET to $LAN_IP port { 443, 10051 } flags S/SA keep state"
  if [ "$FIREWALL_DRY_RUN" -eq 1 ]; then
    printf 'PF rules file: %s\nPF managed rule: %s\n' "$pf_conf" "$rule"
    return 0
  fi
  fw_tmp=$(mktemp "${pf_conf}.zbx.XXXXXX") || die 'Cannot create temporary PF rules'
  cp -p "$pf_conf" "$fw_tmp" || die "Cannot copy $pf_conf"
  # Remove just our previously managed block, insert before first filter rule.
  # This preserves PF required options/scrub/NAT ordering and prior policy.
  awk -v begin="$PF_BEGIN" -v end="$PF_END" -v rule="$rule" '
    function emit() { if (!inserted) {print begin; print rule; print end; inserted=1} }
    $0==begin {skipping=1; next}
    $0==end {skipping=0; next}
    skipping {next}
    /^[[:space:]]*(block|pass|anchor|antispoof)([[:space:]]|$)/ {emit()}
    {print}
    END {emit()}
  ' "$pf_conf" > "$fw_tmp" || die 'Could not render PF rules'
  pfctl -nf "$fw_tmp" || { rm -f "$fw_tmp"; die 'PF syntax check failed; original config unchanged'; }
  backup_file="$pf_conf.before-zabbix-v6-$STAMP"
  cp -p "$pf_conf" "$backup_file" || die 'Could not back up PF rules'
  mv -f "$fw_tmp" "$pf_conf" || die 'Could not replace PF rules'
  if fw_pf_live; then
    if ! pfctl -f "$pf_conf"; then
      warn 'PF reload failed: restoring saved PF rules'
      cp -p "$backup_file" "$pf_conf" || warn 'Emergency: restore PF rules file manually'
      pfctl -f "$pf_conf" || warn 'Emergency: PF active rules could not be reloaded'
      die 'PF reload failed and restore was attempted'
    fi
  else
    warn 'PF is not running; rules prepared for a future PF start. No firewall was enabled.'
  fi
  log "PF allow rule applied/staged: $FIREWALL_SUBNET -> $LAN_IP TCP/443,10051 ($pf_conf)"
}

fw_ipfw() {
  need_cmd ipfw
  # Use FreeBSD firewall_coscripts, run AFTER the system firewall rules are
  # installed at each boot/restart. Never overwrite the administrator's script.
  [ ! -L "$FIREWALL_IPFW_SCRIPT" ] || die "Refusing symbolic link: $FIREWALL_IPFW_SCRIPT"
  if [ -f "$FIREWALL_IPFW_SCRIPT" ] && \
    ! grep -Fq '# Managed by Zabbix FreeBSD 15 installer v6' "$FIREWALL_IPFW_SCRIPT"; then
    die "Unmanaged IPFW coscript exists: $FIREWALL_IPFW_SCRIPT"
  fi
  # On subsequent re-runs, retain the assigned slot. Otherwise select a free
  # low-number slot, before typical generic deny rules, without touching them.
  fw_ruleno=
  if [ -f "$FIREWALL_IPFW_SCRIPT" ]; then
    fw_ruleno=$(sed -n 's/^RULE_NUMBER=\([0-9][0-9]*\)$/\1/p' "$FIREWALL_IPFW_SCRIPT")
    [ -n "$fw_ruleno" ] || die 'Existing managed IPFW script lacks rule number; review it'
    current_setting=$(sed -n "s/^RULE_ARGS='\\(.*\\)'$/\\1/p" "$FIREWALL_IPFW_SCRIPT")
    wanted="allow tcp from $FIREWALL_SUBNET to $LAN_IP 443,10051 in via $LAN_IFACE setup keep-state"
    [ "$current_setting" = "$wanted" ] || die "Existing IPFW Zabbix rule differs. Review $FIREWALL_IPFW_SCRIPT before changing LAN bindings."
  else
    # Do not dynamically reassign rules when re-running. Save chosen slot.
    rules_list=$(ipfw -q list 2>/dev/null) || die 'Cannot list IPFW rules; is IPFW available?'
    fw_ruleno=0
    for candidate in $(jot 99 1 99); do
      if ! printf '%s\n' "$rules_list" | awk '{print $1}' | grep -Eq "^0*$candidate\$"; then
        fw_ruleno=$candidate
        break
      fi
    done
    [ "$fw_ruleno" -ne 0 ] || die 'No available IPFW rule number 1..99; configure a custom IPFW rule manually'
  fi
  fw_rule="allow tcp from $FIREWALL_SUBNET to $LAN_IP 443,10051 in via $LAN_IFACE setup keep-state"
  if [ "$FIREWALL_DRY_RUN" -eq 1 ]; then
    printf 'IPFW proposed rule: ipfw add %s %s\nIPFW persistence: firewall_coscripts -> %s\n' \
      "$fw_ruleno" "$fw_rule" "$FIREWALL_IPFW_SCRIPT"
    return 0
  fi
  [ ! -f "$FIREWALL_IPFW_SCRIPT" ] || backup "$FIREWALL_IPFW_SCRIPT"
  cat > "$FIREWALL_IPFW_SCRIPT" <<EOF
#!/bin/sh
# Managed by Zabbix FreeBSD 15 installer v6
# Called by FreeBSD ipfw via firewall_coscripts after base rules load.
RULE_NUMBER=$fw_ruleno
RULE_ARGS='$fw_rule'
case "\${1:-quietstart}" in
  quietstart|start)
    if /sbin/ipfw -q list "\$RULE_NUMBER" 2>/dev/null | grep -Eq "^0*\$RULE_NUMBER[[:space:]]"; then
      /sbin/ipfw -q list "\$RULE_NUMBER" | grep -Fq "allow tcp from $FIREWALL_SUBNET to $LAN_IP 443,10051" || {
        echo 'IPFW Zabbix rule slot is in use by a different rule; refusing override' >&2
        exit 1
      }
    else
      # Deliberate word splitting of administrator-validated, generated arguments.
      # shellcheck disable=SC2086
      /sbin/ipfw -q add "\$RULE_NUMBER" \$RULE_ARGS || exit 1
    fi
    ;;
  quietstop|stop)
    # Leave the firewall rule in place on shutdown, so restart/reload cannot
    # inadvertently interrupt active LAN monitoring. Boot rules are rebuilt.
    ;;
  *) echo 'Usage: zabbix-monitoring-ipfw.sh {quietstart|quietstop}' >&2; exit 2 ;;
esac
EOF
  chmod 0700 "$FIREWALL_IPFW_SCRIPT"
  # Keep any pre-existing FreeBSD coscripts (NAT, other site logic).
  coscripts=$(fw_rcvalue firewall_coscripts)
  case " $coscripts " in
    *" $FIREWALL_IPFW_SCRIPT "*) : ;;
    *) sysrc firewall_coscripts="${coscripts:+$coscripts }$FIREWALL_IPFW_SCRIPT" >/dev/null || \
         die 'Failed to register IPFW firewall_coscripts' ;;
  esac
  if fw_ipfw_live; then
    sh -n "$FIREWALL_IPFW_SCRIPT" || die 'Generated IPFW script has a syntax error'
    "$FIREWALL_IPFW_SCRIPT" quietstart || die 'Could not add IPFW Zabbix LAN rule'
  else
    warn 'IPFW is not running; coscript prepared, no firewall was enabled.'
  fi
  log "IPFW Zabbix LAN rule #$fw_ruleno configured (persistent via firewall_coscripts)"
}
fw_apply() {
  [ "$FIREWALL_SELECTED" != none ] || return 0
  case "$FIREWALL_SELECTED" in
    pf) fw_pf ;;
    ipfw) fw_ipfw ;;
  esac
}

# FreeBSD rc.conf uses shell variable names, NOT service script basenames.
# In particular the rc.d script mysql-server reads mysql_enable, and the
# agent service is zabbix_agentd (not zabbix_agent). Repair only known typos.
# The original file is backed up before any edit; database is untouched.
repair_bad_rc_conf() {
  for rc_file in /etc/rc.conf /etc/rc.conf.local; do
    [ -f "$rc_file" ] || continue
    if grep -Eq '^[[:space:]]*(mysql-server_enable|php-fpm_enable|zabbix-agent_enable|zabbix_agent_enable)[[:space:]]*=' "$rc_file"; then
      rc_backup="$rc_file.before-zabbix-v5-$STAMP"
      cp -p "$rc_file" "$rc_backup" || die "Could not back up $rc_file"
      sed -i '' \
        -e '/^[[:space:]]*mysql-server_enable[[:space:]]*=/d' \
        -e '/^[[:space:]]*php-fpm_enable[[:space:]]*=/d' \
        -e '/^[[:space:]]*zabbix-agent_enable[[:space:]]*=/d' \
        -e '/^[[:space:]]*zabbix_agent_enable[[:space:]]*=/d' \
        "$rc_file" || die "Could not repair $rc_file"
      log "Removed unsupported service variables from $rc_file (backup: $rc_backup)"
    fi
    /bin/sh -n "$rc_file" || die "Syntax error in $rc_file; restore backup and review"
  done
}

check_service_present() {
  [ -x "/usr/local/etc/rc.d/$1" ] || [ -x "/etc/rc.d/$1" ] || \
    die "Missing rc.d service '$1'. Check pkg info and /usr/local/etc/rc.d before retrying."
}

start_existing_stack_services() {
  # These are rc.d script names, NOT the rc.conf variable names.
  for svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx; do
    check_service_present "$svc"
  done
  # These are the actual rcvars supported by the five services.
  sysrc mysql_enable=YES php_fpm_enable=YES nginx_enable=YES \
    zabbix_server_enable=YES zabbix_agentd_enable=YES >/dev/null || \
    die 'Failed to enable services using sysrc'
  for svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx; do
    if service "$svc" onestatus >/dev/null 2>&1; then
      log "$svc already running"
    else
      log "Starting $svc"
      service "$svc" start || die "Failed to start $svc. Check service logs and configuration."
    fi
  done
  for svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx; do
    service "$svc" onestatus >/dev/null 2>&1 || die "$svc is not running. Check logs."
  done
}

if [ "$FIREWALL_ONLY" -eq 1 ]; then
  log 'Configuring ONLY the existing LAN firewall; no package/DB/service/listener changes'
  # Never open a firewall port for an unrelated/unconfigured listener.
  fw_server_conf=$(fw_rcvalue zabbix_server_config)
  [ -n "$fw_server_conf" ] && [ -f "$fw_server_conf" ] || \
    die 'Cannot locate existing Zabbix server configuration in sysrc zabbix_server_config'
  fw_nginx_site=/usr/local/etc/nginx/zabbix-monitoring.conf
  [ -f "$fw_nginx_site" ] || die "Missing existing Zabbix Nginx site: $fw_nginx_site"
  grep -Eq "^[[:space:]]*listen[[:space:]]+$LAN_IP:443[[:space:]]+ssl" "$fw_nginx_site" || \
    die "Nginx is not configured to listen on $LAN_IP:443; bind the LAN listener first"
  awk -F= -v ip="$LAN_IP" '$1=="ListenIP" {n=split($2,addr,","); for (i=1;i<=n;i++) if(addr[i]==ip) ok=1} END {exit !ok}' "$fw_server_conf" || \
    die "Zabbix server is not configured to listen on $LAN_IP:10051"
  fw_prepare && fw_apply
  exit 0
fi

if [ "$REPAIR_SERVICES" -eq 1 ]; then
  log 'Repairing FreeBSD rc.conf and existing Zabbix stack service startup only'
  repair_bad_rc_conf
  start_existing_stack_services
  log 'All installed services are running; database and application configs were untouched.'
  exit 0
fi

# Non-destructive recovery from service startup failures in a managed installation.
# This path intentionally runs BEFORE repo/pkg discovery and database provisioning.
if [ "$RESUME_SERVICES" -eq 1 ]; then
  [ "$RESET_INCOMPLETE_DB" -eq 0 ] || die '--resume-services cannot be combined with --reset-incomplete-db'
  repair_bad_rc_conf
  [ -f "$STATE_DIR/owned" ] && [ -s "$STATE_DIR/version" ] || \
    die 'Resume refused: missing installer ownership or version marker'
  [ -f "$STATE_DIR/database-seeded" ] && [ -f "$STATE_DIR/admin-rotated" ] || \
    die 'Resume refused: database has not been marked fully initialized and admin-rotated'
  need_cmd php-fpm
  need_cmd nginx
  need_cmd zabbix_server
  need_cmd zabbix_agentd
  SERVER_CONFIG=$(sysrc -n zabbix_server_config 2>/dev/null || :)
  AGENT_CONFIG=$(sysrc -n zabbix_agentd_config 2>/dev/null || :)
  [ -n "$SERVER_CONFIG" ] && [ -f "$SERVER_CONFIG" ] || die 'Missing managed Zabbix server config in sysrc zabbix_server_config'
  [ -n "$AGENT_CONFIG" ] && [ -f "$AGENT_CONFIG" ] || die 'Missing managed Zabbix agent config in sysrc zabbix_agentd_config'
  grep -Fq "$MARKER" "$SERVER_CONFIG" && grep -Fq "$MARKER" "$AGENT_CONFIG" || \
    die 'Resume refused: Zabbix configs are not managed by this installer'
  log 'Validating existing configs without modifying installation or database'
  php-fpm -t || die 'PHP-FPM config invalid'
  nginx -t || die 'Nginx config invalid'
  zabbix_server -T -c "$SERVER_CONFIG" || die 'Zabbix server config invalid'
  zabbix_agentd -T -c "$AGENT_CONFIG" || die 'Zabbix agent config invalid'
  log 'Enabling and starting the existing installation (non-destructive)'
  start_existing_stack_services
  log 'Existing Zabbix installation is running. No database import/reset or pkg operation performed.'
  printf 'Zabbix version (original install): %s\n' "$(cat "$STATE_DIR/version")"
  printf 'Local HTTPS: https://127.0.0.1/\n'
  exit 0
fi

[ -x /usr/sbin/pkg ] || die 'FreeBSD pkg bootstrap stub is missing'

log 'Bootstrapping pkg'
if ! pkg -N >/dev/null 2>&1; then pkg bootstrap -y || die 'pkg bootstrap failed'; fi

# Select latest for *ports packages* only; leave pkgbase and kmod repositories alone.
# A separate override avoids altering FreeBSD-generated FreeBSD.conf on pkgbase.
if [ "$REPO_POLICY" = latest ]; then
  install -d -m 0755 /usr/local/etc/pkg/repos
  if [ -e "$REPO_OVERRIDE" ] && ! grep -Fq "$MARKER" "$REPO_OVERRIDE"; then
    die "Existing unmanaged repo override $REPO_OVERRIDE; use --keep-repo or review it"
  fi
  cat > "$REPO_OVERRIDE" <<EOF
# $MARKER
# This changes the system's FreeBSD-ports package branch to latest.
# It does NOT change FreeBSD-base or FreeBSD-ports-kmods.
FreeBSD-ports: {
  url: "pkg+https://pkg.FreeBSD.org/\${ABI}/latest",
  mirror_type: "srv",
  signature_type: "fingerprints",
  fingerprints: "/usr/share/keys/pkg",
  enabled: yes
}
EOF
  chmod 0644 "$REPO_OVERRIDE"
  warn 'Using FreeBSD-ports latest for this host. Review future pkg upgrade transactions.'
fi
log 'Refreshing FreeBSD binary package catalogs'
pkg update -f || die 'pkg repository update failed; check network, DNS, and repository configuration'

# Query pkg FIRST. pkg rquery reports actual mirror availability, not merely
# the FreeBSD Ports versions. No local ports tree is read or updated.
log 'Discovering the newest complete Zabbix package family available through pkg'
CATALOG=$(mktemp /tmp/zabbix-pkg-catalog.XXXXXX) || die 'Unable to create temp file'
# An interrupted import must not leave a global MySQL security relaxation behind.
restore_mysql_trust() {
  [ "$MYSQL_TRUST_CHANGED" -eq 1 ] || return 0
  if mysql_root -e "SET GLOBAL log_bin_trust_function_creators = $MYSQL_TRUST_ORIGINAL" >/dev/null 2>&1; then
    MYSQL_TRUST_CHANGED=0
    rm -f "$MYSQL_TRUST_MARKER"
    log "Restored MySQL log_bin_trust_function_creators=$MYSQL_TRUST_ORIGINAL"
  else
    warn "URGENT: unable to restore MySQL log_bin_trust_function_creators=$MYSQL_TRUST_ORIGINAL; restore it manually"
    return 1
  fi
}
cleanup() {
  restore_mysql_trust || :
  rm -f "$CATALOG"
}
trap 'cleanup' 0
trap 'exit 129' 1
trap 'exit 130' 2
trap 'exit 143' 15
pkg rquery -a '%n|%v' > "$CATALOG" || die 'Failed to query remote pkg catalog'

remote_version() { awk -F '|' -v name="$1" '$1==name {print $2; exit}' "$CATALOG"; }
normalize_version() { printf '%s\n' "$1" | sed 's/,[0-9][0-9]*$//;s/_[0-9][0-9]*$//'; }
ZBX_BASE=
ZBX_VERSION=0.0.0
# Pull server package NAMES; these can be zabbix8-server, zabbix74-server, etc.
for serverpkg in $(awk -F '|' '$1 ~ /^zabbix[0-9]+-server$/ {print $1}' "$CATALOG" | sort -u); do
  candidate=${serverpkg%-server}
  sv=$(remote_version "$serverpkg")
  av=$(remote_version "$candidate-agent")
  fv=$(remote_version "$candidate-frontend-$PHP_FLAVOR")
  [ -n "$sv" ] && [ -n "$av" ] && [ -n "$fv" ] || continue
  sv=$(normalize_version "$sv")
  av=$(normalize_version "$av")
  fv=$(normalize_version "$fv")
  printf '%s\n' "$sv" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || continue
  [ "$sv" = "$av" ] && [ "$sv" = "$fv" ] || continue
  candidate_major=${sv%%.*}
  [ -z "$REQUIRE_MAJOR" ] || [ "$candidate_major" = "$REQUIRE_MAJOR" ] || continue
  if [ -z "$ZBX_BASE" ] || [ "$(pkg version -t "$sv" "$ZBX_VERSION")" = '>' ]; then
    ZBX_BASE=$candidate
    ZBX_VERSION=$sv
  fi
done
if [ -z "$ZBX_BASE" ]; then
  if [ -n "$REQUIRE_MAJOR" ]; then
    die "No complete Zabbix $REQUIRE_MAJOR binary package set (server/agent/frontend-$PHP_FLAVOR) exists in this pkg repository. Retry later or deliberately select an older version by omitting --require-major."
  fi
  die 'No complete Zabbix server + agent + frontend package set found in configured pkg repositories'
fi
SERVER_PKG="$ZBX_BASE-server"
AGENT_PKG="$ZBX_BASE-agent"
FRONTEND_PKG="$ZBX_BASE-frontend-$PHP_FLAVOR"
ZBX_MAJOR=${ZBX_VERSION%%.*}
if [ "$ZBX_MAJOR" -lt 8 ]; then
  warn "Zabbix 8 binary packages are not available; selected $ZBX_VERSION. Use --require-8 to REFUSE older versions."
fi
log "Selected Zabbix $ZBX_VERSION with pkg: $SERVER_PKG / $AGENT_PKG / $FRONTEND_PKG"
if [ "$CHECK_ONLY" -eq 1 ]; then
  printf 'FreeBSD version: %s\nZabbix version: %s\nPackages: %s %s %s\n' "$(freebsd-version -u)" "$ZBX_VERSION" "$SERVER_PKG" "$AGENT_PKG" "$FRONTEND_PKG"
  exit 0
fi

# Do not claim existing Zabbix/NGINX/PHP/MySQL installations on unmanaged hosts.
if [ ! -f "$STATE_DIR/owned" ]; then
  if pkg info -a -q 2>/dev/null | grep -Eq '^zabbix[0-9].*-(server|agent|frontend)'; then
    die 'Existing Zabbix packages found: this script is not an upgrade tool'
  fi
  for p in nginx mysql84-server mariadb114-server mariadb118-server mariadb1011-server php84; do
    if pkg info -e "$p" >/dev/null 2>&1; then
      die "Existing unmanaged $p package detected; this requires a dedicated fresh machine"
    fi
  done
  for f in /usr/local/etc/nginx/nginx.conf /usr/local/etc/mysql/my.cnf /usr/local/etc/php-fpm.conf; do
    [ ! -f "$f" ] || die "Unmanaged service configuration already exists: $f"
  done
  [ ! -d /var/db/mysql/mysql ] || die 'Existing MySQL data directory found: refusing to overwrite'
fi
install -d -m 0700 "$SECRETS_DIR" "$STATE_DIR"
chmod 0700 "$SECRETS_DIR" "$STATE_DIR"
[ ! -f "$STATE_DIR/version" ] || [ "$(cat "$STATE_DIR/version")" = "$ZBX_VERSION" ] || \
  die "Managed Zabbix installation is $(cat "$STATE_DIR/version"); this is NOT an upgrade script"
SERVER_CONFIG="/usr/local/etc/$ZBX_BASE/zabbix_server.conf"
AGENT_CONFIG="/usr/local/etc/$ZBX_BASE/zabbix_agentd.conf"
SCHEMA_DIR="/usr/local/share/$ZBX_BASE/server/database/mysql"
FRONTEND_NAME="Zabbix $ZBX_VERSION"
MYSQL_CONF=/usr/local/etc/mysql/my.cnf
NGINX_CONF=/usr/local/etc/nginx/nginx.conf
NGINX_SITE=/usr/local/etc/nginx/zabbix-monitoring.conf
PHP_POOL=/usr/local/etc/php-fpm.d/zabbix-monitoring.conf
TLS_DIR=/usr/local/etc/ssl/zabbix-monitoring
DB_PASS_FILE="$SECRETS_DIR/mysql-zabbix.password"
DB_CNF="$SECRETS_DIR/mysql-zabbix.cnf"
ROOT_CNF="$SECRETS_DIR/mysql-root.cnf"
ADMIN_CREDS="$SECRETS_DIR/frontend-admin.txt"
for f in "$SERVER_CONFIG" "$AGENT_CONFIG" "$NGINX_SITE" "$PHP_POOL"; do managed_or_absent "$f"; done

# Write ownership marker only after package/config preflight. Keep on interrupted
# runs for deliberate recovery, but forbid silent in-place version changes.
touch "$STATE_DIR/owned"
printf '%s\n' "$ZBX_VERSION" > "$STATE_DIR/version"
repair_bad_rc_conf
log 'Installing MySQL 8.4 LTS, nginx, PHP 8.4, and the selected Zabbix packages'
pkg install -y "$MYSQL_PACKAGE" nginx php84 ca_root_nss "$SERVER_PKG" "$AGENT_PKG" "$FRONTEND_PKG" || \
  die 'pkg installation failed. Review repository and dependency errors above.'

need_cmd zabbix_server
need_cmd zabbix_agentd
need_cmd php
need_cmd mysql
need_cmd php-fpm
need_cmd nginx
for svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx; do
  check_service_present "$svc"
done
INSTALLED_VERSION=$(zabbix_server -V | sed -n '1s/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p')
[ "$INSTALLED_VERSION" = "$ZBX_VERSION" ] || die "Installed server $INSTALLED_VERSION differs from requested $ZBX_VERSION"
ldd /usr/local/sbin/zabbix_server | grep -Eiq 'lib(mysqlclient|mariadb)' || \
  die 'The Zabbix server pkg uses a different DB backend, not MySQL. Stop; do not initialize the database.'
[ -s "$SCHEMA_DIR/schema.sql" ] && [ -s "$SCHEMA_DIR/images.sql" ] && [ -s "$SCHEMA_DIR/data.sql" ] || \
  die "Zabbix database scripts missing: $SCHEMA_DIR"
WEBROOT="/usr/local/www/$ZBX_BASE"
if [ ! -f "$WEBROOT/index.php" ]; then
  WEBINDEX=$(pkg info -l "$FRONTEND_PKG" 2>/dev/null | awk '/\/index\.php$/ {print $NF; exit}')
  [ -n "$WEBINDEX" ] && [ -f "$WEBINDEX" ] || die 'Could not locate Zabbix frontend document root'
  WEBROOT=${WEBINDEX%/index.php}
fi
[ -d "$WEBROOT/conf" ] || die "Frontend conf directory missing: $WEBROOT"
for m in bcmath ctype gd gettext mbstring mysqli session simplexml sockets xml xmlreader xmlwriter; do
  php -m | grep -iq "^${m}\$" || die "Required PHP extension missing: $m"
done

log 'Installing a local-only MySQL configuration (new host only)'
install -d -m 0755 /usr/local/etc/mysql
managed_or_absent "$MYSQL_CONF"
backup "$MYSQL_CONF"
cat > "$MYSQL_CONF" <<EOF
# $MARKER
[mysqld]
bind-address=127.0.0.1
mysqlx-bind-address=127.0.0.1
local_infile=OFF
skip_name_resolve=ON
EOF
chmod 0644 "$MYSQL_CONF"
sysrc mysql_enable=YES >/dev/null || die 'Failed to enable mysql-server with mysql_enable'
service mysql-server onestatus >/dev/null 2>&1 || service mysql-server start || die 'MySQL failed to start'
i=0
while [ "$i" -lt 40 ]; do
  if mysqladmin ping --silent >/dev/null 2>&1; then break; fi
  i=$((i + 1))
  sleep 1
done
[ "$i" -lt 40 ] || die 'MySQL failed to respond. Inspect /var/db/mysql/*.err'
mysql_root() {
  if [ -s "$ROOT_CNF" ]; then mysql --defaults-extra-file="$ROOT_CNF" "$@"
  else mysql -uroot "$@"; fi
}
if [ ! -s "$ROOT_CNF" ]; then
  mysql -uroot -N -e 'SELECT 1' >/dev/null 2>&1 || \
    die 'MySQL root is not accessible with socket auth/no password. No existing MySQL instance should be present.'
  ROOT_PASSWORD=$(new_password)
  # Save secret before changing password, so interrupted runs can recover.
  printf '[client]\nuser=root\npassword=%s\nhost=localhost\n' "$ROOT_PASSWORD" > "$ROOT_CNF"
  chmod 0600 "$ROOT_CNF"
  printf "ALTER USER 'root'@'localhost' IDENTIFIED BY '%s';\n" "$ROOT_PASSWORD" | mysql -uroot || die 'Could not secure MySQL root account'
  unset ROOT_PASSWORD
fi
mysql_root -N -e 'SELECT 1' >/dev/null || die "Cannot authenticate to MySQL root using $ROOT_CNF"
# If a previous run was killed during schema import, restore the original
# global setting before doing anything else. The marker is owned by this script.
if [ -s "$MYSQL_TRUST_MARKER" ]; then
  MYSQL_PREVIOUS_TRUST=$(cat "$MYSQL_TRUST_MARKER")
  case "$MYSQL_PREVIOUS_TRUST" in 0|1) : ;; *) die 'Invalid saved MySQL trust setting; inspect it manually' ;; esac
  log 'Recovering MySQL security setting from an interrupted schema import'
  mysql_root -e "SET GLOBAL log_bin_trust_function_creators = $MYSQL_PREVIOUS_TRUST" || \
    die 'Could not restore the MySQL trust setting from the previous interrupted run'
  rm -f "$MYSQL_TRUST_MARKER"
fi

if [ ! -s "$DB_PASS_FILE" ]; then
  count=$(mysql_root -N -e "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='$DB_NAME'")
  [ "$count" -eq 0 ] || die 'Existing Zabbix database without installer credentials: refusing to modify'
  new_password > "$DB_PASS_FILE"
  chmod 0600 "$DB_PASS_FILE"
fi
DB_PASS=$(cat "$DB_PASS_FILE")
[ "${#DB_PASS}" -eq 64 ] || die 'Database credential file is invalid'
printf '[client]\nuser=%s\npassword=%s\nhost=127.0.0.1\nport=3306\ndefault-character-set=utf8mb4\n' \
  "$DB_USER" "$DB_PASS" > "$DB_CNF"
chmod 0600 "$DB_CNF"
{
  printf 'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;\n' "$DB_NAME"
  printf "CREATE USER IF NOT EXISTS '%s'@'127.0.0.1' IDENTIFIED BY '%s';\n" "$DB_USER" "$DB_PASS"
  printf "ALTER USER '%s'@'127.0.0.1' IDENTIFIED BY '%s';\n" "$DB_USER" "$DB_PASS"
  printf "GRANT ALL PRIVILEGES ON %s.* TO '%s'@'127.0.0.1';\n" "$DB_NAME" "$DB_USER"
} | mysql_root || die 'Database account provisioning failed'
mysql --defaults-extra-file="$DB_CNF" -N -e 'SELECT 1' >/dev/null || die 'Zabbix database credentials are invalid'

TABLE_COUNT=$(mysql_root -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME'")
# A previous schema.sql run that fails at CREATE TRIGGER leaves tables behind.
# SQL DDL is not a single transaction: retrying on top of them is unsafe.
if [ "$RESET_INCOMPLETE_DB" -eq 1 ]; then
  [ -f "$STATE_DIR/owned" ] && [ -s "$DB_PASS_FILE" ] || \
    die 'Reset refused: missing installer ownership marker or database credentials'
  [ ! -f "$STATE_DIR/database-seeded" ] || \
    die 'Reset refused: installer recorded a completed database seed'
  service zabbix_server onestatus >/dev/null 2>&1 && \
    die 'Reset refused: Zabbix server service is running; stop and review it manually'
  if [ "$TABLE_COUNT" -gt 0 ]; then
    USERS_TABLE=$(mysql_root -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$DB_NAME' AND table_name='users'")
    if [ "$USERS_TABLE" -gt 0 ]; then
      ADMIN_ROWS=$(mysql_root -N -e "SELECT COUNT(*) FROM \`$DB_NAME\`.users WHERE username='Admin'")
      [ "$ADMIN_ROWS" -eq 0 ] || die 'Reset refused: Admin data exists; this is not a verified empty bootstrap'
    fi
    need_cmd mysqldump
    INCOMPLETE_BACKUP="$SECRETS_DIR/incomplete-$DB_NAME-$STAMP.sql"
    log "Backing up the incomplete database to $INCOMPLETE_BACKUP"
    if ! mysqldump --defaults-extra-file="$ROOT_CNF" --single-transaction \
        --routines --triggers --databases "$DB_NAME" > "$INCOMPLETE_BACKUP"; then
      rm -f "$INCOMPLETE_BACKUP"
      die 'Reset refused: backup of the incomplete database failed'
    fi
    chmod 0600 "$INCOMPLETE_BACKUP"
    log 'Recreating only the installer-owned, incomplete Zabbix database'
    mysql_root -e "DROP DATABASE \`$DB_NAME\`; CREATE DATABASE \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;" || \
      die 'Could not reset incomplete Zabbix database (backup retained)'
    TABLE_COUNT=0
  else
    log 'No database tables exist: nothing to reset'
  fi
fi
if [ "$TABLE_COUNT" -eq 0 ]; then
  log 'Initializing Zabbix schema, images, and default data'
  # Zabbix installs deterministic triggers. With MySQL binary logging ON,
  # temporarily trust function/trigger creators as documented by Zabbix.
  # Save the original GLOBAL value and always restore it after schema.sql.
  MYSQL_TRUST_ORIGINAL=$(mysql_root -N -e 'SELECT @@GLOBAL.log_bin_trust_function_creators')
  MYSQL_BINLOG_ENABLED=$(mysql_root -N -e 'SELECT @@GLOBAL.log_bin')
  case "$MYSQL_TRUST_ORIGINAL" in 0|1) : ;; *) die 'Invalid MySQL trust variable' ;; esac
  case "$MYSQL_BINLOG_ENABLED" in 0|1) : ;; *) die 'Invalid MySQL binlog variable' ;; esac
  if [ "$MYSQL_BINLOG_ENABLED" -eq 1 ] && [ "$MYSQL_TRUST_ORIGINAL" -eq 0 ]; then
    log 'Temporarily allowing trigger creation during schema import'
    printf '%s\n' "$MYSQL_TRUST_ORIGINAL" > "$MYSQL_TRUST_MARKER"
    chmod 0600 "$MYSQL_TRUST_MARKER"
    MYSQL_TRUST_CHANGED=1
    mysql_root -e 'SET GLOBAL log_bin_trust_function_creators = 1' || \
      die 'Unable to temporarily set log_bin_trust_function_creators'
  fi
  if ! mysql --defaults-extra-file="$DB_CNF" "$DB_NAME" < "$SCHEMA_DIR/schema.sql"; then
    die 'Schema import failed; partial tables remain. Use --reset-incomplete-db after reviewing the error.'
  fi
  restore_mysql_trust || die 'Schema imported, but MySQL trust setting could not be restored'
  for sql in images.sql data.sql; do
    if ! mysql --defaults-extra-file="$DB_CNF" --default-character-set=utf8mb4 "$DB_NAME" < "$SCHEMA_DIR/$sql"; then
      die "Database import failed at $sql; partial data remain. Do not retry blindly."
    fi
  done
  mysql --defaults-extra-file="$DB_CNF" "$DB_NAME" -N -e \
    'SELECT mandatory FROM dbversion LIMIT 1' >/dev/null || die 'Missing database version after import'
  ADMIN_COUNT=$(mysql_root -N -e "SELECT COUNT(*) FROM \`$DB_NAME\`.users WHERE username='Admin'")
  [ "$ADMIN_COUNT" -eq 1 ] || die 'Zabbix Admin seed row missing or duplicated'
  TRIGGER_COUNT=$(mysql_root -N -e "SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='$DB_NAME'")
  [ "$TRIGGER_COUNT" -gt 0 ] || die 'No Zabbix database triggers found after schema import; refusing an incomplete installation'
  touch "$STATE_DIR/database-seeded"
  DB_NEW=1
else
  mysql --defaults-extra-file="$DB_CNF" "$DB_NAME" -N -e \
    'SELECT mandatory FROM dbversion LIMIT 1' >/dev/null 2>&1 || \
    die 'Database contains partial tables but lacks dbversion; use --reset-incomplete-db ONLY for this fresh installer-owned database'
  ADMIN_COUNT=$(mysql_root -N -e "SELECT COUNT(*) FROM \`$DB_NAME\`.users WHERE username='Admin'" 2>/dev/null || printf 0)
  [ "$ADMIN_COUNT" -eq 1 ] || \
    die 'Database contains incomplete schema/data; use --reset-incomplete-db ONLY if it holds no real data'
  TRIGGER_COUNT=$(mysql_root -N -e "SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='$DB_NAME'")
  [ "$TRIGGER_COUNT" -gt 0 ] || die 'No Zabbix triggers found; existing database schema is incomplete'
  if [ ! -f "$STATE_DIR/database-seeded" ]; then
    warn 'No database-seeded marker; database has Admin data. Confirm schema/triggers before proceeding.'
    die 'Refusing to assume a previously incomplete schema is healthy; restore snapshot or inspect database manually'
  fi
fi
# Never leave a newly created installation with the Zabbix default Admin/zabbix.
if [ ! -f "$STATE_DIR/admin-rotated" ]; then
  log 'Replacing the default dashboard Admin password'
  ADMIN_PASS=$(new_password)
  ADMIN_HASH=$(ZBX_ADMIN_PASS="$ADMIN_PASS" php -r 'echo password_hash(getenv("ZBX_ADMIN_PASS"), PASSWORD_BCRYPT, ["cost" => 12]);')
  [ -n "$ADMIN_HASH" ] || die 'Could not generate an Admin bcrypt hash'
  [ "$(mysql_root -N -e "SELECT COUNT(*) FROM zabbix.users WHERE username='Admin'")" -eq 1 ] || \
    die 'Default Admin account not found; check schema import'
  printf 'Username: Admin\nPassword: %s\n' "$ADMIN_PASS" > "$ADMIN_CREDS"
  chmod 0600 "$ADMIN_CREDS"
  printf "UPDATE zabbix.users SET passwd='%s' WHERE username='Admin';\n" "$ADMIN_HASH" | mysql_root || die 'Could not rotate Admin password'
  unset ADMIN_PASS ADMIN_HASH
  touch "$STATE_DIR/admin-rotated"
fi

log 'Configuring Zabbix server and isolated local agent'
if ! pw groupshow zbxagent >/dev/null 2>&1; then pw groupadd zbxagent; fi
if ! pw usershow zbxagent >/dev/null 2>&1; then
  pw useradd zbxagent -g zbxagent -d /nonexistent -s /usr/sbin/nologin -c 'Zabbix monitoring agent'
fi
install -d -o zabbix -g zabbix -m 0750 /var/run/zabbix /var/log/zabbix
install -d -o zbxagent -g zbxagent -m 0750 /var/run/zbxagent /var/log/zbxagent
install -d -m 0755 "${SERVER_CONFIG%/*}" "${AGENT_CONFIG%/*}"
backup "$SERVER_CONFIG"
backup "$AGENT_CONFIG"
TRAPPER_IP=127.0.0.1
[ "$PUBLIC_TRAPPER" -eq 0 ] || TRAPPER_IP=0.0.0.0
if [ "$LAN_MODE" -eq 1 ]; then TRAPPER_IP="127.0.0.1,$LAN_IP"; fi
cat > "$SERVER_CONFIG" <<EOF
# $MARKER
LogType=file
LogFile=/var/log/zabbix/zabbix_server.log
LogFileSize=10
PidFile=/var/run/zabbix/zabbix_server.pid
ListenIP=$TRAPPER_IP
ListenPort=10051
DBHost=127.0.0.1
DBPort=3306
DBName=$DB_NAME
DBUser=$DB_USER
DBPassword=$DB_PASS
CacheSize=128M
HistoryCacheSize=32M
HistoryIndexCacheSize=8M
TrendCacheSize=16M
ValueCacheSize=64M
Timeout=10
AlertScriptsPath=${SERVER_CONFIG%/*}/zabbix/alertscripts
ExternalScripts=${SERVER_CONFIG%/*}/zabbix/externalscripts
FpingLocation=/usr/local/sbin/fping
EOF
chown zabbix:zabbix "$SERVER_CONFIG"
chmod 0600 "$SERVER_CONFIG"
cat > "$AGENT_CONFIG" <<EOF
# $MARKER
LogType=file
LogFile=/var/log/zbxagent/zabbix_agentd.log
LogFileSize=10
PidFile=/var/run/zbxagent/zabbix_agentd.pid
User=zbxagent
Server=127.0.0.1
ServerActive=127.0.0.1:10051
Hostname=$DOMAIN
HostMetadata=FreeBSD
StartAgents=0
RefreshActiveChecks=60
Timeout=10
DenyKey=system.run[*]
EOF
chown root:zbxagent "$AGENT_CONFIG"
chmod 0640 "$AGENT_CONFIG"
# Explicit rc.d config paths: FreeBSD server/agent port directories differ.
sysrc zabbix_server_config="$SERVER_CONFIG" zabbix_agentd_config="$AGENT_CONFIG" >/dev/null

log 'Configuring isolated PHP-FPM pool and frontend secrets'
[ -f /usr/local/etc/php-fpm.conf ] || \
  cp /usr/local/etc/php-fpm.conf.default /usr/local/etc/php-fpm.conf || die 'Missing PHP-FPM base configuration'
install -d -m 0755 /usr/local/etc/php-fpm.d
managed_or_absent "$PHP_POOL"
backup "$PHP_POOL"
cat > "$PHP_POOL" <<EOF
; $MARKER
[zabbixmonitor]
user = www
group = www
listen = /var/run/zabbix-monitoring-php.sock
listen.owner = www
listen.group = www
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 20s
pm.max_requests = 500
clear_env = yes
security.limit_extensions = .php
php_admin_value[date.timezone] = UTC
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 300
php_admin_value[max_input_time] = 300
php_admin_value[post_max_size] = 32M
php_admin_value[upload_max_filesize] = 16M
php_admin_value[max_input_vars] = 10000
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_flag[session.cookie_httponly] = on
php_admin_flag[session.cookie_secure] = on
php_admin_flag[session.use_strict_mode] = on
EOF
chmod 0644 "$PHP_POOL"
FRONTEND_CONFIG="$WEBROOT/conf/zabbix.conf.php"
managed_or_absent "$FRONTEND_CONFIG"
backup "$FRONTEND_CONFIG"
cat > "$FRONTEND_CONFIG" <<EOF
<?php
// $MARKER
\$DB['TYPE'] = 'MYSQL';
\$DB['SERVER'] = '127.0.0.1';
\$DB['PORT'] = '3306';
\$DB['DATABASE'] = '$DB_NAME';
\$DB['USER'] = '$DB_USER';
\$DB['PASSWORD'] = '$DB_PASS';
\$DB['SCHEMA'] = '';
\$DB['ENCRYPTION'] = false;
\$ZBX_SERVER = '127.0.0.1';
\$ZBX_SERVER_PORT = '10051';
\$ZBX_SERVER_NAME = '$FRONTEND_NAME';
EOF
chown root:www "$FRONTEND_CONFIG"
chmod 0640 "$FRONTEND_CONFIG"
php -l "$FRONTEND_CONFIG" >/dev/null || die 'Frontend PHP config syntax error'

log 'Configuring TLS and dedicated Nginx HTTPS listener'
install -d -m 0750 "$TLS_DIR"
CRT="$TLS_DIR/server.crt"
KEY="$TLS_DIR/server.key"
if [ -n "$CERT_SOURCE" ]; then
  [ "$CERT_SOURCE" = "$CRT" ] || cp "$CERT_SOURCE" "$CRT"
  [ "$KEY_SOURCE" = "$KEY" ] || cp "$KEY_SOURCE" "$KEY"
elif [ ! -s "$CRT" ] || [ ! -s "$KEY" ]; then
  SELF_SIGNED=1
  CERT_SANS="DNS:$DOMAIN"
  if [ "$LAN_MODE" -eq 1 ]; then CERT_SANS="DNS:$DOMAIN,IP:$LAN_IP"; fi
  openssl req -new -newkey rsa:3072 -nodes -x509 -sha256 -days 90 \
    -subj "/CN=$DOMAIN" -addext "subjectAltName=$CERT_SANS" \
    -keyout "$KEY" -out "$CRT" >/dev/null 2>&1 || die 'Certificate generation failed'
else
  SELF_SIGNED=1
fi
chmod 0600 "$KEY"
chmod 0644 "$CRT"
openssl x509 -in "$CRT" -noout >/dev/null || die 'Invalid TLS certificate'
openssl pkey -in "$KEY" -noout >/dev/null 2>&1 || die 'Invalid TLS key'
# Compare modulus-independent public-key fingerprints (RSA, EC, Ed25519).
CERT_PUB=$(openssl x509 -in "$CRT" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256)
KEY_PUB=$(openssl pkey -in "$KEY" -pubout -outform DER | openssl dgst -sha256)
[ "$CERT_PUB" = "$KEY_PUB" ] || die 'TLS key does not match certificate'

# Stock nginx.conf from an installation we just created is safe to replace;
# on subsequent runs, only replace the version already marked as managed.
if [ -f "$STATE_DIR/nginx-managed" ]; then managed_or_absent "$NGINX_CONF"; fi
backup "$NGINX_CONF"
backup "$NGINX_SITE"
install -d -m 0755 /var/log/nginx
cat > "$NGINX_CONF" <<EOF
# $MARKER
user www;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events { worker_connections 1024; }
http {
  include /usr/local/etc/nginx/mime.types;
  default_type application/octet-stream;
  server_tokens off;
  sendfile on;
  keepalive_timeout 30;
  client_max_body_size 16m;
  access_log /var/log/nginx/access.log;
  include /usr/local/etc/nginx/zabbix-monitoring.conf;
}
EOF
WEB_IP=127.0.0.1
[ "$PUBLIC_WEB" -eq 0 ] || WEB_IP=0.0.0.0
NGINX_LISTENS="  listen $WEB_IP:443 ssl;"
if [ "$LAN_MODE" -eq 1 ]; then
  WEB_IP=$LAN_IP
  NGINX_LISTENS=$(printf '  listen 127.0.0.1:443 ssl;\n  listen %s:443 ssl;' "$LAN_IP")
fi
HSTS_LINE=
[ "$SELF_SIGNED" -eq 1 ] || HSTS_LINE='add_header Strict-Transport-Security "max-age=31536000" always;'
cat > "$NGINX_SITE" <<EOF
# $MARKER
server {
$NGINX_LISTENS
  server_name $DOMAIN;
  root $WEBROOT;
  index index.php;
  ssl_certificate $CRT;
  ssl_certificate_key $KEY;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_session_timeout 1d;
  ssl_session_cache shared:ZabbixTLS:10m;
  ssl_session_tickets off;
  $HSTS_LINE
  add_header X-Content-Type-Options nosniff always;
  add_header Referrer-Policy same-origin always;
  location / { try_files \$uri \$uri/ =404; }
  location ~ /\\. { deny all; }
  location ~* ^/(conf|include|app|vendor|tools|local)/ { deny all; }
  location ~* \\.(sql|bak|ini|log|conf|sh)\$ { deny all; }
  location ~ \\.php\$ {
    try_files \$uri =404;
    include /usr/local/etc/nginx/fastcgi_params;
    fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
    fastcgi_param HTTPS on;
    fastcgi_pass unix:/var/run/zabbix-monitoring-php.sock;
    fastcgi_read_timeout 300;
  }
}
EOF
chmod 0644 "$NGINX_CONF" "$NGINX_SITE"
touch "$STATE_DIR/nginx-managed"

log 'Validating all configurations before enabling services'
php-fpm -t || die 'PHP-FPM configuration validation failed'
nginx -t || die 'Nginx configuration validation failed'
zabbix_server -T -c "$SERVER_CONFIG" || die 'Zabbix server configuration validation failed'
zabbix_agentd -T -c "$AGENT_CONFIG" || die 'Zabbix agent configuration validation failed'

log 'Enabling and starting services'
sysrc zabbix_server_enable=YES zabbix_agentd_enable=YES php_fpm_enable=YES nginx_enable=YES >/dev/null || \
  die 'Failed to enable services; check rc.conf'
for svc in php_fpm zabbix_server zabbix_agentd nginx; do
  if service "$svc" onestatus >/dev/null 2>&1; then
    service "$svc" restart || die "Failed to restart $svc"
  else
    service "$svc" start || die "Failed to start $svc"
  fi
done

for svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx; do
  service "$svc" onestatus >/dev/null 2>&1 || die "$svc is not running after installation"
done
if command -v curl >/dev/null 2>&1; then
  curl -ksS --connect-timeout 5 --max-time 15 -o /dev/null "https://127.0.0.1:443/" || \
    warn 'HTTPS check failed: inspect nginx/php-fpm logs and PHP frontend'
fi
if [ "$LAN_MODE" -eq 1 ]; then
  printf '%s %s\n' "$LAN_IFACE" "$LAN_IP" > "$STATE_DIR/lan-binding"
fi
if [ "$LAN_MODE" -eq 1 ]; then
  log 'Configuring existing PF/IPFW for trusted LAN Zabbix access'
  fw_prepare && fw_apply
fi
printf '\n===========================================================\n'
printf 'Zabbix %s full-stack deployment completed.\n' "$ZBX_VERSION"
printf 'Source:       newest available complete binary pkg (repo: %s)\n' "$REPO_POLICY"
printf 'Dashboard:    https://%s/ (%s:443)\n' "$DOMAIN" "$WEB_IP"
printf 'Trapper IPs:  %s (TCP/10051)\n' "$TRAPPER_IP"
printf 'Server conf:  %s\n' "$SERVER_CONFIG"
printf 'Agent conf:   %s\n' "$AGENT_CONFIG"
printf 'Admin login:  %s\n' "$ADMIN_CREDS"
printf 'DB secrets:   %s\n' "$SECRETS_DIR"
printf 'SSH tunnel:   ssh -L 8443:127.0.0.1:443 user@%s\n' "$DOMAIN"
if [ "$LAN_MODE" -eq 1 ]; then
  printf 'LAN access:   https://%s/ (interface %s)\n' "$LAN_IP" "$LAN_IFACE"
  printf 'Local access: https://127.0.0.1/ (also listening on loopback)\n'
  printf 'Firewall:     %s (trusted sources %s to TCP/443,10051)\n' "$FIREWALL_SELECTED" "$FIREWALL_SUBNET"
else
  printf 'Browse to:    https://localhost:8443/ (default localhost-only web)\n'
fi
printf 'Next: register local host "%s" with the FreeBSD active-agent template;\n' "$DOMAIN"
printf '      configure alerts, firewall, TLS for remote agents and OFF-HOST backups.\n'
[ "$SELF_SIGNED" -eq 0 ] || printf 'WARNING: temporary self-signed certificate (90 days), not publicly trusted.\n'
printf '===========================================================\n'

Бъди първият коментирал

защитено · без проследяване

0 коментара