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
| Path | Purpose |
|---|---|
provisioning/wordpress/wphoster-agent/ | Plugin root (source; rsynced to each site) |
provisioning/wordpress/wphoster-agent/wphoster-agent.php | Main plugin file, registers REST routes |
provisioning/wordpress/wphoster-agent/includes/class-rest-controller.php | REST controller: health + actions |
provisioning/wordpress/wphoster-agent/includes/class-cache-purge.php | Admin-bar "Wyczyść cache" item and the REST relay to the panel |
provisioning/wordpress/wphoster-agent/includes/class-security.php | Security module: WordPress hooks (REST, lockout, audit, hardening) |
provisioning/wordpress/wphoster-agent/includes/class-security-rules.php | Pure decisions used by the module; tested without WordPress in tests/Unit/AgentSecurityRulesTest.php |
app/Http/Controllers/Api/AgentSecurityController.php | Panel side: POST /api/agent/security-events |
app/Services/Security/SecurityEventProcessor.php | Panel side: stores events, raises/resolves the security alerts |
app/Services/Security/SiteSecuritySettings.php | Panel side: per-site settings in Site.metadata['security'] and the constants written to wp-config |
app/Services/WordPress/WphosterAgentInstaller.php | Installs/re-syncs the plugin and writes the constants to wp-config |
app/Http/Middleware/AuthenticateAgent.php | Panel side: resolves the site from X-WPHoster-Token on /api/agent/* |
app/Http/Controllers/Api/AgentCacheController.php | Panel 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:
WPHOSTER_AGENT_TOKEN— Secret forX-WPHoster-Tokenheader (both directions)WPHOSTER_WP_CLI_PATH— Path to WP-CLI (for verify-checksums in plugin)WPHOSTER_PANEL_URL— Base URL of the panel the agent calls back (WP_HOSTER_AGENT_PANEL_URL, defaultAPP_URL)WPHOSTER_SECURITY_LOCKOUT_ATTEMPTS,WPHOSTER_SECURITY_LOCKOUT_MINUTES,WPHOSTER_SECURITY_APPLICATION_PASSWORDS,WPHOSTER_SECURITY_HIDE_REST_INDEX— the site's security settings (see below).DISALLOW_FILE_EDITis set separately throughwp config set, so a value the customer already defined is updated, not duplicated.
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 / Path | Purpose |
|---|---|
GET /wp-json/wphoster/v1/health | Returns PHP version, memory, WP version, active theme, plugins/themes list, db_size_bytes, disk_usage_bytes, core_checksum_status |
POST /wp-json/wphoster/v1/actions | Body: action = flush_cache | run_cron | clear_transients | verify_checksums |
POST /wp-json/wphoster/v1/purge-cache | Called 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:
- An administrator clicks Wyczyść cache in the admin bar (wp-admin or front end). The item only renders when
WPHOSTER_PANEL_URLand the token are defined. - The inline script posts to the site's own
/wp-json/wphoster/v1/purge-cachewith the REST nonce. - The plugin posts to
{WPHOSTER_PANEL_URL}/api/agent/purge-cachewithX-WPHoster-Tokenand{"wp_user": "<login>"}, then flushes the local object cache. - The panel resolves the site by token hash, verifies the plain token, dispatches
PurgeSiteCacheJoband writes anAuditEvent(site.cache_purge_queued,actor = agent:<slug>,wp_user). It answers202 {"ok": true, "message": ...}. - The result shows as a toast. A
429from the panel (more than 1 purge per minute or 10 per hour for that site) shows "spróbuj za chwilę"; a401(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:
| Area | What the agent does |
|---|---|
| REST for visitors | rest_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 lockout | wp_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 panel | login_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. |
| Hardening | No <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:
brute_force— warning on a lockout; critical once a site has more than 100 failed logins in the last hour (the warning is closed, so one open alert per site). Auto-resolved bywphoster:resolve-quiet-security-alerts(scheduled every 10 minutes) after an hour without failed logins.new_admin_user— critical onadmin_user_created/admin_role_granted; one open alert per site until it is resolved by hand.registration_enabled— warning.
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
| Key | Purpose |
|---|---|
wphoster.agent.enabled | Whether to install agent on provision/clone |
wphoster.agent.path | Absolute path to plugin source (default: base_path('provisioning/wordpress/wphoster-agent')) |
wphoster.agent.panel_url | URL written as WPHOSTER_PANEL_URL (WP_HOSTER_AGENT_PANEL_URL; falls back to APP_URL). Must be reachable from the sites. |