WP Hoster Agent

The WP Hoster Agent is a custom WordPress plugin installed on each site. It exposes a token-authenticated REST API used for health data and remote actions (flush cache, run cron, etc.). The panel uses the health endpoint during healthchecks to avoid relying solely on WP-CLI. Since 1.1.0 it also adds a "Wyczyść cache" item to the WP admin bar, so site administrators can purge every cache layer without a panel account. Since 1.2.0 it carries the security module: the application-level half of the hardening that replaces Wordfence (login lockout, REST exposure, audit events to the panel).

File paths

PathPurpose
provisioning/wordpress/wphoster-agent/Plugin root (source; rsynced to each site)
provisioning/wordpress/wphoster-agent/wphoster-agent.phpMain plugin file, registers REST routes
provisioning/wordpress/wphoster-agent/includes/class-rest-controller.phpREST controller: health + actions
provisioning/wordpress/wphoster-agent/includes/class-cache-purge.phpAdmin-bar "Wyczyść cache" item and the REST relay to the panel
provisioning/wordpress/wphoster-agent/includes/class-security.phpSecurity module: WordPress hooks (REST, lockout, audit, hardening)
provisioning/wordpress/wphoster-agent/includes/class-security-rules.phpPure decisions used by the module; tested without WordPress in tests/Unit/AgentSecurityRulesTest.php
app/Http/Controllers/Api/AgentSecurityController.phpPanel side: POST /api/agent/security-events
app/Services/Security/SecurityEventProcessor.phpPanel side: stores events, raises/resolves the security alerts
app/Services/Security/SiteSecuritySettings.phpPanel side: per-site settings in Site.metadata['security'] and the constants written to wp-config
app/Services/WordPress/WphosterAgentInstaller.phpInstalls/re-syncs the plugin and writes the constants to wp-config
app/Http/Middleware/AuthenticateAgent.phpPanel side: resolves the site from X-WPHoster-Token on /api/agent/*
app/Http/Controllers/Api/AgentCacheController.phpPanel side: POST /api/agent/purge-cache

Installation

WphosterAgentInstaller::install() rsyncs the plugin into {site_root}/wp-content/plugins/wphoster-agent, activates it if needed, generates a 64-char token (an existing token is kept on re-sync), and writes a // WP Hoster Agent block to wp-config.php:

Token is stored in site.metadata['agent_token'] so the panel can call the health endpoint, and its SHA-256 in sites.agent_token_hash so the panel can find the site for an incoming agent request with one indexed query.

Existing sites are backfilled or upgraded with php artisan wphoster:install-agent-on-existing-sites. It installs where the agent is missing and re-syncs where the installed version differs from WPHOSTER_AGENT_VERSION in the source; --force re-syncs every site, --dry-run only lists them, --site=slug limits to one site.

REST endpoints

Method / PathPurpose
GET /wp-json/wphoster/v1/healthReturns PHP version, memory, WP version, active theme, plugins/themes list, db_size_bytes, disk_usage_bytes, core_checksum_status
POST /wp-json/wphoster/v1/actionsBody: action = flush_cache | run_cron | clear_transients | verify_checksums
POST /wp-json/wphoster/v1/purge-cacheCalled by the admin-bar button (cookie + X-WP-Nonce, manage_options). Relays to the panel, see below.

Authentication

/health and /actions use a permission_callback that checks the X-WPHoster-Token header against WPHOSTER_AGENT_TOKEN (constant-time comparison). Missing or invalid token returns 401. /purge-cache is the one route meant for a browser: it requires a logged-in user with manage_options and a valid REST nonce, and the agent token never leaves the server.

"Wyczyść cache" from the WP admin bar

Clients do not have panel accounts, but the caches that matter (WP Fastest Cache files, nginx fastcgi_cache, Cloudflare) can only be purged by the panel. The flow:

  1. An administrator clicks Wyczyść cache in the admin bar (wp-admin or front end). The item only renders when WPHOSTER_PANEL_URL and the token are defined.
  2. The inline script posts to the site's own /wp-json/wphoster/v1/purge-cache with the REST nonce.
  3. The plugin posts to {WPHOSTER_PANEL_URL}/api/agent/purge-cache with X-WPHoster-Token and {"wp_user": "<login>"}, then flushes the local object cache.
  4. The panel resolves the site by token hash, verifies the plain token, dispatches PurgeSiteCacheJob and writes an AuditEvent (site.cache_purge_queued, actor = agent:<slug>, wp_user). It answers 202 {"ok": true, "message": ...}.
  5. The result shows as a toast. A 429 from the panel (more than 1 purge per minute or 10 per hour for that site) shows "spróbuj za chwilę"; a 401 (token mismatch, e.g. the site was re-provisioned) tells the user to contact the host.

Panel-side limits: the agent-purge rate limiter in AppServiceProvider keys on the site, not the IP, because every site on a box shares one outbound address. Requests with an unknown token are limited per IP (30/min) and then answered 401. Covered by tests/Feature/AgentPurgeCacheTest.php.

Security module (agent 1.2.0)

nginx (provisioning/nginx/site.conf.stub) already blocks XML-RPC, PHP under uploads, wp-cron.php/readme.html/license.txt from outside, and anonymous requests to /wp-json/wp/v2/users, ?rest_route=/wp/v2/users, ?author=N, wp-admin/install.php and upgrade.php (map on the wordpress_logged_in_* cookie in wphoster-security.conf). The agent repeats and extends that inside WordPress, where it can see the real session and the hooks:

AreaWhat the agent does
REST for visitorsrest_pre_dispatch: 401 on the index / (lists every namespace = installed plugins; switchable per site) and on wp/v2/users + wp/v2/users/{id}, however the request was addressed. Nothing else is touched: Contact Form 7, Divi, Yoast and wphoster/v1 keep working. oEmbed responses lose author_name/author_url; the REST <link> in the HTML head, the Link header and the RSD entry are removed.
Login lockoutwp_login_failed counts failures per IP (REMOTE_ADDR, already the real client behind Cloudflare) and per login in transients; after lockout_attempts (default 5) the authenticate filter (priority 30) refuses further attempts for lockout_minutes (default 15) with a Polish message. A successful login clears the counters. lockout_attempts = 0 disables the lockout (failures are still reported).
Events to the panellogin_failed, login_lockout, admin_login (a user with the administrator role signed in), admin_user_created, admin_role_granted, registration_enabled (users_can_register switched on), admin_email_changed (an administrator's e-mail, or the site's admin_email option). Each is a POST {WPHOSTER_PANEL_URL}/api/agent/security-events with X-WPHoster-Token, body {type, ip, login, user_agent, occurred_at, meta}, 3-second timeout, no retry. Attempts refused by the lock are not reported again, so one attacker IP yields at most lockout_attempts + 1 events per window.
HardeningNo <meta name="generator">, no X-Pingback header, application passwords off when WPHOSTER_SECURITY_APPLICATION_PASSWORDS is false. DISALLOW_FILE_EDIT is written by the panel (default true).

Settings per site

Stored in Site.metadata['security'] and edited in the panel under Sites → edit → Bezpieczeństwo: xmlrpc_enabled (nginx), lockout_attempts, lockout_minutes, application_passwords, hide_rest_index, disallow_file_edit. Saving the form regenerates the vhost when XML-RPC changed and calls WphosterAgentInstaller::applySecurityConfig(), which rewrites the // WP Hoster Agent block in wp-config.php with the current WPHOSTER_SECURITY_* constants (token kept) and runs wp config set DISALLOW_FILE_EDIT. If the push fails the previous settings are restored. The same push for every site:

php artisan wphoster:apply-security-config --dry-run   # show the constants per site
php artisan wphoster:apply-security-config             # write them
php artisan wphoster:apply-security-config --site=slug

Sites without the agent are skipped (install it first with wphoster:install-agent-on-existing-sites). New sites get the constants from the installer during provisioning and DISALLOW_FILE_EDIT=true from Provisioner::installWordPress(). The agent reads the constants with defaults and clamps (0–100 attempts, 1–1440 minutes), so a missing block just means the defaults.

Panel side: events and alerts

POST /api/agent/security-events is authenticated by AuthenticateAgent (same token as the purge relay) and limited to 120 events per minute per site (unknown tokens: 30/min per IP). Rows go to security_events (site_id, type, ip, login, user_agent, meta, occurred_at; timestamps from the site are clamped to "not in the future, not older than a week"). SecurityEventProcessor then raises:

The events are listed fleet-wide under Zdarzenia bezpieczeństwa, per site in the "Bezpieczeństwo" section of the site page (last 20, failed logins in the last hour, open alerts) and in the incident timeline (source security). Covered by tests/Feature/AgentSecurityEventsTest.php and tests/Feature/ApplySecurityConfigTest.php.

Health response structure (excerpt)

{
  "php_version": "8.2.x",
  "memory_limit": 268435456,
  "memory_usage": 12345678,
  "wp_version": "6.x",
  "active_theme": "Theme Name",
  "active_plugins_count": 5,
  "db_size_bytes": 1234567,
  "disk_usage_bytes": 12345678,
  "agent_version": "1.2.0",
  "plugins": [...],
  "themes": [...],
  "core_checksum_status": "ok" | "modified"
}

Config

KeyPurpose
wphoster.agent.enabledWhether to install agent on provision/clone
wphoster.agent.pathAbsolute path to plugin source (default: base_path('provisioning/wordpress/wphoster-agent'))
wphoster.agent.panel_urlURL written as WPHOSTER_PANEL_URL (WP_HOSTER_AGENT_PANEL_URL; falls back to APP_URL). Must be reachable from the sites.