FAQ

This is a guide to installing, configuring and maintaining Arcivéo Monitor. The sections are grouped: overview, deploying the dashboard, connecting security tools, built-in modules and diagnostics. Commands can be copied with the button on the right.

Getting Started

01. Panel installation — choose a method

Panel installation is covered on separate step-by-step pages. Choose a method:

Not sure — go with the automatic one. This guide remains the single source of truth for SSL, tools, cron and diagnostics — the install pages link to its sections without duplicating anything.

Overview

02. What is Arcivéo Monitor

Arcivéo Monitor is a server-security dashboard. It collects data from installed tools (Fail2ban, UFW, Lynis, ModSecurity, AIDE, ClamAV, Auditd, CrowdSec, Suricata, Falco, and more) and shows it in a single interface with a dashboard, an attack map, and detailed pages for each tool.

Monitor is not an active protection tool — it does not block attacks on its own. Its job is to aggregate information from already-running tools and present it in a convenient form.

03. How the monitor runs on your server

The monitor runs locally only — it must be installed on the same server it monitors. There is no SSH or remote API.

The dashboard runs all commands (fail2ban-client, ufw status, ipset list, etc.) as the web-server user (usually www-data, or the site account on hosting panels) with a narrow set of sudo permissions — limited to specific utilities, without general root access. The results are parsed and shown in the browser.

For multiple servers, install the monitor on each one separately, with a unique domain.

04. How the Security Score is calculated

The score starts at maximum and drops for each issue found:

  • UFW inactive — −30
  • Fail2ban not running (no active jails) — −25
  • No WebAuthn keys — −15
  • Lynis hardening index < 60 — −20; 60–79 — −10
  • IPset ipsum not loaded — −10
  • Threats found by ClamAV — −20
  • AIDE file changes — −15
  • SSL expired — −30, expiring in <14 days — −15, <30 days — −5
  • CrowdSec installed but not running — −5
  • Suricata installed but not running — −5
  • Database/cache (MySQL, PostgreSQL, Redis…) reachable externally — −10
  • Root SSH login allowed (PermitRootLogin yes) — −20
  • Security updates pending — −5

Result: 80+ = Protected, 60–79 = Attention, <60 = At risk.

Deductions for ClamAV, AIDE, CrowdSec and Suricata apply only if the tool is installed. Lynis and AIDE without an initialized database show as "no data" and do not lower the score. The number of attacks today is shown on the dashboard but does not affect the Security Score.

Settings & License

05. WebAuthn — two-factor authentication

WebAuthn is a passwordless authentication standard using a hardware key. It supports YubiKey, Touch ID, Face ID, Windows Hello, Passkey.

After you log in with a password, the system requests confirmation via a registered key. Even if the password leaks, no one can log in without the physical key or biometrics.

To set it up, open WebAuthn keys in the side menu and click “Register key”. Register two keys at once: if your only key is lost or breaks, you won't be able to log in to the dashboard with it.

WebAuthn works only over HTTPS. Key registration and login are unavailable on an HTTP connection.

06. Notifications: Telegram and Email

The dashboard can send a security report to Telegram and email (on demand and on a schedule). Configured under "Settings".

Telegram. You need a bot token and a chat id:

  1. In Telegram, message @BotFather/newbot → get a token like 123456:ABC....
  2. Send your new bot any message (so it can reply to you).
  3. Find your chat id: message the bot @userinfobot, or open https://api.telegram.org/bot<TOKEN>/getUpdates and locate "chat":{"id":...}.
  4. Paste the token and chat id into "Settings" → Telegram, then click "Save and send test".

Email. Two options to choose from under "Settings" → Email:

  • SMTP — host, port (465/SSL or 587/TLS), your mailbox username and password;
  • Resend — a modern API: enter the API key (re_...) and a verified sender domain.
The "Send test" button checks the channel right away. The automatic report schedule runs via cron (see "All cron jobs"): it triggers the send, and the channels are taken from your settings.

Report status: "ATTENTION" or "OK". The header turns to "ATTENTION" only for a real problem or a pending action: a ClamAV threat found, file changes in AIDE, critical Falco events (Emergency/Alert/Critical in the last 24 h), a failed service in Monit, a required reboot, an expiring SSL certificate (≤14 days), or pending security updates. Background noise — SSH brute-force bots, IP addresses banned by fail2ban, Suricata alerts, Lynis warnings, and already-blocked ModSecurity requests — does not raise the status, so such numbers in the report do not mean "ATTENTION" on their own.

07. License — entry and activation

Detailed monitoring modules (Lynis, UFW, ModSecurity, attack map, AIDE, ClamAV, and others) unlock with a valid license. Without one, the dashboard, settings, and account still work, but the modules show a "License required" card.

After purchasing in your account, you have an activation code in the form ARCIVEO-XXXX-XXXX-XXXX-XXXX. It needs to be "activated" for your panel's domain — this turns the code into a signed license file (the [license] block) that you paste into the panel.

How to activate (3 steps):

  1. Get the activation code. Account at my.arciveo.com → the "Licenses" / "License activation" section — copy the ARCIVEO-… code.
  2. Activate the code for your domain. In the same account, open "License activation" and enter: the activation code, your email, and the panel domain (the address the monitor opens at, e.g. monitor.example.com). Click activate — the system generates a license file bound to that domain and shows it in a field with a "Copy" button.
  3. Paste the key into the panel. Copy the entire license text → in the panel, open "Settings" → the "License" block, paste it, and click "Save". The modules unlock immediately.

The panel verifies the key cryptographically: the signature, the domain binding, and the expiry date.

The domain used for activation must exactly match the panel address. Take it from the APP_URL constant in config.php and enter only the hostname — without https:// and without the www prefix. Activation is one-time: the code turns into a license for the entered domain and cannot be activated again — if the domain is wrong, the key won't fit your panel and the code will be spent. So enter the domain carefully.
If the license has expired or the domain has changed, a warning appears in the panel header. The license is bound to the domain permanently and cannot be moved to another domain: a new term or a new domain requires a new key (bought in your account and activated one time).

08. The config.php file — all dashboard settings

