
Monitoring centralisé pour instances Nextcloud multiples
Centralized monitoring for multiple Nextcloud instances
NcStatusCheck is a monitoring tool that lets you track the health of multiple Nextcloud servers from a single web interface. It analyzes Nextcloud and PHP versions and provides update recommendations.

< 15d / < 7d)occ app:list against the Nextcloud app store to flag apps to review — blocking (upgrade-blocking, incompatible, test apps in production) plus informational maturity/turbulence signals (recently published, pre-1.0, alpha/beta/rc build, burst of releases, freshly released version)📦) and a twin "Containers to watch" (🐳) block aggregate every flagged app / Docker image across all instances into one entry each, with per-group type filters and a popup listing the impacted instances and their versionsALERT_WEBHOOK_URL), and/or a digest email per batch (ALERT_EMAIL_TO) sent by direct SMTP submission to your mail server (vendored PHPMailer; falls back to the local MTA when no SMTP relay is configured). Also covers slow-moving signals (ALERT_CHECKS): SSL certificate expiry (tiered), vulnerable/deprecated Nextcloud version, stale Push probe, critical audit report, blocking app finding — one alert per new condition, no reminder spamnc_update_grace_days), to avoid chasing a same-day-buggy release⚠️ warnings, 📦 apps, 🔄 Docker, 🔒 SSL, 🔴 offline, 🚧 maintenance) when there is something to reportnc-audit.sh reportlocalStoragechmod 700) for the remote Nextcloud instance, auto-updated as options changencstatuscheck/
├── Frontend
│ ├── index.php # Main entry point
│ ├── template.html # HTML template (dashboard)
│ ├── admin.html # Administration interface
│ ├── detail.php # Server detail page
│ ├── audit.php # Server-audit script distribution page (nc-audit.sh)
│ ├── troubleshooting.php # Probe troubleshooting guide
│ ├── app.js / admin.js / detail.js / audit.js / troubleshooting.js
│ └── style*.css # One stylesheet per page family
├── APIs (HTTP)
│ ├── api.php # Main monitoring API
│ ├── detail-api.php # Detail page API (serverinfo + warnings + acks + availability)
│ ├── push-api.php # Push reception + trigger + audit-report reception
│ ├── ack-api.php # Warning acknowledge / unmute
│ ├── admin-api.php # Version configuration routing
│ ├── servers-admin-api.php # Server list management
│ ├── nextcloud-versions-api.php # Official version scraping
│ ├── nextcloud-apps-api.php # App store catalog (slim cache) for the apps audit
│ ├── php-versions-api.php # PHP branch support data
│ └── apps-warnings-api.php # Manual app warnings (known-bug list) CRUD
├── Shared modules (lib/)
│ ├── auth.php # Auth + CSRF + URL redaction (defense in depth)
│ ├── csrf-client.js # Auto-inject X-CSRF-Token in fetch()
│ ├── nextcloud-client.php # Centralized HTTP client → remote Nextclouds
│ ├── servers-store.php # Single source of truth for servers.json
│ ├── uptime-state.php # Up/down state machine + transition journal + availability
│ ├── alerts.php # Proactive alert dispatch: webhook + email digest
│ ├── alerts-checks.php # Slow-signal alerts (SSL/version/push/audit/apps) + dedup state
│ ├── smtp-mailer.php # SMTP transport adapter over vendored PHPMailer
│ ├── phpmailer/ # Vendored PHPMailer (3 files + LICENSE, pinned in VERSION)
│ ├── apps-warnings-manager.php # Manual app warnings storage
│ ├── ui-common.js # NcUI: notify / confirm / prompt + shared app-audit messages
│ ├── url-guard.php # Anti-SSRF (loopback, RFC1918, link-local…)
│ ├── json-cache.php # Locked JSON read/write helpers
│ └── version-config-manager.php # Version rules CRUD
├── Business logic
│ ├── version-rules.php # NC / PHP status analysis engine
│ ├── warnings-rules.php # Configuration warning engine
│ ├── apps-rules.php # Installed-apps audit engine (store catalog cross-check)
│ ├── cron-update.php # Full collection script, CLI only (twice a day)
│ └── cron-ping.php # Lightweight up/down probe, CLI only (every 5 min)
├── Tools (never web-served — blocked by nginx/.htaccess)
│ ├── tools/nc-audit.sh # Standalone server audit script (root, read-only)
│ └── tools/ncstatuscheck-push-core.sh # Generic Push probe core (fleet-shared)
├── Tests
│ └── tests/run.php # Plain-PHP test suite (no framework): php tests/run.php
├── Configuration
│ ├── config.php # Central configuration (git-ignored)
│ └── servers.json # Server list with tokens (git-ignored)
└── Cache
├── servers_data.json # All server data
├── serverinfo_<md5>.json # Raw per-server cache (Extended)
├── push_<md5>.json # Last push payload per server
├── ack_<md5>.json # Acknowledged warnings per server
├── audit_<md5>.json # Last nc-audit.sh report per server
├── version-config.json # Version configuration
├── uptime_state.json # Up/down state per server (mini uptime)
├── uptime_history.json # Bounded up/down transition journal (availability % + incidents)
├── alerts_state.json # "Already alerted" memory of the check alerts
├── nextcloud_versions.json # Official NC versions
├── nextcloud_apps.json # App store slim catalog (apps audit)
├── apps-warnings.json # Manual app warnings (admin-curated)
├── .csrf_secret # CSRF HMAC secret (binary, 0600)
└── *.log # Activity logs
deploy/ansible/ # Fleet deployment of the Push core (Ansible / scp)
deploy/docker/ # Container packaging of the monitor itself
Modes are not mutually exclusive — a server can be Extended and Push simultaneously.
The serverinfo NC-Token is available in Nextcloud Settings → Administration → System.
The push token is generated from the administration interface; the admin provides a ready-to-use bash cron script (chmod 700) to deploy on the monitored instance.
Extended mode data is provided by the nextcloud/serverinfo app, which must be installed and enabled on the monitored instance.
Fallback behavior: if the Extended API is unreachable (connection error, invalid token, app not installed), NcStatusCheck automatically falls back to /status.php to retrieve at least the Nextcloud version.
Push staleness threshold: a push server is considered stale if no data has been received within auto_push_interval + 30 minutes. The default push interval is 12 hours.
The main dashboard shows 5 columns: Server | NC Version | PHP | Probes | Health
The Probes column displays the active collection modes for each server:
⚡ Extended badge (purple, turns orange on connection error or stale data)📡 Push badge (blue, turns orange when no data received within the threshold)The Health column only shows something when there is something to act on:
NcStatusCheck keeps a minimal up/down state per server (current state + last change date only — no time series, no history page). "Up" means the outbound HTTPS probe reached the instance; a red Offline badge appears only when down. During that same HTTPS probe, the SSL certificate expiry is read for free (CURLOPT_CERTINFO) and surfaced when it gets close. Both are visible in full on the detail page. Note: these outbound checks don't apply to Push-only instances that the monitor never contacts.
📦)When a Push server reports its installed apps (occ app:list, push script v3+), NcStatusCheck cross-checks them against the Nextcloud app store catalogue and flags apps worth reviewing. Signalling only — the tool never disables anything; it surfaces candidates (it cannot know whether an app is actually used). Only factual, binary signals are used. The 📦 N badge counts blocking findings (no compatible release for the current NC version, no release for NC N+1 → blocks the upgrade, or a test/dev app left enabled in production). Informational findings (app outdated on the instance, abandoned upstream, PHP incompatible) are shown on the detail page only. Findings can be muted via the same acknowledge mechanism as warnings.
servers.json format[
{"url": "https://cloud.example.com"},
{"url": "https://cloud2.example.com", "serverinfo_token": "abc123def456"},
{"url": "https://cloud3.example.com", "serverinfo_token": "...", "push_token": "xyz789"}
]
server {
server_name monitoring.your-domain.com;
root /var/www/ncstatuscheck;
index index.php;
# HTTP Basic Authentication
auth_basic "Monitoring Access";
auth_basic_user_file /etc/nginx/.htpasswd;
# Protect sensitive files/dirs (tests/run.php has no CLI-only guard — it must
# never be reachable over HTTP; same blocklist as deploy/docker/nginx.conf)
location ~ ^/(cache/|\.git|deploy/|tools/|tests/) {
deny all;
return 404;
}
# .txt covers servers.txt (legacy server list — real monitored URLs)
location ~* \.(log|json|txt)$ {
deny all;
return 404;
}
# Security headers for static HTML pages (admin.html, template.html).
# PHP pages (index.php, detail.php) send the same headers themselves
# via send_security_headers() in lib/auth.php.
location ~* \.html$ {
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'" always;
try_files $uri =404;
}
# Standard PHP configuration (adjust the socket to your PHP version —
# use a security-supported one: 8.2 has been EOL since December 2025)
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location / {
try_files $uri $uri/ =404;
}
}
Apache: the repository ships
.htaccessfiles mirroring thedenyrules above (root: blocks*.log/*.json/*.txtand.git;cache/,tools/,deploy/,tests/:Require all denied). They only work if the vhost setsAllowOverride FileInfo AuthConfig(orAll) — Debian's default for/var/wwwisAllowOverride None, in which case replicate the rules in the vhost directly. HTTP Basic auth still has to be configured in the vhost either way.
git clone https://gitlab.com/jp.louvel/ncstatuscheck.git
cd ncstatuscheck
cp config-example.php config.php
Edit config.php and adjust paths and URL to your environment:
define('MONITOR_PATH', '/var/www/ncstatuscheck');
define('MONITOR_URL', 'https://monitoring.your-domain.com'); // your public URL
define('CACHE_DIR', MONITOR_PATH . '/cache');
Servers are managed directly from the administration interface (⚙️ Admin button).
You can also create servers.json manually:
[
{"url": "https://cloud.example.com"},
{"url": "https://nextcloud.mycompany.org", "serverinfo_token": "your_token_here"}
]
Migration from
servers.txt: if aservers.txtfile exists, it is automatically converted toservers.jsonon first access. You can then deleteservers.txt.
nginx/PHP-FPM run as their own user (www-data on Debian/Ubuntu, nginx/apache
on RHEL-family — adjust below) — if you cloned as your own login user, that user
almost certainly won't be www-data or in its group, so chmod alone leaves the
web server with no access at all, not even read (every request 403/404s):
chown -R www-data:www-data /var/www/ncstatuscheck # adjust the user:group to your distro
chmod 750 /var/www/ncstatuscheck
chmod 750 cache img
chmod 640 *.php *.html *.js *.css *.md
chmod 600 config.php servers.json servers.txt # secrets / serverinfo & push tokens
chmod 660 cache/*.json cache/*.log
If
servers.jsondoesn't exist yet (you're letting the admin UI create it instead of the manual step above), thechmod 600above simply has nothing to act on — that's fine:ServersStore::save()chmods the file to0600itself on every write, so aservers.json(re)created through the admin UI never stays group/world-readable with serverinfo/push tokens inside.
htpasswd -c /etc/nginx/.htpasswd admin
Two complementary crons — install both in the same crontab - call:
crontab - installs a whole new crontab from stdin, it doesn't append, so
running it twice (once per line) leaves only the second job — the first
silently vanishes, no error. This also preserves anything already in your
crontab (crontab -l piped in first) instead of wiping it:
(crontab -l 2>/dev/null; cat <<'EOF'
# Full collection (NC/PHP versions, serverinfo, app-store catalog) — twice a day
0 6,18 * * * cd /var/www/ncstatuscheck && php cron-update.php
# Lightweight reachability probe (status.php only -> up/down state) — every 5 min
*/5 * * * * cd /var/www/ncstatuscheck && php cron-ping.php
EOF
) | crontab -
Re-running this appends duplicates if these lines are already present — check
with crontab -l first if unsure.
cron-ping.php is intentionally minimal: it only checks each instance's
status.php and updates the up/down state (cache/uptime_state.json), so it can
run frequently without load. An instance is only flagged down after
UPTIME_FAIL_THRESHOLD consecutive failed probes (default 2 → ~10 min with a
5-min cadence); recovery to up is immediate. The full cron-update.php
remains unchanged for everything else.
Instead of steps 1–6 above, NcStatusCheck can also run as a small docker compose stack (PHP-FPM + nginx + a cron container) — the repo is bind-mounted
as-is, no build step or Composer, so it mirrors the bare-metal layout exactly,
just containerised. Serves plain HTTP only (port 8080 by default) — put your
own TLS-terminating reverse proxy in front of it.
Full setup — configuration, the permissions gotchas (uid 82, servers.json
pre-creation), HTTP Basic Auth, cron, updates and backups — lives entirely in
deploy/docker/README.md. Start there; this
section is intentionally just a pointer, to avoid keeping two copies of the
same steps in sync.
https://monitoring.your-domain.comAccessible by clicking on any server name or its Health indicator.
For Basic servers (no Extended or Push probe), a simplified page shows the available data (NC version, web server, HTTP protocol) with a notice and a suggestion to enable a probe.
For Extended / Push servers, the full detail page displays separate sections:
NcStatusCheck exposes several REST endpoints:
Main API (api.php)
GET ?action=get_data — Fetch data (cache or refresh)POST ?action=refresh_data — Force update of all serversPush API (push-api.php)
POST with push_token header — Receive push data from a remote NC instancePOST ?action=request_push_all — Request an immediate push from all configured push servers (sets a trigger flag consumed by the remote cron script)The cron script generated by the admin UI is split in two: a generic core
/usr/local/bin/ncstatuscheck-push.sh— identical on every server (all the logic) — driven by a small per-instance config/etc/ncstatuscheck/<slug>.conf(SERVER_URL,SLUG,OCC_CMD,DOCKER_ENABLED,SKOPEO_ENABLED). It is invoked asncstatuscheck-push.sh /etc/ncstatuscheck/<slug>.conf [--test]. The core refuses to source a group/world-writable config (anti code-injection).It is multi-target (fan-out): data is collected once and pushed to every monitor listed in
/etc/ncstatuscheck/targets-<slug>.conf(oneurl|push_token[|http_user|http_pass]line per monitor). Each monitor's admin emits an idempotent command to register itself.Multiple Nextcloud instances on one host: per-instance paths are suffixed by a
<slug>derived from the monitored URL (e.g. → ): , , , , state . Only the core is shared, so co-located instances never collide.
Detail API (detail-api.php)
GET ?server=<url> — Full serverinfo data + computed warnings for an Extended/Push serverAdministration APIs
admin-api.php — Version configurationservers-admin-api.php — Server management (get_servers, add_server, remove_server, update_server_token, generate_push_token, remove_push_token)nextcloud-versions-api.php — Official versionsnc-audit.sh)A separate subsystem from monitoring: a standalone, read-only bash script
(tools/nc-audit.sh) run as root on a Nextcloud server for a one-off / monthly
audit of the web + PHP + database tuning, cross-checked against the machine's
physical capacity (RAM, CPU, disk type). Aimed at a managed-supervision offer:
the client installs it, the monitor only receives reports — no machine/network
access required. The script only reads the configuration (no changes), prints
a coloured report and writes a copy to /tmp.
What it checks: server capacity (RAM/CPU/SSD-HDD, swappiness, shared-server
detection) · Nextcloud (versions, cron, cache, Redis runtime, DB type, logs) ·
PHP/PHP-FPM (real serving SAPI, OPcache runtime, multi-pool memory) · Apache
(MPM-aware worker memory) · Nginx · PostgreSQL · MariaDB · security hygiene
(fail2ban or CrowdSec + bouncer + community blocklist; pending updates /
reboot / services on stale libraries) · RAM budget reconciliation (InnoDB +
FPM + Apache vs real RAM) · deep analysis with optional tools if already present
(mysqltuner, pt-variable-advisor, apache2buddy, sar/iostat).
# Download (the page distributes it; the repo raw URL is public)
curl -fsSL https://gitlab.com/jp.louvel/ncstatuscheck/-/raw/master/tools/nc-audit.sh -o /usr/local/bin/nc-audit.sh
chmod 700 /usr/local/bin/nc-audit.sh
sudo nc-audit.sh # auto-detect, dedicated server
sudo nc-audit.sh /var/www/nextcloud # explicit path (or NC_PATH=…)
sudo NC_RAM_BUDGET_PCT=50 nc-audit.sh # shared host: size to 50% of RAM
Multi-instance hosts (several Nextclouds + a shared database). NC_RAM_BUDGET_PCT
is then the total stack budget; NC_PHP_SHARE_PCT% of it (default 60, the rest
covers DB + web + OS — lower it on DB-heavy servers) is the PHP share, split across
FPM pools by weight (a relative importance — not a percentage, not MB) to give a
target pm.max_children per pool:
target = PHP_share × (weight / Σ weights) / ~50 MB per process
The target is a ceiling the budget allows, not a value you must set (only raise a pool that actually saturates). Weights are your call — the tool never guesses them.
# Weights you provide (a human judgment — the tool never guesses them):
sudo NC_RAM_BUDGET_PCT=70 NC_INSTANCES="poolA:4,poolB:2,poolC:1" nc-audit.sh
# Interactive helper (terminal only): lists the pools, asks a weight for each,
# prints the targets + a reusable NC_INSTANCES line:
sudo NC_RAM_BUDGET_PCT=70 nc-audit.sh --tune-fpm
Report push-back (optional, reuses the Push infrastructure): nc-audit.sh --push /etc/ncstatuscheck/<slug>.conf runs the audit and POSTs the report to the
monitor(s), which store it and show it on the server's detail page ("🩺 Server
audit" section). Typically a monthly cron. The web page (admin, beta) at
audit.php distributes the script (download + inline + GitLab one-liner) and shows
its version.
Deep-analysis tools are never installed by the script — they only run if already present (no
curl | bash, no auto-install), each bounded bytimeout.
Evaluation rules are configurable via the administration interface:
Nextcloud statuses:
dev — Development versionstable — Current stable versionoldstable — Previous supported stable versiondeprecated — Deprecated versionPHP statuses:
recommended — Recommended versionsupported — Supported versiondeprecated — Deprecated versionEdit config.php to adapt the configuration:
// Environment: 'dev' or 'prod'
define('ENV', 'prod');
// Paths and URLs
define('MONITOR_PATH', '/var/www/ncstatuscheck');
define('MONITOR_URL', 'https://monitoring.your-domain.com');
// Main server cache duration
define('CACHE_MAX_AGE', 86400); // 24 hours
// Official Nextcloud versions cache duration
define('VERSIONS_CACHE_AGE', 86400);
// Consecutive failed probes before a server is marked "down" (min 1)
define('UPTIME_FAIL_THRESHOLD', 2);
// Proactive alerts — webhook on a confirmed up/down state change.
// Empty URL = disabled. Format: 'slack' (default, also Mattermost/Google Chat),
// 'discord', or 'raw' (structured JSON). The URL usually carries a secret, so it
// is never logged in full — see config-example.php for details.
define('ALERT_WEBHOOK_URL', '');
define('ALERT_WEBHOOK_FORMAT', 'slack');
// Check alerts on top of up/down (cron-update cadence, 2×/day): SSL expiry
// tiers, vulnerable (below min_secure) or deprecated Nextcloud version, stale
// Push data, critical audit report, blocking apps-audit finding. Edge-triggered with a persisted state
// (cache/alerts_state.json): one alert per NEW condition, no reminders, re-arms
// when resolved (renewed cert, fixed/acked app…). First run arms silently.
define('ALERT_CHECKS', 'ssl,version,push_stale,audit,apps'); // '' = up/down only
define('ALERT_SSL_DAYS', '30,14,7'); // days-left tiers
// Email channel, independent of the webhook (either one arms the alerting).
// One digest mail per batch. Recommended transport: direct SMTP submission to
// your mail server (vendored PHPMailer, lib/phpmailer/ — nothing to set up on
// the host). Without ALERT_SMTP_HOST it falls back to PHP mail() (local MTA).
define('ALERT_EMAIL_TO', ''); // comma list of recipients, '' = off
define('ALERT_EMAIL_FROM', ''); // default: ncstatuscheck@<hostname>
define('ALERT_SMTP_HOST', ''); // e.g. 'mail.example.org', '' = mail() fallback
define('ALERT_SMTP_PORT', 587);
define('ALERT_SMTP_SECURITY', 'starttls'); // 'starttls' | 'tls' | 'none'
define('ALERT_SMTP_USER', '');
define('ALERT_SMTP_PASS', '');
See
config-example.phpfor the full, commented list of options (includingDEMO_MODEandPUSH_SCRIPT_VERSION).
lib/csrf-client.js + csrf_require()).htaccess files for Apache; servers.json chmod'ed 0600 automatically (tokens inside)X-Frame-Options, nosniff, Referrer-Policy) on every PHP-served pagehash_equals() passes on entry's token, so a compromised monitored server can neither read nor overwrite another one's data. / sit behind admin auth + CSRF. Verified end to end against a compromised-client scenarioconfig.php is gitignored and hand-edited per server, so it drifts — silently,
since nearly every constant has a fallback in the code. A banner at the top of
the admin page reports what is actually wrong, and only when something is:
a PUSH_SCRIPT_VERSION left behind by a bump, no alert transport configured at
all, a trailing closing tag emitting a byte before any header(), an unwritable
cache directory, constants absent and silently falling back to defaults.
Read-only by design and with no save action, for the same reason as the
Notifications tab: config.php is root-owned and holds secrets. The file
content never travels — only facts about it — and no secret is read.
Everything above is application-level: anyone on the internet can still reach the monitor and probe it, and only the password stops them. The IP filtering tab generates the rules that put an allowlist in front of the app, so unknown hosts cannot talk to it at all. It is defense in depth, not a replacement for Basic auth or the push tokens — and it only ever produces text to review and paste, it never writes a web server or firewall config.
Two classes of source, deliberately unequal, so a compromised monitored server cannot reach the admin:
| Class | Who | May reach |
|---|---|---|
push | monitored instances in Push mode only | /push-api.php, nothing else |
admin | bastion / VPN / fixed office IP | everything |
Instances polled in Basic/Extended mode open no inbound connection and get no allowlist entry at all.
Addresses come from two sources, and the difference matters: a monitored
domain's DNS record is its ingress address, while its push leaves from its
egress. Where they differ, only the second one works. push-api.php therefore
records the real source address of every push (source_ip in the push cache),
and the tab allowlists that, reporting the mismatch. Until a server has pushed
once, it falls back to DNS A+AAAA and says so.
Three outputs:
conf.d file (geo + map) plus a
single if ($ncsc_forbidden) { return 403; } line in the vhost. No need to
duplicate the fastcgi block, static files are covered too (admin.html is
one), and /.well-known/acme-challenge/ stays open so certificate renewal
cannot silently break.<LocationMatch> with a negative lookahead plus a <Location>
for the push endpoint, so the two sections cannot overlap and nothing depends
on Apache's merge order. All addresses of a rule go on one Require ip
line: several lines inside <RequireAll> are ANDed, which nobody can satisfy.The generator refuses to emit anything when no administration address is given,
warns when the operator's own address is not covered, and warns when the request
came through a proxy (both geo and Require ip read the transport peer, so
behind a proxy every client looks alike). The generated ufw snippet puts the SSH
rule first, keeps port 80 open for the HTTP-01 challenge, and spells out the
IPv6 trap: unlike nginx, which refuses an unlisted v6 address, ufw does not
filter v6 at all unless IPV6=yes is set — a dual-stack host would otherwise be
wide open over IPv6.
Known limit, surfaced in the page itself: once the rules are applied, this
tab discovers nothing new. A refused push is rejected by the web server before
it reaches PHP, so the recorded address stays the last one that got through —
and still looks verified. Two consequences: adding a Push server means
regenerating and reapplying the rules, or its first push is refused; and if an
instance's address changes, the new one is only readable in the web server
access log (grep 'push-api.php' access.log | grep ' 403 '). The tab therefore
shows the last-seen date of every observed address and flags it once it is older
than a full missed push cycle — the same threshold as the push_stale alert,
which covers the same blind spot from the other side.
Telling a filtering problem from any other one: a bare GET on the push
endpoint separates the layers cleanly, with no side effect and no token needed —
run it from the machine concerned, since what is judged is that machine's
outgoing address:
curl -sS -o /dev/null -w '%{http_code}\n' https://your-monitor/push-api.php
| Answer | Meaning |
|---|---|
403 | blocked by the IP filtering |
401 | filtering passed, Basic auth is answering — the problem is elsewhere |
Re-run with -u user:password to settle a doubt on a 403: if the code does not
move, it really is the filtering. Verified on both nginx and Apache (including
with Require valid-user enabled), the filter answers before authentication —
and a 403 coming from the application itself always carries JSON in the body.
The snippet logic lives in lib/hardening-rules.php, which is pure and covered
by tests/run.php: the snippets are the product here, and a wrong one either
locks the operator out or leaves a hole. Both the nginx and the Apache output
have been verified behaviourally (real servers, real source addresses, including
path-traversal attempts from the push class).
security stage)Dependency scanning (npm/pnpm audit, Snyk Open Source, Dependabot) is a no-op
here: there is no package.json and no composer.json — nothing to scan. The
risk lives in the custom code (~15k lines of PHP, ~6k of JS) and in the shell
scripts that run as root on monitored instances (tools/*.sh). The pipeline
is aimed there:
All blocking jobs have a zero-finding baseline, so any new alert is a real
signal. Two deliberate calls, documented inline in .gitlab-ci.yml:
php.lang.security.injection.echoed-request is excluded from semgrep: it
flags every echo json_encode() as XSS, which is what every API endpoint here
legitimately does (JSON responses, not HTML). It accounted for 10 out of 10
findings on the first run, all false. Keeping it would train everyone to
ignore the job.allow_failure jobs report upstream facts (a CVE in nginx:alpine
or one whose fix has not reached the Alpine branch yet, a new PHPMailer
release) that a merge request cannot fix. Red-but-tolerated is the accurate
signal — "time to rebuild / refresh the vendoring" — not a reason to block
unrelated work. phpmailer_freshness reports an unreachable or rate-limited
GitHub API as a skip, never as "outdated".container_cve scans the image it builds, not the FROM tag. The
Dockerfile hardens the base with apk --no-cache upgrade (the official PHP
image lags behind the Alpine repos — it shipped c-ares 1.34.6-r0 while
1.34.8-r0, fixing CVE-2026-33630, was already published). Scanning the base
tag would therefore report CVEs the shipped image no longer has: a
permanently orange job nobody reads.Allowlists are intentionally narrow: .gitleaks.toml excuses literal
placeholder strings, never whole documentation files (allowlisting README.md
would blind the scan the day a real secret is pasted into it) — so a new example
token in the docs must be added there. .trivyignore holds a single entry,
DS-0002, argued in the file: the php-fpm master must start as root to drop its
workers to www-data (uid 82).
nc-selfcheck.sh)CI can lock the shipped config (the deploy_selfcheck job above stands the
nginx ruleset up in a container and probes it), but it cannot verify the server
you actually deployed to — different host, Basic-auth credentials, filesystem
permissions. tools/nc-selfcheck.sh closes that gap. It is a standalone,
read-only bash script (same model as nc-audit.sh) you run after each deploy:
# Black-box, no credentials: confirms Basic auth is enforced (401) and that
# sensitive files are blocked (cache/, servers.*, .git, config.php source).
bash tools/nc-selfcheck.sh https://monitoring.example.com
# + security headers behind Basic auth:
bash tools/nc-selfcheck.sh -u user:pass https://monitoring.example.com
# + filesystem checks (run ON the host): servers.json / config.php / CSRF-secret
# permissions, and a stray closing "?>" in config.php.
bash tools/nc-selfcheck.sh --webroot /var/www/ncstatuscheck https://monitoring.example.com
It exits non-zero on any critical finding (source leak, unblocked secret file, world-readable token store, missing Basic auth), so it can gate a rollout — wire it into your sync/deploy script as a post-step. WARN/INFO never fail the run.
// In config.php
define('ENV', 'dev');
In development mode, additional information is displayed (PHP version, web server).
Use the administration interface to add a server by URL. The server will be polled on the next data refresh.
Check the log files in cache/:
monitor.log — General application logscron.log — Full collection script logs (cron-update.php)ping.log — Lightweight up/down probe logs (cron-ping.php)alerts.log — Proactive alert dispatch (webhook/email), never logs webhook secrets or SMTP credentialsphp tests/run.php # plain-PHP assertions, no framework — exit 0 = all green
Covers the pure business logic (version/apps rules, warnings, uptime state machine and availability, alert dedup/re-arm state machine, email builders).
Open a new issue with:
This project is licensed under GNU AGPL v3.
NcStatusCheck is developed by ézéo, a digital cooperative specializing in open source solutions.
Need help? Check the issues or contact the ézéo team.
| Mode | Badge | Source | Collected data |
|---|
| Basic | (none) | /status.php + HTTP headers | Nextcloud version (PHP/webserver if exposed) |
| Extended | ⚡ Extended (purple → orange on error/stale) | /ocs/v2.php/apps/serverinfo/api/v1/info with NC-Token | NC version, PHP, web server, OPcache, Redis, DB, active users… |
| Push | 📡 Push (blue → orange on error/stale) | POST to push-api.php | Data pushed by the remote NC instance via cron script |
| Indicator | Badge | Meaning |
|---|
| Offline | 🔴 Offline | Instance unreachable (HTTP probe failed), with "offline for X" |
| Active warnings | ⚠️ N | N configuration issues |
| Apps audit | 📦 N | N installed apps to review (upgrade-blocking/incompatible) |
| SSL expiry | 🔒 N d | Certificate expires soon — orange < 15d, red < 7d or expired |
| Docker updates | 🔄 M | M container updates available |
| All OK | (empty) | Nothing to report |
| No data | ? | Basic mode with no push data |
| Section | Fields |
|---|
| Nextcloud system | Version, debug mode, local/distributed memcache, file locking, disk space |
| PHP | Version, memory_limit, upload_max_filesize, max_execution_time, FPM, OPcache |
| Web server | Name + version, HTTP protocol |
| Database | Type, version, size |
| Cache | Redis, APCu hit rate |
| Active users | Last 5 min, 1 h, 24 h, 7 days |
latest.ezeo.cooplatest_ezeo_coop<slug>.conf/etc/cron.d/ncstatuscheck-<slug>targets-<slug>.confncstatuscheck-push-<slug>.log…-<slug>.<md5>.lastNextcloud running in Docker (official image, compose, AIO): fully supported — the
script is installed on the host (root cron + Docker daemon access), never inside the
container, and occ goes through docker exec:
OCC_CMD=docker exec -u www-data <container> php occ (AIO container: nextcloud-aio-nextcloud).
The admin script generator has an install type preset that prefills this. Never add
-t (no TTY under cron); keep -u www-data (the official image refuses occ as root).
Fleet deployment / updates: because the core is a single identical file, updating
the logic across many servers = replacing that one file (the ↑ marker flags servers
running an older version). See deploy/ansible/ for a ready-to-use
playbook (or a plain scp loop). The monitor stays passive — it never sends code
to the fleet; the trust anchor is your own SSH access, not the monitor.
Migrating from a pre-v4 install (monolithic per-instance script): remove the old
/usr/local/bin/ncstatuscheck-push-<slug>.sh and /etc/cron.d/ncstatuscheck-<slug>
before installing the core + config (the targets-<slug>.conf is reused as-is),
otherwise you double-push.
request_pushrequest_push_alltargets.conf silently copies every push to a third party otherwise<>"'& at ingestion, on top of escaping at render timetry/catch can catch, which would kill the collection run mid-loop and, with it, every alert for the whole fleet405 | the request reached the application (GET is not an accepted method there) |
| nothing / timeout | not the filtering: a filter answers, it does not go silent |
| Job | Tool | Blocking | Scope |
|---|
secrets_scan | gitleaks | yes | committed secrets (working tree) |
sast_semgrep | semgrep (p/php, p/javascript, p/owasp-top-ten) | yes | SSRF, missing authz/CSRF, XSS |
shellcheck | shellcheck (--severity=warning) | yes | tools/*.sh — root on client hosts |
dockerfile_misconfig | trivy misconfig | yes | deploy/docker/ |
container_cve | trivy image | no (allow_failure) | the image deploy/docker builds, plus nginx:alpine |
ui_tests | node (no deps) | yes | escaping invariants of lib/ui-common.js (both past XSS regressions) |
phpmailer_freshness | GitHub API | no (allow_failure) | vendored pin vs upstream release |
deploy_selfcheck | nc-selfcheck.sh | yes | the shipped nginx ruleset (deny rules + security headers) stood up in a throwaway container |