feat: convert WordOps from Nginx to OpenLiteSpeed + LSPHP + LSCache
Some checks failed
CI / test WordOps (ubuntu-22.04) (push) Has been cancelled
CI / test WordOps (ubuntu-24.04) (push) Has been cancelled

Complete conversion of the WordOps stack from Nginx + PHP-FPM to
OpenLiteSpeed + LSPHP + LSCache. This is a full rewrite across all 7
phases of the codebase:

- Foundation: OLS paths, variables, services, removed pynginxconfig dep
- Templates: 11 new OLS mustache templates, removed nginx-specific ones
- Stack: stack_pref, stack, stack_services, stack_upgrade, stack_migrate
- Site: site_functions, site, site_create, site_update
- Plugins: debug, info, log, clean rewritten for OLS
- SSL/ACME: acme.sh deploy uses lswsctrl, OLS vhssl blocks
- Other: secure, backup, clone, install script

Additional features:
- Debian 13 (trixie) support
- PHP 8.5 support
- WP Fort Knox mu-plugin integration (wo secure --lockdown/--unlock)
- --nginx CLI flag preserved for backward compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 18:55:16 +01:00
parent aa127070e1
commit fa5bf17eb8
42 changed files with 2328 additions and 2926 deletions

View File

@@ -1,3 +1,4 @@
[Definition]
failregex = ^ \[error\] \d+#\d+: .* forbidden .*, client: <HOST>, .*$
failregex = ^ \[error\] .* forbidden .*, client: <HOST>, .*$
^\S+ \S+ \[.*\] .* 403 .* <HOST> .*$
ignoreregex =

View File