All the main dashboard parameters are defined in a single file config.php at the root (next to the public/ folder) as ordinary define() constants. The file is created during installation; you rarely need to edit it by hand — mainly when changing the domain, migrating, or connecting to a different database. After any edit, restart PHP-FPM (otherwise the changes won't take effect because of OPcache).

Substitute your own values in the highlighted spots; leave the rest as is:

// --- Database --- define('DB_HOST', 'localhost'); // leave as is define('DB_NAME', 'db_name'); // what you set when creating the DB define('DB_USER', 'user'); // what you set when creating the DB define('DB_PASS', 'db_password'); // what you set when creating the DB define('DB_CHARSET', 'utf8mb4'); // leave as is // --- Application --- define('APP_URL', 'https://monitor.example.com'); // dashboard address, no trailing slash define('TIMEZONE', 'Europe/London'); // your timezone // --- Session lifetime --- define('SESSION_LIFETIME', 28800); // idle time before re-login, sec (28800 = 8 h)

Database. MySQL/MariaDB connection credentials:

  • DB_HOST — the DBMS host, almost always localhost;
  • DB_NAME — the dashboard's database name;
  • DB_USER — the DB user (access to its own database only);
  • DB_PASS — that user's password;
  • DB_CHARSET — the connection encoding, leave it as utf8mb4.

Application.

  • APP_URL — the full dashboard address (e.g. https://monitor.example.com). It must match the domain the license is activated for — otherwise the key will be rejected (see the "License" section);
  • TIMEZONE — the PHP timezone: it only affects how the dashboard shows dates and times. It does not affect when cron jobs run — those use the system timezone (see "All cron jobs").

Session lifetime. SESSION_LIFETIME — the session idle timeout in seconds (sliding: refreshed on activity). The default 28800 = 8 hours; after that much inactivity the dashboard will ask you to log in again. For example, 3600 = 1 hour, 86400 = one day.

Error logging. Errors are never shown to visitors; they are written to logs/php_errors.log — you can view them on the "Application logs" page. These lines (display_errors=0, log_errors=1, the error_log path) usually don't need changing — the settings are defined right in the file and don't depend on php.ini.

config.php is a secret file. It contains the DB password. It sits in the dashboard root (next to public/), and this dashboard's web root (DocumentRoot) is exactly the dashboard root, not public/. The file doesn't "leak" on its own: the root .htaccess explicitly denies it (Require all denied) — the server returns 403. Even without that rule the source wouldn't leak: this is PHP — the server executes it rather than serving it as text. Just in case: don't post it to public repositories and don't send it to support with the real password. File permissions — 640.
When migrating or restoring access, this file is the main source of credentials: the DB name, user and password are taken from here (see the "Updating and migrating the dashboard" and "Restoring access" sections).

Security Tools

09. UFW firewall

UFW (Uncomplicated Firewall) is a simple front-end for nftables/iptables. It closes all incoming ports except those explicitly allowed. The "UFW firewall" page shows the status and rules.

sudo apt install ufw # Allow SSH (required BEFORE enabling!) and web sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp # Close the DB from outside (local access only) sudo ufw deny 3306 # Enable and check sudo ufw enable sudo ufw status verbose
Before ufw enable, be sure to allow SSH (ufw allow OpenSSH), otherwise you will lose access to the server.
"External exposure" on the dashboard takes UFW into account: a port closed by a deny rule is not counted as reachable from outside.
Skipping adding existing rule is not an error. This is how UFW reports that the exact same rule already exists and won't add it again. When you re-run auto-configuration (it's idempotent), this is a normal message — no action needed.

10. Installing Fail2ban

Automatically bans an IP after too many failed login attempts. Analyzes logs from SSH, nginx, Apache and other services.

sudo apt install fail2ban sudo systemctl enable --now fail2ban # Check status: sudo fail2ban-client status
A working configuration (jail.local with dozens of jails and auto-ban from ipsum) is in the next section.

11. Working Fail2ban + ipsum configuration

The basic install is above. Here is a working configuration that yields dozens of active jails and thousands of bans: global settings, key jails and auto-banning of malicious IPs from the ipsum list.

The /etc/fail2ban/jail.local file — global settings and the most important jails:

[DEFAULT] bantime = 1w findtime = 900 maxretry = 3 backend = systemd banaction = nftables-multiport ignoreip = 127.0.0.1/8 ::1 <YOUR_IP> <TRUSTED_NETS> # Progressive ban: each repeat lasts longer bantime.increment = true bantime.factor = 2 bantime.maxtime = 5w bantime.rndtime = 300 [sshd] enabled = true maxretry = 5 bantime = -1 # permanent ban for SSH brute-force findtime = 3600 # Recidivists: anyone caught by several bans is banned forever [recidive] enabled = true logpath = /var/log/fail2ban.log banaction = %(banaction_allports)s bantime = -1 findtime = 86400 maxretry = 2 [http-get-dos] enabled = true maxretry = 100 findtime = 300 bantime = 1w # Web services (apache-*, nginx-*, php-url-fopen, phpmyadmin-syslog): [nginx-http-auth] enabled = true port = http,https [apache-badbots] enabled = true port = http,https # … and the other per-service jails (dovecot, exim, postfix-sasl, # mysqld-auth, vsftpd, portscan, pam-generic) — enabled = true
Be sure to add your own IP and trusted networks to ignoreip, otherwise you may ban yourself. After editing: sudo fail2ban-client reload.

Auto-loading of the ipsum block list — in the root cron (sudo crontab -e): level 1 (100,000+ IPs) is loaded into the ipsum set, which is dropped at the firewall (more in the "IPset block list" section):

# 04:00 — update ipset ipsum (level 1, maximum coverage): 0 4 * * * /usr/local/bin/load-ipsum.sh >> /path/to/monitor/logs/cron.log 2>&1
The set must be named ipsum — that is exactly what the dashboard reads (the "IPset ipsum" card). Levels: levels/1.txt — maximum coverage, levels/3.txt — more precise (3+ sources).

Why the "Security Monitor" is split into two zones. Protection works on two levels, and the dashboard does not mix them:

  • Real attacks (reactive) — everything caught by fail2ban: live break-in attempts (the sshd, apache-*, nginx-* jails, etc.) and persistent recidivists (the recidive jail — those already banned several times). These are IPs that really hit you — they appear on the attack map and the "Timeline".
  • Preventive block (proactive) — the public block list of known malicious IPs ipset ipsum, dropped at the firewall by a DROP rule. Most of these addresses never even touched your server — they are cut off in advance; the "IPset ipsum" counter shows how many were blocked preventively.

The difference is simple: reactive means "these attacked and got banned", preventive means "these were blocked before they even tried". Previously, ipsum list-3 was artificially forced into recidive (hence the old "list recidive" split); now recidive holds only real recidivists, and prevention is entirely on the firewall.

12. IPset block-list (ipsum)

ipsum is a public list of malicious IPs, updated daily. The Monitor shows the number of loaded addresses on the dashboard and attack map, and factors it into the Security Score (−10 if the set is not loaded).

The minimal option without fail2ban is a separate ipsum set with blocking via iptables:

# Create the set (once): sudo ipset create ipsum hash:ip # Update script /usr/local/bin/update-ipsum.sh: #!/bin/bash ipset flush ipsum for ip in $(curl -s https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt); do ipset add ipsum "$ip" 2>/dev/null done iptables -C INPUT -m set --match-set ipsum src -j DROP 2>/dev/null \ || iptables -I INPUT -m set --match-set ipsum src -j DROP # Cron (sudo crontab -e, daily at 4:00): 0 4 * * * /usr/local/bin/update-ipsum.sh
The extended option with fail2ban-recidive is in the "Working Fail2ban + ipsum configuration" section.
ipset lives in memory and is lost on reboot. A daily cron alone will leave the set empty from the moment of reboot until the next run (the dashboard will show 0). Load the set at startup too — move the loading into a script and hook it to @reboot. The create … -exist command also sets the limit maxelem 300000 (the default 65536 won't fit level 1, you'll get "Hash is full"):
# /usr/local/bin/load-ipsum.sh #!/bin/bash curl -s https://raw.githubusercontent.com/stamparm/ipsum/master/levels/1.txt \ | grep -v '^#' | sed 's/^/add ipsum /' \ | (echo "create ipsum hash:ip hashsize 131072 maxelem 300000 -exist"; echo "flush ipsum"; cat) \ | ipset restore -exist # sudo crontab -e — daily at 04:00 AND on every startup: 0 4 * * * /usr/local/bin/load-ipsum.sh @reboot sleep 60 && /usr/local/bin/load-ipsum.sh
How it works with auto-install. The script loads the full level 1 list (100,000+ IPs) into the ipsum set and, if the firewall is managed by the installer (fresh VPS — "Full"/"Lite" profiles), attaches the set to UFW with a DROP rule — traffic from these IPs is actually blocked. The rule sits after ESTABLISHED,RELATED, so current connections (including your SSH) aren't dropped — only new connections from the list are cut. The set is restored when the ipsum-load.service service loads before the firewall (otherwise UFW wouldn't come up), and is refreshed by cron at 04:00. On an already-configured server (panel, own firewall) the installer doesn't touch the firewall — there ipsum stays a list for the dashboard and attack map, and the DROP rule can be added manually if desired (the minimal option with iptables … --match-set ipsum … -j DROP is above). With auto-install nothing needs to be done manually.

13. Installing CrowdSec

A modern replacement for Fail2ban with collective threat intelligence: community-sourced bans plus your own rules. Requires a separate bouncer to apply bans to the firewall.

curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash sudo apt install crowdsec sudo systemctl enable --now crowdsec # Bouncer for iptables/nftables: sudo apt install crowdsec-firewall-bouncer-iptables # Check status: sudo systemctl status crowdsec sudo cscli decisions list sudo cscli bouncers list
Status "Not running" in the dashboard = the package is installed, but the service is not active (the monitor checks it via systemctl is-active crowdsec). Start it: sudo systemctl enable --now crowdsec; if it crashes, check sudo journalctl -u crowdsec -n 30. The same applies to any service showing "Not running" (Suricata, Falco, Monit, MySQL).
"0 scenarios" or "0 bouncers" on the dashboard. CrowdSec ships almost empty out of the box — without collections it detects nothing, and without a registered bouncer bans are not applied to the firewall. Install the base collections and make sure the bouncer is listed:
# Base collections (Linux + SSH + web server): sudo cscli collections install crowdsecurity/linux crowdsecurity/sshd crowdsecurity/base-http-scenarios sudo systemctl reload crowdsec # The bouncer must be listed and show an active connection status: sudo cscli bouncers list
The bouncer log shows stream halted / bans are not applied. This is an orphaned API key: the bouncer was removed from cscli bouncers list, but its old key remains in /etc/crowdsec/bouncers/*.yaml. Re-register the bouncer and set the new key:
sudo cscli bouncers add fw-bouncer # outputs a new api_key # enter this key in api_key: in /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml sudo systemctl restart crowdsec-firewall-bouncer
Auto-install (the "Full protection" profile) installs the collections and registers the firewall-bouncer itself — you only need to do this manually with a manual install or after manually tampering with CrowdSec.

14. Installing AIDE

AIDE (Advanced Intrusion Detection Environment) takes a snapshot of the file system and, on every check, reports changes in /etc, /bin, /usr. After installation, initializing the database (aideinit) is mandatory.

sudo apt install aide # Initialize the database (5–15 minutes): sudo aideinit sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db # Ubuntu 24.04: the /var/lib/aide directory is created in mode 700 (owner _aide), # and the panel (www-data) cannot see the database → shows "Not initialized". # Open the directory for traversal (database files remain 600): sudo chmod 755 /var/lib/aide # First check WITH WRITING to the log that the monitor reads. # On Ubuntu/Debian, aide requires an explicit --config (otherwise "missing configuration"; # the aide.wrapper binary is not shipped in newer versions): sudo bash -c 'aide --config /etc/aide/aide.conf --check > /var/log/aide/aide.log 2>&1'
During aideinit the terminal stays on the line Running aide --init... for 5–15 minutes — this is normal (hashing the entire filesystem, heavy disk load). Do not interrupt with Ctrl+C. If the process seems to "hang" but writes nothing — it may be waiting for a hidden prompt Overwrite existing aide.db.new [Yn]? (press Y). Check activity from another session: pgrep -af aide.
aideinit error: "21_aide_spamassassin … printf: invalid number" (return code 20) — a known bug in the AIDE config snippet on Ubuntu 22.04. The database is not created. Move the broken snippet aside and retry:
sudo mv /etc/aide/aide.conf.d/21_aide_spamassassin /etc/aide/21_aide_spamassassin.disabled sudo aideinit sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
Statuses on the dashboard. "Not initialized" = the panel cannot see the database file: either aideinit was never run, or (Ubuntu 24.04) the /var/lib/aide directory was created in mode 700 and is inaccessible to www-data — fixed with sudo chmod 755 /var/lib/aide (see the block above). "No checks yet" = the database exists but no check has run yet — this is not an error. The monitor reads results from /var/log/aide/aide.log.
Regular check → log for the panel. The stock /etc/cron.daily/aide on newer Ubuntu/Debian may not write /var/log/aide/aide.log in the required form (and aide.wrapper is no longer present there). It is more reliable to add your own cron job with an explicit --config — it writes the log as root in mode 644, and the monitor reads it without extra groups:
# sudo crontab -e — daily check at 02:00: 0 2 * * * /usr/bin/aide --config /etc/aide/aide.conf --check > /var/log/aide/aide.log 2>&1 # Run now, without waiting for the schedule: sudo bash -c 'aide --config /etc/aide/aide.conf --check > /var/log/aide/aide.log 2>&1'
Automatic installation already does all of this: chmod 755 /var/lib/aide and a check cron job at 02:00 — nothing needs to be done manually.
Perform the first initialization on a clean server — before installing web applications. After legitimate changes, rebuild the database: sudo aideinit && sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db.

15. Installing ClamAV

Antivirus scanner for Linux. Especially useful for scanning /var/www for PHP shells and malicious code.

sudo apt install clamav clamav-daemon sudo systemctl enable --now clamav-daemon # Update signature database: sudo freshclam # Scan a folder manually: sudo clamscan -r /var/www --infected
The clamd daemon shows "Inactive" after enable --now? Three common causes:

1. The config still has an Example line — clamd refuses to start while it's there:

sudo sed -i '/^Example/d' /etc/clamav/clamd.conf sudo systemctl restart clamav-daemon

2. The signature database isn't downloaded — clamd won't start without it:

sudo systemctl stop clamav-freshclam sudo freshclam sudo systemctl start clamav-freshclam sudo systemctl restart clamav-daemon

3. It's simply loading — clamd loads ~8M signatures into memory in 30–60 sec. Wait and check: systemctl is-active clamav-daemon (status activating → still loading).

Diagnostics: sudo journalctl -u clamav-daemon -n 30 --no-pager.
Dashboard shows "Files scanned: 0" / "Last scan: —"? The clamd daemon only keeps signatures in memory; it doesn't scan anything on a schedule by itself. The dashboard shows scheduled-scan results, so you need a cron job that scans and writes a log. Auto-install adds the wrapper /usr/local/bin/clamav-scan.sh and a cron job at 01:30 — after the first run, "Files scanned" and "Last scan" will populate. Run it right away without waiting for the schedule: sudo /usr/local/bin/clamav-scan.sh.

16. Installing Linux Malware Detect (maldet)

Linux Malware Detect (LMD) — a malware scanner for web threats: PHP shells, web backdoors, droppers. It uses the ClamAV engine and supplements it with its own signatures.

wget https://www.rfxn.com/downloads/maldetect-current.tar.gz tar -xzf maldetect-current.tar.gz dir=$(ls -d maldetect-*/ | head -1) && cd "$dir" && sudo bash install.sh && cd ~ rm -rf maldetect-* maldetect-current.tar.gz # Update signatures: sudo maldet -u # Scan /var/www: sudo maldet -a /var/www
LMD and ClamAV work well together. Latest report: maldet --report.
During installation the line update-rc.d: error: unable to read /etc/init.d/maldet may appear — it is harmless. maldet does not use init.d; signature updates and scans run via /etc/cron.daily/maldet. If installation completed appears below, everything was installed.
LMD shows "Not installed" on the page even though it is installed? maldet is installed not via apt but into /usr/local/maldetect, and when open_basedir is enabled its presence is checked through the shell — see the section "The page is empty even though there is data on the server".

17. Installing Suricata

A network intrusion detection system: it analyzes traffic at the packet level and knows thousands of attack signatures. It complements ModSecurity (which works at the HTTP level, while Suricata works at the TCP/IP level).

sudo add-apt-repository ppa:oisf/suricata-stable sudo apt update && sudo apt install suricata # Download the latest rules: sudo suricata-update sudo systemctl enable --now suricata
Suricata is "Active", but the panel shows no alerts / event count is 0? Suricata writes /var/log/suricata/eve.json as root with mode 750 on the directory, and the web server (www-data) can't read it. Open the directory for traversal — the files inside stay protected:
sudo chmod o+rx /var/log/suricata
Automatic installation does this for you — no manual step needed.

18. Installing Falco

Intercepts system calls via eBPF/kernel module and detects anomalies in real time: a shell spawned from nginx, a web process reading /etc/passwd, writes to /bin, etc.

curl -fsSL https://falco.org/repo/falcosecurity-packages.asc > /tmp/falco.asc gpg --dearmor < /tmp/falco.asc | sudo tee /usr/share/keyrings/falco-archive-keyring.gpg > /dev/null sudo chmod 644 /usr/share/keyrings/falco-archive-keyring.gpg && rm /tmp/falco.asc echo "deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] https://download.falco.org/packages/deb stable main" \ | sudo tee /etc/apt/sources.list.d/falcosecurity.list sudo apt update && sudo apt install falco sudo systemctl enable --now falco
The Monitor reads Falco events via journalctl -u falco (without sudo — through the systemd-journal group). Make sure www-data is in this group — see "Configuring sudo" (step 2) on the manual installation page.
"0 events in 24 hours" is normal, not an error. Falco is event-driven: it stays silent while everything is fine and logs an event only on an anomaly (a shell from a web process, reading /etc/passwd, writes to system directories). Zero critical events in a day on a quiet server is a healthy state.
File output is more reliable for the panel. Reading via journalctl requires journal permissions; so the panel sees events reliably, auto-install enables Falco's file_output/var/log/falco/falco.log and sets UMask=0022 on the service (the log is readable by the web server). On a fresh install there's no need to configure this manually.

19. Installing ModSecurity (WAF)

ModSecurity is a web firewall (WAF) for Apache or Nginx. It blocks application-layer attacks: SQL injection, XSS, path traversal, scanners.

