#!/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.4until 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-major8--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-vip="$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"-eq1 ] || { warn 'No LAN binding specified, skipping firewall configuration';return1; }
[ -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"return0
}
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"-eq1 ];thenprintf'PF rules file: %s\nPF managed rule: %s\n'"$pf_conf""$rule"return0fi
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;thenif ! 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'fielse
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=0for candidate in $(jot 99199);doif ! printf'%s\n'"$rules_list"|awk'{print $1}'|grep-Eq"^0*$candidate\$";then
fw_ruleno=$candidatebreakfidone
[ "$fw_ruleno"-ne0 ] || 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"-eq1 ];thenprintf'IPFW proposed rule: ipfw add %s %s\nIPFW persistence: firewall_coscripts -> %s\n' \
"$fw_ruleno""$fw_rule""$FIREWALL_IPFW_SCRIPT"return0fi
[ ! -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 1fi;;
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
chmod0700"$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';;esacif fw_ipfw_live;thensh-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 ] ||return0case"$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" ] ||continueifgrep-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;doifservice"$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."fidonefor svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx;doservice"$svc" onestatus >/dev/null 2>&1|| die "$svc is not running. Check logs."done
}
if [ "$FIREWALL_ONLY"-eq1 ];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= -vip="$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 0fiif [ "$REPAIR_SERVICES"-eq1 ];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 0fi# 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"-eq1 ];then
[ "$RESET_INCOMPLETE_DB"-eq0 ] || 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 0fi
[ -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-m0755 /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"ficat>"$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
chmod0644"$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"-eq1 ] ||return0if mysql_root -e"SET GLOBAL log_bin_trust_function_creators = $MYSQL_TRUST_ORIGINAL">/dev/null 2>&1;then
MYSQL_TRUST_CHANGED=0rm-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"return1fi
}
cleanup() {
restore_mysql_trust || :
rm-f"$CATALOG"
}
trap'cleanup'0trap'exit 129'1trap'exit 130'2trap'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" ] ||continueif [ -z"$ZBX_BASE" ] || [ "$(pkg version -t "$sv" "$ZBX_VERSION")" = '>' ];then
ZBX_BASE=$candidate
ZBX_VERSION=$svfidoneif [ -z"$ZBX_BASE" ];thenif [ -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"-lt8 ];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"-eq1 ];thenprintf'FreeBSD version: %s\nZabbix version: %s\nPackages: %s %s %s\n'"$(freebsd-version -u)""$ZBX_VERSION""$SERVER_PKG""$AGENT_PKG""$FRONTEND_PKG"
exit 0fi# Do not claim existing Zabbix/NGINX/PHP/MySQL installations on unmanaged hosts.if [ ! -f"$STATE_DIR/owned" ];thenif pkg info -a-q2>/dev/null |grep-Eq'^zabbix[0-9].*-(server|agent|frontend)';then
die 'Existing Zabbix packages found: this script is not an upgrade tool'fifor p in nginx mysql84-server mariadb114-server mariadb118-server mariadb1011-server php84;doif pkg info -e"$p">/dev/null 2>&1;then
die "Existing unmanaged $p package detected; this requires a dedicated fresh machine"fidonefor 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-m0700"$SECRETS_DIR""$STATE_DIR"chmod0700"$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-m0755 /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
chmod0644"$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=0while [ "$i"-lt40 ];doif mysqladmin ping--silent>/dev/null 2>&1;thenbreak;fi
i=$((i + 1))
sleep1done
[ "$i"-lt40 ] || 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"chmod0600"$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"in0|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"fiif [ ! -s"$DB_PASS_FILE" ];then
count=$(mysql_root -N-e"SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='$DB_NAME'")
[ "$count"-eq0 ] || die 'Existing Zabbix database without installer credentials: refusing to modify'
new_password >"$DB_PASS_FILE"chmod0600"$DB_PASS_FILE"fi
DB_PASS=$(cat"$DB_PASS_FILE")
[ "${#DB_PASS}"-eq64 ] || 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"chmod0600"$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"-eq1 ];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"-gt0 ];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"-gt0 ];then
ADMIN_ROWS=$(mysql_root -N-e"SELECT COUNT(*) FROM \`$DB_NAME\`.users WHERE username='Admin'")
[ "$ADMIN_ROWS"-eq0 ] || 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";thenrm-f"$INCOMPLETE_BACKUP"
die 'Reset refused: backup of the incomplete database failed'fichmod0600"$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=0else
log 'No database tables exist: nothing to reset'fifiif [ "$TABLE_COUNT"-eq0 ];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"in0|1) : ;; *) die 'Invalid MySQL trust variable';;esaccase"$MYSQL_BINLOG_ENABLED"in0|1) : ;; *) die 'Invalid MySQL binlog variable';;esacif [ "$MYSQL_BINLOG_ENABLED"-eq1 ] && [ "$MYSQL_TRUST_ORIGINAL"-eq0 ];then
log 'Temporarily allowing trigger creation during schema import'printf'%s\n'"$MYSQL_TRUST_ORIGINAL">"$MYSQL_TRUST_MARKER"chmod0600"$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'fiif ! 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;doif ! 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."fidone
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"-eq1 ] || 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"-gt0 ] || die 'No Zabbix database triggers found after schema import; refusing an incomplete installation'touch"$STATE_DIR/database-seeded"
DB_NEW=1else
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 ||printf0)
[ "$ADMIN_COUNT"-eq1 ] || \
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"-gt0 ] || 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'fifi# 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'")"-eq1 ] || \
die 'Default Admin account not found; check schema import'printf'Username: Admin\nPassword: %s\n'"$ADMIN_PASS">"$ADMIN_CREDS"chmod0600"$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;fiif ! 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 -m0750 /var/run/zabbix /var/log/zabbix
install -d-o zbxagent -g zbxagent -m0750 /var/run/zbxagent /var/log/zbxagent
install -d-m0755"${SERVER_CONFIG%/*}""${AGENT_CONFIG%/*}"
backup "$SERVER_CONFIG"
backup "$AGENT_CONFIG"
TRAPPER_IP=127.0.0.1
[ "$PUBLIC_TRAPPER"-eq0 ] || TRAPPER_IP=0.0.0.0if [ "$LAN_MODE"-eq1 ];then TRAPPER_IP="127.0.0.1,$LAN_IP";ficat>"$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"chmod0600"$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"chmod0640"$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-m0755 /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
chmod0644"$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"chmod0640"$FRONTEND_CONFIG"
php -l"$FRONTEND_CONFIG">/dev/null || die 'Frontend PHP config syntax error'
log 'Configuring TLS and dedicated Nginx HTTPS listener'
install -d-m0750"$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"-eq1 ];then CERT_SANS="DNS:$DOMAIN,IP:$LAN_IP";fiopenssl req -new-newkey rsa:3072-nodes-x509-sha256-days90 \
-subj"/CN=$DOMAIN"-addext"subjectAltName=$CERT_SANS" \
-keyout"$KEY"-out"$CRT">/dev/null 2>&1|| die 'Certificate generation failed'else
SELF_SIGNED=1fichmod0600"$KEY"chmod0644"$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-m0755 /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"-eq0 ] || WEB_IP=0.0.0.0
NGINX_LISTENS=" listen $WEB_IP:443 ssl;"if [ "$LAN_MODE"-eq1 ];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"-eq1 ] || 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
chmod0644"$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;doifservice"$svc" onestatus >/dev/null 2>&1;thenservice"$svc" restart || die "Failed to restart $svc"elseservice"$svc" start || die "Failed to start $svc"fidonefor svc in mysql-server php_fpm zabbix_server zabbix_agentd nginx;doservice"$svc" onestatus >/dev/null 2>&1|| die "$svc is not running after installation"doneif command -vcurl>/dev/null 2>&1;thencurl-ksS--connect-timeout5--max-time15-o /dev/null "https://127.0.0.1:443/"|| \
warn 'HTTPS check failed: inspect nginx/php-fpm logs and PHP frontend'fiif [ "$LAN_MODE"-eq1 ];thenprintf'%s %s\n'"$LAN_IFACE""$LAN_IP">"$STATE_DIR/lan-binding"fiif [ "$LAN_MODE"-eq1 ];then
log 'Configuring existing PF/IPFW for trusted LAN Zabbix access'
fw_prepare && fw_apply
fiprintf'\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"-eq1 ];thenprintf'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"elseprintf'Browse to: https://localhost:8443/ (default localhost-only web)\n'fiprintf'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"-eq0 ] ||printf'WARNING: temporary self-signed certificate (90 days), not publicly trusted.\n'printf'===========================================================\n'
0 коментара