@@ -4,23 +4,17 @@ ignoreip = 127.0.0.1/8 ::1
[recidive]
enabled = true
{{#nginx}}[nginx-http-auth]
enabled = true
logpath = /var/log/nginx/*error*.log
[nginx-botsearch]
enabled = true
logpath = /var/log/nginx/*access*.log
[wo-wordpress]
{{#ols}}[wo-wordpress]
enabled = true
filter = wo-wordpress
action = iptables-multiport[name="wo-wordpress", port="http,https"]
logpath = /var/log/nginx/*access*.log
logpath = /usr/local/lsws/logs/access.log
/usr/local/lsws/conf/vhosts/*/logs/ols.access_log
maxretry = 5
[nginx-forbidden]
[ols-forbidden]
enabled = true
filter = nginx-forbidden
action = iptables-multiport[name="nginx-forbidden", port="http,https"]
logpath = /var/log/nginx/*error*.log{{/nginx}}
action = iptables-multiport[name="ols-forbidden", port="http,https"]
logpath = /usr/local/lsws/logs/error.log
/usr/local/lsws/conf/vhosts/*/logs/ols.error_log{{/ols}}

View File

@@ -0,0 +1,9 @@
OpenLiteSpeed ({{version}}):
server_name {{server_name}}
max_connections {{max_connections}}
max_ssl_connections {{max_ssl_connections}}
keepalive_timeout {{keepalive_timeout}}
gzip_compress {{gzip_compress}}
brotli_compress {{brotli_compress}}
quic_enabled {{quic_enabled}}

View File

@@ -0,0 +1,16 @@
# WordOps (wo) Access Control - OpenLiteSpeed
# Protect locations using HTTP authentication or IP address
realm WordOpsAdmin {
userDB {
location /usr/local/lsws/conf/htpasswd-wo
}
}
context / {
realm WordOpsAdmin
authName "Restricted Area"
required valid-user
accessControl {
allow 127.0.0.1, ::1
}
}

View File

@@ -0,0 +1,104 @@
# WordOps Admin Backend Virtual Host - WordOps {{release}}
# Port {{port}}
docRoot {{webroot}}22222/htdocs
vhDomain _backend
enableGzip 1
enableBr 1
errorlog {{webroot}}22222/logs/ols.error_log {
useServer 0
logLevel ERROR
rollingSize 10M
}
accesslog {{webroot}}22222/logs/ols.access_log {
useServer 0
rollingSize 10M
keepDays 30
compressArchive 0
}
index {
useServer 0
indexFiles index.php, index.html, index.htm
autoIndex 1
}
# PHP handler
scripthandler {
add lsapi:lsphp{{default_php_short}} php
}
extprocessor lsphp{{default_php_short}} {
type lsapi
address uds://tmp/lshttpd/lsphp{{default_php_short}}.sock
maxConns 10
env PHP_LSAPI_CHILDREN=10
env LSAPI_AVOID_FORK=200M
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
autoStart 2
path /usr/local/lsws/lsphp{{default_php_short}}/bin/lsphp
backlog 100
instances 1
priority 0
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 1400
procHardLimit 1500
}
# Rewrite rules
rewrite {
enable 1
autoLoadHtaccess 1
rules <<<END_rules
RewriteRule ^(.*)$ /index.php?$1 [QSA,L]
END_rules
}
# HTTP Auth realm for backend
realm WordOpsBackend {
userDB {
location /usr/local/lsws/conf/htpasswd-wo
}
}
# Require auth for the whole backend
context / {
realm WordOpsBackend
authName "Restricted Area"
required valid-user
allowBrowse 1
addDefaultCharset off
}
# Netdata proxy context
context /netdata/ {
type proxy
handler netdata_backend
addDefaultCharset off
}
extprocessor netdata_backend {
type proxy
address 127.0.0.1:19999
maxConns 100
pcKeepAliveTimeout 60
initTimeout 60
retryTimeout 0
respBuffer 0
}
# SSL Configuration
vhssl {
keyFile /var/www/22222/cert/22222.key
certFile /var/www/22222/cert/22222.crt
sslProtocol 24
}
# Include custom configs
include {{webroot}}22222/conf/ols/*.conf

View File

@@ -0,0 +1,23 @@
# LSPHP {{php_version}} External App Configuration - WordOps {{release}}
# DO NOT MODIFY, ALL CHANGES WILL BE LOST AFTER AN WordOps (wo) UPDATE
extprocessor lsphp{{short_version}} {
type lsapi
address uds://tmp/lshttpd/lsphp{{short_version}}.sock
maxConns 10
env PHP_LSAPI_CHILDREN=10
env LSAPI_AVOID_FORK=200M
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
autoStart 2
path /usr/local/lsws/lsphp{{short_version}}/bin/lsphp
backlog 100
instances 1
priority 0
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 1400
procHardLimit 1500
}

View File

@@ -0,0 +1,208 @@
# OpenLiteSpeed Main Configuration - WordOps {{release}}
# DO NOT MODIFY, ALL CHANGES WILL BE LOST AFTER AN WordOps (wo) UPDATE
serverName {{server_name}}
user nobody
group nogroup
priority 0
inMemBufSize 60M
swappingDir /tmp/lshttpd/swap
autoFix503 1
gracefulRestartTimeout 300
mime conf/mime.properties
showVersionNumber 0
adminEmails root@localhost
errorlog /usr/local/lsws/logs/error.log {
logLevel DEBUG
debugLevel 0
rollingSize 10M
enableStderrLog 1
}
accesslog /usr/local/lsws/logs/access.log {
rollingSize 10M
keepDays 30
compressArchive 0
}
indexFiles index.php, index.html, index.htm
expires {
enableExpires 1
expiresByType image/*=A604800,text/css=A604800,application/x-javascript=A604800,application/javascript=A604800,font/*=A604800,application/x-font-ttf=A604800
}
tuning {
maxConnections 10000
maxSSLConnections 10000
connTimeout 300
maxKeepAliveReq 10000
keepAliveTimeout 5
sndBufSize 0
rcvBufSize 0
maxReqURLLen 32768
maxReqHeaderSize 65536
maxReqBodySize 2047M
maxDynRespHeaderSize 32768
maxDynRespSize 2047M
maxCachedFileSize 4096
totalInMemCacheSize 20M
maxMMapFileSize 256K
totalMMapCacheSize 40M
useSendfile 1
fileETag 28
enableGzipCompress 1
enableBrCompress 1
enableDynGzipCompress 1
gzipCompressLevel 6
brStaticCompressLevel 6
gzipAutoUpdateStatic 1
gzipStaticCompressLevel 6
gzipMaxFileSize 10M
gzipMinFileSize 300
enableQuic 1
quicShmDir /dev/shm
}
fileAccessControl {
followSymbolLink 1
checkSymbolLink 0
requiredPermissionMask 000
restrictedPermissionMask 000
}
perClientConnLimit {
staticReqPerSec 0
dynReqPerSec 0
outBandwidth 0
inBandwidth 0
softLimit 10000
hardLimit 10000
gracePeriod 15
banPeriod 300
}
CGIRLimit {
maxCGIInstances 20
minUID 11
minGID 10
priority 0
CPUSoftLimit 10
CPUHardLimit 50
memSoftLimit 1460M
memHardLimit 1470M
procSoftLimit 400
procHardLimit 450
}
accessDenyDir {
dir /
dir /etc/*
dir /dev/*
dir conf/*
dir admin/conf/*
}
scripthandler {
add lsapi:lsphp{{default_php_short}} php
}
railsDefaults {
maxConns 1
env LSAPI_MAX_IDLE=60
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
backlog 50
runOnStartUp 3
extMaxIdleTime 300
priority 3
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 500
procHardLimit 600
}
wsgiDefaults {
maxConns 5
env LSAPI_MAX_IDLE=60
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
backlog 50
runOnStartUp 3
extMaxIdleTime 300
priority 3
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 500
procHardLimit 600
}
nodeDefaults {
maxConns 5
env LSAPI_MAX_IDLE=60
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
backlog 50
runOnStartUp 3
extMaxIdleTime 300
priority 3
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 500
procHardLimit 600
}
module cache {
ls_enabled 1
checkPrivateCache 1
checkPublicCache 1
maxCacheObjSize 10000000
maxStaleAge 200
qsCache 1
reqCookieCache 1
respCookieCache 1
ignoreReqCacheCtrl 1
ignoreRespCacheCtrl 0
enableCache 0
expireInSeconds 3600
enablePrivateCache 0
privateExpireInSeconds 3600
}
# Listener for HTTP on port 80
listener Default {
address *:80
secure 0
}
# Listener for HTTPS on port 443
listener Secure {
address *:443
secure 1
keyFile /usr/local/lsws/conf/example.key
certFile /usr/local/lsws/conf/example.crt
sslProtocol 24
enableQuic 1
}
# Listener for backend on port 22222
listener Backend {
address *:{{backend_port}}
secure 1
keyFile /var/www/22222/cert/22222.key
certFile /var/www/22222/cert/22222.crt
sslProtocol 24
}
# Include external app definitions
include /usr/local/lsws/conf/lsphp*.conf
# Include virtual host mappings
include /usr/local/lsws/conf/vhosts/*/vhconf.conf

View File

@@ -0,0 +1,53 @@
# General Security .htaccess Rules - WordOps {{release}}
# DO NOT MODIFY, ALL CHANGES WILL BE LOST AFTER AN WordOps (wo) UPDATE
# Deny access to hidden files (except .well-known)
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule "(^|/)\.(?!well-known\/)" - [F]
</IfModule>
# Deny access to backup, log, and config files
<FilesMatch "\.(old|orig|original|php#|php~|php_bak|save|swo|aspx?|tpl|sh|bash|bak?|cfg|cgi|dll|exe|git|hg|ini|jsp|log|mdb|out|sql|svn|swp|tar|rdf|gz|zip|bz2|7z|pem|asc|conf|dump)$">
Order Deny,Allow
Deny from all
</FilesMatch>
# Deny access to readme, license, and similar files
<FilesMatch "(readme|license|example|README|LEGALNOTICE|INSTALLATION|CHANGELOG)\.(txt|html|md)$">
Order Deny,Allow
Deny from all
</FilesMatch>
# Cache static files
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/x-javascript "access plus 1 year"
ExpiresByType font/ttf "access plus 1 year"
ExpiresByType font/otf "access plus 1 year"
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
# CORS headers for static assets
<IfModule mod_headers.c>
<FilesMatch "\.(ttf|ttc|otf|eot|woff|woff2|font\.css|css|js|gif|png|jpe?g|svg|svgz|ico|webp)$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header set X-Frame-Options "SAMEORIGIN"
Header set X-Content-Type-Options "nosniff"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set X-Powered-By "WordOps"
</IfModule>

View File

@@ -0,0 +1,10 @@
# OpenLiteSpeed SSL Configuration - WordOps
# Domain: {{domain}}
vhssl {
keyFile {{ssl_live_path}}/{{domain}}/key.pem
certFile {{ssl_live_path}}/{{domain}}/fullchain.pem
certChain 1
sslProtocol 24
enableQuic 1
}

View File

@@ -0,0 +1,17 @@
# Virtual Host Mapping for {{site_name}} - WordOps {{release}}
virtualhost {{site_name}} {
vhRoot {{webroot}}
configFile {{vhost_conf_path}}
allowSymbolLink 1
enableScript 1
restrained 0
}
# Listener mappings
listener Default {
map {{site_name}} {{site_name}}{{#www_alias}}, www.{{site_name}}{{/www_alias}}
}
listener Secure {
map {{site_name}} {{site_name}}{{#www_alias}}, www.{{site_name}}{{/www_alias}}
}

View File

@@ -0,0 +1,122 @@
# OpenLiteSpeed Virtual Host Configuration - WordOps {{release}}
# Domain: {{site_name}}
docRoot {{webroot}}/htdocs
vhDomain {{site_name}}
vhAliases www.{{site_name}}
enableGzip 1
enableBr 1
errorlog {{webroot}}/logs/ols.error_log {
useServer 0
logLevel ERROR
rollingSize 10M
}
accesslog {{webroot}}/logs/ols.access_log {
useServer 0
rollingSize 10M
keepDays 30
compressArchive 0
}
index {
useServer 0
indexFiles {{^static}}index.php, {{/static}}index.html, index.htm
autoIndex 0
}
{{^static}}
# PHP handler via LSAPI
scripthandler {
add lsapi:lsphp{{php_short}} php
}
{{/static}}
# External app - LSPHP
extprocessor lsphp{{php_short}} {
type lsapi
address uds://tmp/lshttpd/lsphp{{php_short}}.sock
maxConns 10
env PHP_LSAPI_CHILDREN=10
env LSAPI_AVOID_FORK=200M
initTimeout 60
retryTimeout 0
pcKeepAliveTimeout 60
respBuffer 0
autoStart 2
path /usr/local/lsws/lsphp{{php_short}}/bin/lsphp
backlog 100
instances 1
priority 0
memSoftLimit 2047M
memHardLimit 2047M
procSoftLimit 1400
procHardLimit 1500
}
# LSCache module configuration
module cache {
ls_enabled 1
checkPrivateCache 1
checkPublicCache 1
maxCacheObjSize 10000000
maxStaleAge 200
qsCache 1
reqCookieCache 1
respCookieCache 1
ignoreReqCacheCtrl 1
ignoreRespCacheCtrl 0
{{#wp}}
enableCache 1
{{/wp}}
{{^wp}}
enableCache 0
{{/wp}}
expireInSeconds 3600
enablePrivateCache 0
privateExpireInSeconds 3600
}
# Rewrite rules
rewrite {
enable 1
autoLoadHtaccess 1
}
# Security context for wp-admin
context /wp-admin/ {
location {{webroot}}/htdocs/wp-admin/
allowBrowse 1
{{^static}}
addDefaultCharset off
phpIniOverride {
}
{{/static}}
}
# Security - deny hidden files
context exp:/(\.(?!well-known)) {
allowBrowse 0
}
# Let's Encrypt validation
context /.well-known/acme-challenge/ {
location /var/www/html/.well-known/acme-challenge/
allowBrowse 1
addDefaultCharset off
}
# Include per-site custom configurations
include {{webroot}}/conf/ols/*.conf
{{#ssl}}
# SSL Configuration
vhssl {
keyFile {{ssl_live_path}}/{{site_name}}/key.pem
certFile {{ssl_live_path}}/{{site_name}}/fullchain.pem
certChain 1
sslProtocol 24
enableQuic 1
}
{{/ssl}}

View File

@@ -0,0 +1,49 @@
# WordPress Common Security Rules - WordOps {{release}}
# DO NOT MODIFY, ALL CHANGES WILL BE LOST AFTER AN WordOps (wo) UPDATE
# This file is auto-generated and placed in the site's htdocs/.htaccess
# Limit access to wp-login.php to prevent brute force attacks
<Files wp-login.php>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} POST
RewriteCond %{HTTP_REFERER} !^https?://(.*)?{{site_name}} [NC]
RewriteRule .* - [F]
</IfModule>
</Files>
# Block xmlrpc.php except Jetpack IPs
<Files xmlrpc.php>
Order Deny,Allow
Deny from all
Allow from 122.248.245.244
Allow from 54.217.201.243
Allow from 54.232.116.4
Allow from 192.0.80.0/20
Allow from 192.0.96.0/20
Allow from 192.0.112.0/20
Allow from 195.234.108.0/22
</Files>
# Block wp-config.txt
<Files wp-config.txt>
Order Deny,Allow
Deny from all
</Files>
# Deny access to PHP files in uploads directory
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^wp-content/uploads/.*\.php$ - [F]
RewriteRule ^wp-content/uploads/edd/.*\.zip$ / [R=301,L]
</IfModule>
# Mitigate DoS attack with WordPress script concatenation
<Files "load-scripts.php">
Order Deny,Allow
Deny from all
</Files>
<Files "load-styles.php">
Order Deny,Allow
Deny from all
</Files>

View File

@@ -0,0 +1,227 @@
<?php
/**
* Plugin Name: WP Fort Knox
* Description: Enhanced WordPress security plugin that disables file modifications and plugin management from wp-admin while preserving WP-CLI functionality.
* Version: 2.0.0
* Author: WEFIXIT
* Network: true
*
* Security Features:
* - Defines DISALLOW_FILE_MODS constant to block file changes from wp-admin
* - Filters plugin installation, upload, update, and deletion capabilities at runtime (non-destructive)
* - Blocks creation of administrator users through wp-admin interface
* - Prevents role elevation to administrator outside of WP-CLI
* - Hides administrator role from user role dropdown in wp-admin
* - Displays admin notices to inform users about restrictions
* - Preserves WP-CLI functionality for all operations
* - Can be disabled temporarily via constant or filter
*
* This is a Must-Use plugin - place directly in /wp-content/mu-plugins/
*
* Temporary Disable:
* Add to wp-config.php: define('WP_FORT_KNOX_DISABLED', true);
* Or use filter: add_filter('wp_fort_knox_disabled', '__return_true');
*
* WP-CLI Commands for Administrative Tasks:
*
* User Management:
* wp user create admin admin@example.com --role=administrator --user_pass=secure_password
* wp user set-role username administrator
* wp user list --fields=ID,user_login,roles
*
* Plugin Management:
* wp plugin install plugin-name --activate
* wp plugin update plugin-name
* wp plugin update --all
* wp plugin list
* wp plugin deactivate plugin-name
* wp plugin delete plugin-name
*
* Theme Management:
* wp theme install theme-name --activate
* wp theme update theme-name
* wp theme update --all
* wp theme list
*
* Core Updates:
* wp core update
* wp core update --version=6.4.1
* wp core check-update
*
* @package WPFortKnox
* @since 1.0.0
* @version 2.0.0
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class WP_Fort_Knox {
private $managed_capabilities = [
'install_plugins',
'upload_plugins',
'update_plugins',
'delete_plugins'
];
public function __construct() {
// Check if disabled
if ( $this->is_disabled() ) {
return;
}
// Apply security measures
$this->apply_security();
}
/**
* Check if plugin should be disabled
*/
private function is_disabled() {
// Always allow WP-CLI
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return true;
}
// Check for disable constant
if ( defined( 'WP_FORT_KNOX_DISABLED' ) && WP_FORT_KNOX_DISABLED ) {
return true;
}
// Allow filter for programmatic control
if ( apply_filters( 'wp_fort_knox_disabled', false ) ) {
return true;
}
return false;
}
/**
* Apply all security measures
*/
private function apply_security() {
// Block file modifications
if ( ! defined( 'DISALLOW_FILE_MODS' ) ) {
define( 'DISALLOW_FILE_MODS', true );
}
// Remove plugin capabilities at runtime (non-destructive)
add_filter( 'user_has_cap', [ $this, 'filter_capabilities' ], 999, 4 );
// Hide administrator role from user creation/edit screens
add_filter( 'editable_roles', [ $this, 'hide_administrator_role' ] );
// Block admin user creation via wp-admin
add_filter( 'pre_insert_user_data', [ $this, 'block_admin_creation' ], 10, 3 );
// Prevent role elevation to administrator
add_action( 'set_user_role', [ $this, 'prevent_admin_elevation' ], 10, 3 );
// Show notice on plugins page
add_action( 'admin_notices', [ $this, 'show_admin_notice' ] );
}
/**
* Filter user capabilities at runtime
*/
public function filter_capabilities( $allcaps, $caps, $args, $user ) {
// Only filter for non-CLI requests
foreach ( $this->managed_capabilities as $cap ) {
if ( isset( $allcaps[ $cap ] ) ) {
$allcaps[ $cap ] = false;
}
}
return $allcaps;
}
/**
* Hide administrator role from dropdowns
*/
public function hide_administrator_role( $roles ) {
unset( $roles['administrator'] );
return $roles;
}
/**
* Block admin user creation
*/
public function block_admin_creation( $data, $update, $user_id ) {
// Allow updates to existing users
if ( $update ) {
return $data;
}
// Block new admin creation
if ( isset( $data['role'] ) && $data['role'] === 'administrator' ) {
wp_die(
'Administrator account creation is disabled. Use WP-CLI: wp user create username email@example.com --role=administrator',
'Security Policy',
[ 'back_link' => true ]
);
}
return $data;
}
/**
* Prevent elevation to administrator role
*/
public function prevent_admin_elevation( $user_id, $role, $old_roles ) {
// If trying to add administrator role
if ( $role === 'administrator' && ! in_array( 'administrator', $old_roles ) ) {
// Revert the change
$user = get_userdata( $user_id );
if ( $user ) {
$user->remove_role( 'administrator' );
$user->add_role( $old_roles[0] ?? 'subscriber' );
// Log the attempt
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf(
'[WP Fort Knox] Blocked administrator elevation for user %s (ID: %d)',
$user->user_login,
$user_id
) );
}
}
}
}
/**
* Show admin notice on relevant pages
*/
public function show_admin_notice() {
// Only show to users who would normally have capability
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
// Show on plugins page
if ( $screen && $screen->id === 'plugins' ) {
?>
<div class="notice notice-info">
<p><strong>WP Fort Knox:</strong> Plugin management is disabled in wp-admin. Use WP-CLI for all plugin operations. To disable temporarily, contact support.</p>
</div>
<?php
}
// Show on users page when trying to add new
if ( $screen && $screen->id === 'user' && $screen->action === 'add' ) {
?>
<div class="notice notice-warning">
<p><strong>WP Fort Knox:</strong> Administrator role creation is disabled. Use WP-CLI for all user operations. To disable temporarily, contact support.</p>
</div>
<?php
}
}
}
// Initialize - no activation hooks needed for mu-plugins
new WP_Fort_Knox();