# Apache: sudo apt install libapache2-mod-security2 sudo a2enmod security2 # OWASP Core Rule Set: sudo apt install modsecurity-crs # REQUIRED: without this file the rule engine is off sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf sudo apache2ctl configtest && sudo systemctl reload apache2 # Check: should return 403 curl -s -o /dev/null -w '%{http_code}\n' "https://monitor.example.com/?id=1%20UNION%20SELECT%201,2--"
Installing the package alone protects nothing. Apache pulls in configs with the line IncludeOptional /etc/modsecurity/*.conf, but the package only ships modsecurity.conf-recommended — which does not match the *.conf pattern. If you don't copy it to modsecurity.conf, SecRuleEngine stays Off: the module is loaded, the CRS rules are loaded, but traffic isn't inspected and no audit log is created. The intermediate DetectionOnly mode only logs events without blocking requests — the dashboard shows it in yellow.

Dashboard access to the audit log. The log /var/log/apache2/modsec_audit.log is owned by root (permissions 640), so the web user can't read it. The dashboard reads data through a wrapper — create it:

sudo tee /usr/local/bin/monitor-modsec >/dev/null <<'EOF' #!/bin/sh echo "ENGINE=$(grep -hE '^SecRuleEngine[[:space:]]+' /etc/modsecurity/*.conf 2>/dev/null | tail -1 | awk '{print $2}')" echo "---LOG---" tail -n 3000 /var/log/apache2/modsec_audit.log 2>/dev/null echo "---RULES---" for f in /etc/modsecurity/crs/rules/*.conf /usr/share/modsecurity-crs/rules/*.conf /etc/modsecurity/custom-rules.conf; do [ -f "$f" ] && { echo "===FILE:$(basename "$f")==="; cat "$f"; } done EOF sudo chown root:root /usr/local/bin/monitor-modsec sudo chmod 755 /usr/local/bin/monitor-modsec # in /etc/sudoers.d/monitor (user = the one PHP-FPM runs as): # www-data ALL=(ALL) NOPASSWD: /usr/local/bin/monitor-modsec
The wrapper takes the last unindented SecRuleEngine directive: indented lines are inside <LocationMatch>/<Directory> blocks (for example, disabling the WAF for phpMyAdmin) and don't define the global mode.
The user in sudoers must match the FPM pool user: on plain Apache/Debian this is www-data, while in HestiaCP the site pool runs as the site owner (for example, admin) — check with grep -E '^user' /etc/php/*/fpm/pool.d/monitor.example.com.conf.
If the site sits behind an Nginx proxy (HestiaCP), Apache sees the proxy itself as the client — the dashboard takes the attacker's real IP address from the X-Forwarded-For header. Only transactions with a triggered rule appear in the statistics: the SecAuditLogRelevantStatus directive writes any 4xx/5xx responses to the audit log, so ordinary 403/500 responses end up there too — the dashboard does not count them as WAF events.
The ---RULES--- block is needed by the "All active rules" section — the dashboard shows not only triggered rules but every loaded CRS rule plus custom ones. The three paths in the for f in … loop are the typical locations for CRS rules and local additions; if your layout differs (the package installs files in its own directory, or your custom rules aren't in /etc/modsecurity/custom-rules.conf), find the real paths with sudo grep -rl 'IncludeOptional\|^Include ' /etc/apache2/mods-enabled/security2.conf /etc/apache2/conf-enabled/*.conf 2>/dev/null and add them to the list. If the wrapper is old (without this section) the section simply shows an "unavailable" warning, and the rest of the page works as before.

20. Installing Auditd

Auditd (Linux Audit Daemon) records system calls at the kernel level: logins and logouts, sudo commands, failed authentication attempts, file changes. The Monitor shows today's logins, failed attempts and sudo commands.

sudo apt install auditd audispd-plugins sudo systemctl enable --now auditd # Check status and events: sudo systemctl status auditd sudo ausearch -m USER_LOGIN -ts today
The Monitor reads events via ausearch (/usr/sbin/ausearch) and, when needed, from /var/log/audit/audit.log using the tail command. Both must be in sudoers.

21. Installing Monit

Watches services (nginx, php-fpm, mysql, etc.) and restarts them if they go down. Can send alerts by email.

sudo apt install monit sudo systemctl enable --now monit # Configs: sudo nano /etc/monit/monitrc ls /etc/monit/conf.d/
The Monitor gets the service list via monit status. The HTTP interface must be enabled in /etc/monit/monitrc (the set httpd block with allow localhost), otherwise monit status returns an error.
Seeing "0 monitored services" on the dashboard? Two reasons. (1) The HTTP interface is off — the set httpd line in monitrc is commented out (it ships as # set httpd port 2812 … by default). Uncomment the block and allow localhost. (2) An enabled httpd by itself monitors nothing — Monit counts only what is described by check stanzas; without them the list is empty even with a working interface. A minimal working config:
# /etc/monit/conf.d/00-httpd — HTTP interface for localhost: set httpd port 2812 use address localhost allow localhost # example check stanzas (what to monitor): check process sshd with pidfile /run/sshd.pid start program = "/usr/bin/systemctl start ssh" stop program = "/usr/bin/systemctl stop ssh" check filesystem rootfs with path / if space usage > 90% then alert
sudo monit -t # check syntax (Control file syntax OK) sudo systemctl reload monit sudo monit status
Auto-install drops in a ready-made conf.d with httpd on 2812 and a set of checks — on a fresh install there is nothing to configure by hand.
A service showing "With errors"? The Monitor only displays state and deliberately does not restart services from the web panel (that would be remote execution of root commands in a security panel). Diagnose and restart over SSH via Monit:
sudo monit status <service> # reason for the error sudo monit restart <service> # restart via Monit # if Monit won't bring the service up — check its own unit: sudo systemctl status <unit> --no-pager sudo journalctl -u <unit> -n 50 --no-pager

22. Installing PSAD (port scan detection)

PSAD analyzes the iptables log and detects port scans and network attacks, assigning each source a threat level (1–5). It complements fail2ban and Suricata.

sudo apt install psad # PSAD reads the iptables log — logging must be enabled (UFW does this on its own). # For plain iptables, add LOG rules to the INPUT/FORWARD chains. sudo psad --sig-update sudo systemctl enable --now psad
The Monitor reads data via psad --Status (required in sudoers). Without iptables logging the page will be empty — this is normal until a scan occurs.

23. AppArmor / SELinux (access control)

Mandatory Access Control restricts which files and resources a program can access, even if it has been compromised. Ubuntu/Debian use AppArmor by default (usually already installed and active).

# AppArmor (Ubuntu/Debian): sudo apt install apparmor apparmor-utils sudo systemctl enable --now apparmor sudo aa-status # check profiles
The Monitor reads the status via aa-status (needs to be in sudoers). It shows the number of profiles in enforce/complain mode and processes without a profile.

"Profiles loaded" being higher than enforce + complain is normal. AppArmor 4.x (Ubuntu 24.04 and later) introduced the unconfined mode: the profile is loaded into the kernel but restricts nothing. Ubuntu flags dozens of profiles this way for programs that use user namespaces (browsers, torrent clients and the like). When such profiles exist, the "Profiles loaded" card turns amber and shows their count — for example unconfined: 90 with 120 loaded and 26 in enforce. Only profiles in enforce actually provide protection; on Ubuntu 22.04 (AppArmor 3.x) this mode does not exist and the numbers always add up.

sudo aa-status | grep -E "profiles are" # breakdown by mode sudo aa-enforce /etc/apparmor.d/profile-name # switch a profile to enforce
Switching to enforce those profiles Ubuntu deliberately left unconfined should only be done knowingly: they are disabled not by mistake, but because otherwise the programs themselves break. Profiles in complain are a different matter: the rules are already written there and simply not enforced.

24. Installing debsums (package integrity)

debsums verifies that installed package files match the checksums from the repository — it detects tampered system binaries (complementing AIDE). A full scan takes 1–2 minutes, so it runs via cron, and the dashboard reads the result from data/debsums/debsums.log and sorts it into categories itself (only binaries and libraries matter).

The job goes in the root crontab (sudo crontab -e). The ready-made wrapper debsums-scan.sh is placed in /usr/local/bin/ (chmod +x; see the cron job summary) and writes its report to the dashboard's data/debsums/ itself.

sudo apt install debsums # Cron line (daily at 4:30): 30 4 * * * /usr/local/bin/debsums-scan.sh >> /path/to/monitor/logs/cron.log 2>&1

The debsums-scan.sh wrapper finds the dashboard's data/ itself — no need to specify the path.

Changes in /etc/ (configs) and /usr/share/ (resources) on the server are usually normal — the dashboard marks them in a separate color. Changes to binaries and libraries are the alarming ones (/bin, /sbin, /usr/lib, etc.) — the "Binaries / libraries" card shows exactly those.

25. Setting up Lynis reports

Lynis runs manually or via cron. The report must be saved to the project's data/lynis/ folder — the monitor reads the lynis-report.dat file.

# One-time run (substitute your own path to the dashboard root): sudo lynis audit system --report-file /path/to/monitor/data/lynis/lynis-report.dat # Daily audit — cron line (ready-made wrapper lynis-scan.sh in /usr/local/bin/, see summary): 0 3 * * * /usr/local/bin/lynis-scan.sh >> /path/to/monitor/logs/cron.log 2>&1
After the first run, the "Lynis Audit" page immediately shows the hardening index, warnings and recommendations.
The "Run audit" button on the Lynis page. It runs lynis-scan.sh in the background right from the dashboard (without waiting for cron): it shows "Scanning…" and refreshes the report itself when done. For this the web user needs a sudoers line to run the script — the installer adds it automatically to /etc/sudoers.d/monitor. If the dashboard was installed manually/earlier, add it with the same user already listed in the file:
u=$(sudo awk '/NOPASSWD/ && !/lynis-scan/ {print $1; exit}' /etc/sudoers.d/monitor) [ -n "$u" ] && echo "$u ALL=(ALL) NOPASSWD: /usr/local/bin/lynis-scan.sh" | sudo tee -a /etc/sudoers.d/monitor sudo visudo -c && sudo chmod 440 /etc/sudoers.d/monitor

26. Configuring Logwatch reports

Logwatch must save daily reports to the project's data/logwatch/ folder in .txt format. The Monitor shows the latest report and the archive.

# Daily (6:00) — cron line (ready-made logwatch_daily.sh wrapper in /usr/local/bin/, see summary): 0 6 * * * /usr/local/bin/logwatch_daily.sh >> /path/to/monitor/logs/cron.log 2>&1

Dashboard Modules

27. Network monitor (built in)

The network monitor requires no installation — it is a built-in dashboard page. It shows the server's network status from local sources:

  • interfaces and traffic — from /proc/net/dev;
  • link status (UP/DOWN) and IP — via ip;
  • connections and listening ports — via ss;
  • kernel network events over 24 h — via journalctl -k.

The first three sources work without sudo, so interfaces, traffic, connections and ports are visible right away. The "Kernel events" block uses journalctl -k — it is read via the systemd-journal group ("Configuring sudo", step 2), no sudo needed. Check that everything is accessible to the web user:

# Check as www-data (PHP runs under it): sudo -u www-data bash -c 'cat /proc/net/dev' sudo -u www-data bash -c 'ip -o link show' sudo -u www-data bash -c 'ss -s' sudo -u www-data journalctl -k --no-pager -n 5
The "Kernel network events" block shows kernel network stack events (link up/down changes, carrier errors, "network unreachable"). Firewall UFW BLOCK entries do not appear here — they are on the "UFW Firewall" and "Attack map" pages. An empty block with a green check = no network failures in the last 24 hours.

28. Disk and SMART

The built-in page shows three things:

  • File systems — partition usage (df); the bar turns red at ≥90%;
  • Drives — the list of disks (lsblk), real ones only (loop/snap hidden);
  • Health (SMART) — disk status and attributes (smartctl).

Space and the device list work right away, no setup needed. SMART requires the smartmontools package. The web process has no direct access to disk devices, so SMART is read via cron into the file data/disk/smart.txt, and the dashboard reads it from there.

The job goes in the root crontab (sudo crontab -e). A ready-made wrapper smart-scan.sh is placed in /usr/local/bin/ (chmod +x; see the cron job summary) and writes to the dashboard's data/disk/ itself.

sudo apt install smartmontools # Cron line (every 30 minutes): */30 * * * * /usr/local/bin/smart-scan.sh >> /path/to/monitor/logs/cron.log 2>&1

The smart-scan.sh wrapper finds the dashboard's data/ on its own — no need to specify a path. Inside, lsblk -e7,11 excludes loop/cdrom.

On virtual disks (QEMU/KVM and the like), usually only the overall status "health: OK" is available, while temperature, power-on hours and reallocated sectors may be empty — this is normal. On a physical server all attributes are shown.

29. Performance (CPU/RAM/Network/Disk)

The page shows the server load history for the last 24 hours — Load Average, CPU usage and I/O wait, RAM/Swap, network traffic (receive/transmit), disk I/O (read/write), disk and inode usage, open file descriptors and MySQL connections, plus the current number of TCP connections and processes.

The data is collected by cron/collect_metrics.php — every 5 minutes it writes one "raw" snapshot of counters (/proc/loadavg, /proc/meminfo, /proc/stat, /proc/net/dev, /proc/diskstats, df/df -i, /proc/sys/fs/file-nr, SHOW GLOBAL STATUS LIKE 'Threads_connected') into the system_metrics database table; the page computes percentages and rates itself from the difference between adjacent snapshots (disk/inode/descriptor usage and MySQL connections are instantaneous values, with no recalculation). Sudo is not required — the sources are read without root privileges. Points older than 24 hours are deleted automatically on every write.

# Cron line (every 5 minutes): */5 * * * * /usr/local/bin/collect-metrics-all.sh >> /path/to/monitor/logs/cron.log 2>&1

The collect-metrics-all.sh wrapper (see the cron jobs summary) finds all installed panel instances on the server itself and runs each one's cron/collect_metrics.php as the site owner.

Until the collector has run at least twice (the first ~10 minutes after installation), the page shows "collecting data" — the charts need at least one pair of adjacent points to compute rates and percentages.

Load alerts (the "Settings" → "Load alerts" section) — when a CPU/RAM/disk/inode threshold is exceeded, the panel sends a notification to Telegram/Email (the same channels as the daily report — there's no need to enable them separately for alerts), and one more when the metric returns to normal. It won't spam again while the threshold is held: the next notification arrives only after a "recovered → exceeded again" cycle.

The thresholds are checked by the same collect_metrics.php on every run (every 5 minutes) — no separate cron is needed. The "already notified / not yet" state is stored in data/alerts_state.json, and the thresholds are in the panel settings.

30. Attack map (GeoIP)

The "Attack map" page determines the country by IP address using the geoiplookup command. Without the GeoIP package, countries won't be resolved and no points will appear on the map:

sudo apt install geoip-bin geoip-database # Check: geoiplookup 8.8.8.8
Sudo is not required — the /usr/share/GeoIP/GeoIP.dat database is readable by everyone, and results are cached in tmp/geoip_cache.json. The map itself (Leaflet + OpenStreetMap tiles) loads in the browser — you need internet on the computer where the dashboard is open.

31. External exposure, updates and automatic updates

Two built-in dashboard cards that show not a tool's "on/off" state but the server's real security posture. They need no setup and are read locally without sudo.

External exposure — how many services listen on all interfaces (0.0.0.0/[::]) and are reachable from outside. It flags red if a database or cache is exposed (MySQL, PostgreSQL, Redis, MongoDB, Memcached, Elasticsearch) — a direct hole (−10 to the Security Score). Source: ss -tuln.

If the card is red, close the database to the outside world: bind it to 127.0.0.1 (bind-address in the MySQL/PostgreSQL config, bind 127.0.0.1 in Redis) or close the port in UFW.
"Open port" ≠ "reachable from outside". A service listening on 127.0.0.1 (loopback) is visible only to the server itself — it cannot be reached from outside, even if the port is "open". That's why Postfix on port 25 bound to loopback is safe: auto-setup sets inet_interfaces = loopback-only (plus a neutral smtpd_banner — clearing the Lynis MAIL-8818 warning about version disclosure). The "External exposure" card counts as exposed only what listens on 0.0.0.0/[::]; loopback services don't count.
Lynis MAIL-8818 manually (if you set up mail yourself): in /etc/postfix/main.cf set smtpd_banner = $myhostname ESMTP (no version or OS) and inet_interfaces = loopback-only, then sudo systemctl restart postfix.

Security updates — how many security patches are pending and whether a reboot is required after a kernel update (−5 to the Security Score if patches exist). Source: /usr/lib/update-notifier/apt-check, file /var/run/reboot-required. Detailed list — on the "Security updates" page.

# Install updates: sudo apt update && sudo apt upgrade # Check what is listening externally: ss -tuln | grep -E '0\.0\.0\.0|\[::\]'
The updates card works on Ubuntu/Debian (update-notifier-common). If apt-check is missing, the monitor counts patches via apt-get -s upgrade.

Automatic security updates (unattended-upgrades) — on the "Security updates" page a separate card shows whether automatic installation of security patches is enabled and when it last ran. No sudo needed — the status is read via apt-config dump.

sudo apt install unattended-upgrades sudo dpkg-reconfigure -plow unattended-upgrades # enable # Check what is enabled: apt-config dump | grep Unattended-Upgrade

Maintenance

32. Backups

A backup is your main insurance: losing data is worse than any breach. You need two things — a server/site backup and a separate backup of the panel database (it holds users, WebAuthn keys, settings, and the license).

Option A — HestiaCP: the Backup tab under the user → the create-backup button (or on a schedule in the server settings). The backup includes sites and their databases.

Option B — manually (cron): a database dump + an archive of the panel's data/ directory:

# root cron (sudo crontab -e) — daily backup at 2:30 (substitute your own names/paths): 30 2 * * * mysqldump -u root MY_DB | gzip > /var/backups/monitor-db-$(date +\%F).sql.gz 40 2 * * * tar czf /var/backups/monitor-data-$(date +\%F).tar.gz -C /path/to/monitor data # Delete archives older than 14 days: 0 3 * * * find /var/backups -name 'monitor-*' -mtime +14 -delete
A backup on the same server protects against mistakes, but not against losing the server. Copy the archives to external storage (another server, S3, rclone to the cloud). Verify that restoring actually works.

33. Updating and migrating the panel

Updating to a new version. First make a backup. Then re-upload the code files, keeping your data:

  • overwrite (code): public/, includes/, assets/, cron/, database/, plus the root .htaccess (front controller — routing must not be left over from the old version), manifest.json, sw.js;
  • do not touch: config.php (database data), data/ (reports), logs/, tmp/ (sessions and cache).
# After uploading — clear the PHP cache (if opcache is enabled): sudo systemctl reload php*-fpm
FileZilla reports SSH_FX_PERMISSION_DENIEDPermission denied. The panel files belong to www-data (that is how they were set during installation), while the SFTP client connects as your own user, which has no write access. Handing the whole panel to www-data “to make it work” is exactly what leads to this error; below are three ways, any of which solves the problem.
# Option A (recommended) — split the owners: the code is yours, the working folders belong to the web server. # The web server gets no write access to the panel CODE at all: sudo chown -R deploy:www-data /path/to/monitor sudo chown -R www-data:www-data /path/to/monitor/data /path/to/monitor/tmp /path/to/monitor/logs sudo find /path/to/monitor -type d -exec chmod 755 {} \; sudo find /path/to/monitor -type f -exec chmod 644 {} \; sudo chmod 750 /path/to/monitor/data /path/to/monitor/tmp /path/to/monitor/logs sudo chmod 640 /path/to/monitor/config.php # Option B — an ACL on top of the current owners (nothing is moved): sudo apt install -y acl sudo setfacl -R -m u:deploy:rwX /path/to/monitor sudo setfacl -R -d -m u:deploy:rwX /path/to/monitor # Option C — via the www-data group. Simpler, but write access to the panel # files goes to the web server too (with a PHP vulnerability the code could be replaced): sudo usermod -aG www-data deploy sudo find /path/to/monitor -type d -exec chmod 2775 {} \; sudo find /path/to/monitor -type f -exec chmod 664 {} \; sudo chmod 640 /path/to/monitor/config.php sudo chmod 2750 /path/to/monitor/data /path/to/monitor/tmp /path/to/monitor/logs
Why option A is safe. The panel writes to only three directories — data/ (reports), tmp/ (sessions and cache), logs/; they stay owned by www-data. Everything else is code, and the web server only needs read access to it, which the www-data group provides with 644 permissions. A side benefit: with a PHP vulnerability the panel files can no longer be rewritten. On hosting panels (HestiaCP and similar) option A is not needed: there the site files already belong to the account you log in with over SFTP, and the web server reads them through the group.
The catch with option B: any subsequent chmod on the files resets the ACL mask, and access silently disappears. If after “tidying up the permissions” the upload again hits Permission denied — rerun both setfacl commands.
The 2 bit in option C is setgid: files uploaded over SFTP stay in the www-data group, otherwise the panel cannot overwrite them. After option C reconnect in FileZilla — the new group only takes effect on a new login. Check: id deploy (the www-data group must appear) and ls -ld /path/to/monitor (drwxrwsr-x — the letter s means setgid is set).

Migrating to another server:

  1. On the new server, set up the site + HTTPS (see the manual installation page).
  2. Copy all panel files together with config.php, data/.
  3. Migrate the database: mysqldump on the old one → import on the new one; update the database data in config.php.
  4. Repeat on the new server: sudoers, membership in the adm group, cron jobs.
  5. The license is bound to the domain — if the domain is the same, the key will keep working.

34. Recovering access (lost key, password, IP block)

If you can't log in, everything can be fixed directly in the database from the server. Open the database (its name is in config.php):

sudo mysql MY_DB

Lost WebAuthn key (second factor fails) — disable 2FA, log in with your password, and register a new key:

UPDATE users SET webauthn_enabled = 0;

Forgot your password — set a new hash (generate it on the server and paste it in):

# Generate a hash for the new password: php -r "echo password_hash('NEW_PASSWORD', PASSWORD_BCRYPT), \"\n\";" # In the database (paste the resulting hash): # UPDATE users SET password = '$2y$10$...' WHERE username = 'admin';

Locked yourself out with the IP filter — disable the restriction:

UPDATE settings SET value = '0' WHERE name = 'ip_restriction_enabled';
You always have database access: sudo mysql on the server, or phpMyAdmin / the database section in your hosting panel. After recovery, re-enable WebAuthn and the IP filter.

35. All cron jobs in one place

A summary of jobs lives in the server's root crontab (added via sudo crontab -e). Keep only the lines for the tools you use; adjust the paths to your server.

# Monitor server cron (root) — add via: sudo crontab -e # 01:30 — ClamAV scan over risky paths (web, home, temp) → "Files scanned" and "Last scan" cards 30 1 * * * /usr/local/bin/clamav-scan.sh >> /path/to/monitor/logs/cron.log 2>&1 # 02:00 — AIDE file integrity check (explicit --config required) 0 2 * * * /usr/bin/aide --config /etc/aide/aide.conf --check > /var/log/aide/aide.log 2>&1 # on boot — restore /var/lib/aide permissions (the aide-common.conf # tmpfiles package resets them to 0700 and the dashboard stops seeing the database) @reboot chmod 755 /var/lib/aide # 03:00 — Lynis security audit 0 3 * * * /usr/local/bin/lynis-scan.sh >> /path/to/monitor/logs/cron.log 2>&1 # 04:00 — update the ipsum block list (level 1) 0 4 * * * /usr/local/bin/load-ipsum.sh >> /path/to/monitor/logs/cron.log 2>&1 # the ipsum set is loaded at boot by the ipsum-load.service (BEFORE the firewall, otherwise # UFW won't see the set in before.rules) — not cron. Only the daily refresh above is here. # 06:00 — Logwatch report 0 6 * * * /usr/local/bin/logwatch_daily.sh >> /path/to/monitor/logs/cron.log 2>&1 # every 30 min — SMART disk check */30 * * * * /usr/local/bin/smart-scan.sh >> /path/to/monitor/logs/cron.log 2>&1 # 04:30 — debsums package integrity 30 4 * * * /usr/local/bin/debsums-scan.sh >> /path/to/monitor/logs/cron.log 2>&1 # 08:00 — scheduled report to Email and Telegram 0 8 * * * /usr/local/bin/daily-report-all.sh >> /path/to/monitor/logs/cron.log 2>&1 # hourly — refresh package lists (for the "Security updates" card) 0 * * * * /usr/bin/apt-get update -qq >/dev/null 2>&1 # every 5 min — resource snapshot (CPU/RAM/network/disk) for the "Performance" page */5 * * * * /usr/local/bin/collect-metrics-all.sh >> /path/to/monitor/logs/cron.log 2>&1
Details for each are in the relevant sections. Backup jobs (previous section) go into this same crontab. After editing, check sudo crontab -l and that the cron service is active.
Cron time = the server's timezone, not TIMEZONE from config.php. The TIMEZONE constant only affects PHP (how the dashboard shows dates), but the cron daemon runs jobs by the OS system time. If the server's timezone doesn't match yours, the "08:00" report will arrive at the wrong time. Example: the server is in another timezone (UTC, UTC+0) while you're in London (UTC+1) → the "08:00" report arrives at 09:00 your time. Check and, if needed, set the system timezone to your own:
# Check the server's current timezone: timedatectl # Set your timezone (example) and restart cron: sudo timedatectl set-timezone Europe/London sudo systemctl restart cron
After this, the line 0 8 * * * will fire at 08:00 local time. Otherwise you'd have to shift cron itself, but at each daylight-saving change the offset would drift again — so setting the system timezone is the right approach.
Ready-made wrapper scripts. Their working copies and a sample crontab (crontab.txt) sit in the system/ folder next to the project, outside public_html. This is not part of the site — don't upload it to the web root; place it on the server at the system paths (as in the crontab above):
  • lynis-scan.sh/usr/local/bin/ (chmod +x) — runs lynis audit system, sets the flag /tmp/lynis-running during the scan, and copies lynis-report.dat into the dashboard's data/lynis/;
  • logwatch_daily.sh/usr/local/bin/ (chmod +x) — builds the daily Logwatch report (sshd, fail2ban, sudo, postfix) into data/logwatch/;
  • smart-scan.sh/usr/local/bin/ (chmod +x) — captures disk status (smartctl) into data/disk/;
  • debsums-scan.sh/usr/local/bin/ (chmod +x) — checks package integrity (debsums) into data/debsums/;
  • clamav-scan.sh/usr/local/bin/ (chmod +x) — ClamAV antivirus scan over risky paths (web, home, temp); writes a summary to /var/log/clamav/scan.log, which the ClamAV page reads (the 01:30 line in the crontab above);
  • load-ipsum.sh/usr/local/bin/ (chmod +x) — updates the ipset set ipsum (level 1) in place, without breaking the active firewall rules (the 04:00 line in the crontab above);
  • daily-report-all.sh/usr/local/bin/ (chmod +x) — runs the dashboard's report cron/daily_report.php (the 08:00 line in the crontab above);
  • daily_report.php — already included in the dashboard (cron/daily_report.php), run via daily-report-all.sh, no separate install needed;
  • collect-metrics-all.sh/usr/local/bin/ (chmod +x) — runs the dashboard's cron/collect_metrics.php (the "Performance" page, the */5 line in the crontab above); collect_metrics.php is already included in the dashboard, no separate install needed;
  • crontab.txt (system/cron/) — a sample of jobs; add the lines you need via sudo crontab -e.
The script path in crontab must match where you actually put it.
How to place a script into /usr/local/bin/. You can't write there directly from FileZilla — the directory belongs to root, and the SFTP client will get SSH_FX_PERMISSION_DENIED. The order is: first upload the file to /tmp (everyone can write there), then move it into place with a single command:
# in FileZilla: enter /tmp in the "Remote site" field and upload the script there, # then over SSH (install sets the owner and permissions at once, no chown/chmod needed): sudo install -o root -g root -m 755 /tmp/lynis-scan.sh /usr/local/bin/lynis-scan.sh rm -f /tmp/lynis-scan.sh # check: file is in place, permissions rwxr-xr-x, syntax intact bash -n /usr/local/bin/lynis-scan.sh && ls -l /usr/local/bin/lynis-scan.sh
Don't mix up the directories: you need /tmp at the server root — not /var/tmp and not tmp/ inside the dashboard itself (the latter belongs to www-data and is closed off from your user). In the FileZilla tree, /tmp is a top-level branch, next to var, not inside it.
Set up the server with auto-configuration? These wrappers and their cron jobs are already installed by the script (in /usr/local/bin/, log — /var/log/arciveo-cron.log) — nothing to do by hand.
Where the scripts look for the dashboard. The wrappers are domain-neutral: they find dashboard installs by scanning /home/*/web/*/public_html and /var/www/*, and place reports in their data/. If the dashboard is at a different path, add it to the for app in … line inside the scripts, otherwise the Lynis/SMART/debsums/Logwatch reports won't reach the dashboard.
cron.log and access permissions. The file logs/cron.log is first created by the root crontab — it will belong to root, and the "Cron log" tab in the dashboard won't be able to read or clear it. Create the file in advance as the web user (owner of the site directory; on HestiaCP that's an account such as admin) — then the root cron will only append, without changing the owner:
# create in advance as the web user (before adding the cron lines): sudo -u OWNER touch /path/to/monitor/logs/cron.log # if cron.log was already created by root cron — hand it to the web user: sudo chown OWNER:OWNER /path/to/monitor/logs/cron.log sudo chmod 644 /path/to/monitor/logs/cron.log
Find the directory owner: stat -c %U /path/to/monitor.
Managing from the dashboard. The "System" section has a "Crontab" page — you can view and add jobs without SSH. The dashboard only edits jobs added through it (a separate block in the root crontab marked with service comments); everything already in the crontab (the list above) is shown there read-only as an "Other server jobs" list with a "Copy to editor" button — it only moves the schedule/command into the add form and doesn't touch the original line. To "bring" an existing job under dashboard management, copy it to the editor, save it, then remove the old line manually (sudo crontab -e), otherwise it will run twice.
One-time setup on the server. The page needs a privileged wrapper script — not a bare sudo crontab (that would be a direct escalation to root by anyone who gains access to the dashboard session), but a narrow script with two commands (list/set) that touches only its own block between the service comments. Install it once:
sudo install -m 0755 -o root -g root /dev/stdin /usr/local/sbin/arciveo-cron <<'ARCIVEO_CRON_EOF' #!/bin/bash set -euo pipefail BEGIN='# >>> ARCIVEO-CRON-MANAGED (edited from the panel; do not edit by hand) >>>' END='# <<< ARCIVEO-CRON-MANAGED <<<' cmd=${1:-} case "$cmd" in list) [ "$#" -eq 1 ] || { echo "usage: ${0##*/} list" >&2; exit 2; } crontab -l -u root 2>/dev/null || true ;; set) [ "$#" -eq 1 ] || { echo "usage: ${0##*/} set (body on stdin)" >&2; exit 2; } body=$(cat) if grep -qF "$BEGIN" <<<"$body" || grep -qF "$END" <<<"$body"; then echo "invalid body: markers not allowed" >&2; exit 2 fi current=$(crontab -l -u root 2>/dev/null || true) tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT { if grep -qF "$BEGIN" <<<"$current"; then awk -v b="$BEGIN" '{print} $0==b{exit}' <<<"$current" else [ -n "$current" ] && printf '%s\n' "$current" echo "$BEGIN" fi printf '%s\n' "$body" echo "$END" if grep -qF "$END" <<<"$current"; then awk -v e="$END" 'f{print} $0==e{f=1}' <<<"$current" fi } > "$tmp" crontab -u root "$tmp" ;; *) echo "usage: ${0##*/} <list|set>" >&2; exit 2 ;; esac ARCIVEO_CRON_EOF echo "www-data ALL=(ALL) NOPASSWD: /usr/local/sbin/arciveo-cron" | sudo tee -a /etc/sudoers.d/monitor sudo visudo -c
The web user may differ from www-data — check which user the site's PHP-FPM pool runs as (ps -o user= -C php-fpm) and substitute it in the sudoers line.
The new file was uploaded with the wrong owner — the page returns "Access denied.". If the file public/crontab_monitor.php was uploaded via FTP/SFTP under a different system user (for example, root) than the rest of the site files, the web server won't be able to read it. Compare the owner and permissions with a neighboring file and bring them in line:
ls -la public/crontab_monitor.php public/ssl_monitor.php sudo chown OWNER:OWNER public/crontab_monitor.php sudo chmod 644 public/crontab_monitor.php

Diagnostics

36. Tool is installed but shows "Not installed"

Monitor detects tools through dpkg-query — the APT package database. If a tool was not installed through apt (manually, from snap, or from source), dpkg does not see it.

# Check via dpkg: dpkg -l fail2ban | grep '^ii' dpkg -l auditd | grep '^ii' # Find the path to the binary: which ufw fail2ban-client auditctl # Test sudo as www-data: sudo -u www-data sudo fail2ban-client status sudo -u www-data sudo ufw status verbose

37. Troubleshooting (500, no data)

Error 500 — check the PHP, nginx and Monitor logs:

tail -50 /var/log/nginx/error.log tail -50 /var/log/php*-fpm.log # Monitor logs: tail -50 logs/monitor_$(date +%Y-%m-%d).log # Folder permissions: ls -la data/ tmp/ logs/
Dashboard on a hosting panel (HestiaCP, ISPmanager, cPanel)? There PHP runs not as www-data but under a user account (e.g. admin — the owner of the site directory). All sudo rules and group memberships (adm, systemd-journal) must be assigned to that user, otherwise the modules will show "Inactive / 0" while the services are running. To find the real PHP user: ps -o user= -C php-fpm | sort -u or the owner of the site directory stat -c '%U' /path/to/monitor. In all commands below, substitute it for www-data. Auto-install detects the web user itself and writes sudoers for it.

No data shown — almost always missing sudo permissions. Check the specific command as the web user (replace www-data with your own). The -n flag = no password, just like PHP — if it asks for a password, the rule is missing from sudoers:

sudo -u www-data sudo -n fail2ban-client status sudo -u www-data sudo -n ufw status verbose sudo -u www-data sudo -n ipset list -t ipsum sudo -u www-data sudo -n /usr/sbin/ausearch -m USER_LOGIN -ts today sudo -u www-data sudo -n /usr/sbin/aa-status sudo -u www-data sudo -n /usr/sbin/psad --Status sudo -u www-data journalctl -u falco --no-pager -n 5
A module shows "Inactive" / "0" even though the tool is running (e.g. sudo aa-status in the terminal lists profiles, but the "AppArmor" page says "Inactive"). Cause: the web user has no sudo permission for that module's command. Check it against the list above: if it asks for a password, add the missing line to /etc/sudoers.d/monitor ("Configuring sudo"). Common "new" commands: /usr/sbin/aa-status (MAC), /usr/sbin/psad --Status (PSAD).
If a specific page (Falco, ModSecurity, Auditd, open UFW ports) is empty — check it against the list in the sudo section: likely apache2ctl, ausearch, aa-status or ss is not allowed, or the web user is not in the adm/systemd-journal groups (the fail2ban/auth/modsec logs and journalctl — Falco and kernel events — are read from there).

38. The page is empty even though the data exists on the server

Symptom: the data is present on the server (visible via shell), but the page shows "no data" or the wrong status — for example, AIDE reports "Not initialized" even though the database has been created.

The cause is open_basedir: many panels and hosts restrict the PHP-FPM pool to the domain's directory, so the PHP functions file_exists(), file_get_contents(), filemtime() are blocked for system paths (/var/lib/aide, /var/log, /proc…). The Monitor works around this by reading such paths with standard system commands (cat, test, stat).

# Is the file visible via shell (this is how the monitor reads it): sudo -u www-data bash -lc 'test -e /var/lib/aide/aide.db && echo VISIBLE || echo NO' # Current open_basedir value for the domain's pool: grep -ri open_basedir /etc/php/*/fpm/pool.d/ 2>/dev/null
If the shell "sees" the file (VISIBLE) but the page does not — it's open_basedir. The correct fix is reading via system commands (already done for AIDE and the Network Monitor). Extending open_basedir to /var, /proc is unnecessary and less secure.

39. SSL page doesn't work

The Monitor checks certificates by connecting to domains directly over port 443. If a domain is unreachable from the server itself or the port is blocked by a firewall, the check will fail.

# Check the certificate manually: echo | openssl s_client -connect monitor.example.com:443 2>/dev/null \ | openssl x509 -noout -dates # Check availability: curl -I https://monitor.example.com
The Monitor picks up domains automatically from Nginx configs (/etc/nginx/sites-enabled/, /etc/nginx/conf.d/) and Apache (/etc/apache2/sites-enabled/), plus the current host from HTTP_HOST.
Subdomain auto-discovery. Subdomains are detected automatically from public Certificate Transparency logs and verified over the network — even if hosted on other servers. There's nothing to add manually.

40. Only one database is visible out of several

The Monitor connects to MySQL as the user from config.php, which only has access to its own database. MySQL lists only databases you have privileges on in information_schema — that's why the others aren't visible.

To let the Monitor see all databases, grant this user read-only access (once, as root; substitute the username from config.php):

sudo mysql -u root GRANT SELECT, PROCESS, SHOW DATABASES ON *.* TO 'DB_USER'@'localhost'; FLUSH PRIVILEGES; EXIT;
SELECT ON *.* grants read access only — nothing can be changed, deleted or created, so it's safe for monitoring.
Without this GRANT the dashboard sees only its own database — that's not a bug, it's a permissions limit. The dashboard doesn't use sudo mysql at all: the database list comes through its own PDO connection.

41. PostgreSQL does not appear on the "Database" page

PostgreSQL requires postgres user-level access, which the panel's web user does not have. Exposing a broad sudo psql from PHP is unsafe — instead, the panel calls a narrow parameterless wrapper that only prints the version, the number of connections and the list of databases with their sizes. Create it:

sudo tee /usr/local/bin/monitor-pgstat >/dev/null <<'EOF' #!/bin/sh # Arciveo Monitor - read-only PostgreSQL version, connections and per-database size sudo -u postgres psql -tAc "SELECT version();" | grep -oE 'PostgreSQL [0-9.]+' echo "---" sudo -u postgres psql -tAc "SELECT count(*) FROM pg_stat_activity;" echo "---" sudo -u postgres psql -tAc "SELECT datname || '|' || pg_size_pretty(pg_database_size(datname)) FROM pg_database WHERE datistemplate = false;" EOF sudo chown root:root /usr/local/bin/monitor-pgstat sudo chmod 755 /usr/local/bin/monitor-pgstat # in /etc/sudoers.d/monitor (user = the one PHP-FPM runs as): # www-data ALL=(ALL) NOPASSWD: /usr/local/bin/monitor-pgstat
If you do not use PostgreSQL, remove the monitor-pgstat line from sudoers (step 13 of manual installation) and do not create the script itself: the PostgreSQL card will simply stay inactive.

42. An alert fired — what to do

The dashboard shows what is happening; below is what to do in common situations. General rule: don't panic, cross-check against legitimate activity (your actions, updates, backups), and respond according to severity.

  • Attack map / lots of fail2ban bans — this is normal for any server on the internet (bots constantly probe SSH/web). What matters is that the bans work. Make sure SSH login is key-only (password disabled) and your IP is in ignoreip.
  • ModSecurity blocked requests — the WAF is fending off attacks on the site, that's its job. If it blocks your legitimate traffic (a false positive), find the rule id in the details and add an exception to the CRS config.
  • AIDE: files changed — compare the list with what you did (updating packages, editing configs is normal). Changes to system binaries you did not touch are cause for concern. After legitimate changes, update the AIDE database.
  • debsums: binaries/libraries changed (outside /etc, outside /usr/share) — potentially tampered. Verify the package: debsums PACKAGE_NAME, and if in doubt reinstall it (apt install --reinstall).
  • ClamAV / maldet: threat found — check the quarantined file, don't open it. If it's a web shell in the site directory, isolate the server and look for the entry point (a vulnerable plugin, leaked credentials).
  • Falco: critical events (a shell launched in a container, access to sensitive files) — analyze the event: whose process, what launched it. Often this is legitimate admin activity.
  • External exposure: database/cache in red — close it immediately: bind the service to 127.0.0.1 or close the port in UFW. This is a real hole.
  • SSL expiring / expired — renew the certificate (Let's Encrypt renews itself; if not, check certbot renew or the settings in the panel).
  • Security updates pending — install them: sudo apt update && sudo apt upgrade; after a kernel update, reboot the server.
Signs of a real breach (unknown processes/users, modified binaries, outgoing spam, unknown cron jobs): disconnect the server from external access, take a backup for analysis, and if the data is critical, spin up a clean server from a trusted backup — cleaning out a rootkit reliably is hard.
Arcivéo - Security Monitor © 2026