"); anything else in the * zone, including another site's csdt rules on a shared zone, is never changed or deleted. * A provider error stops the run. The block lists failing to read stops it before any call * (fails open: nothing at the edge changes). Never on the front-end request path, never on a * standby, never retried in-request. * * @package CloudScale_Cyber_DevTools */ defined( 'ABSPATH' ) || exit; /** * Selection, guards, reconcile, schedule and the panel. */ class CSDT_Edge_Waf { /** off | dry | push. Not autoloaded: read by cron and admin screens only. */ const OPTION_MODE = 'csdt_edge_waf_mode'; const MODE_OFF = 'off'; const MODE_DRY = 'dry'; const MODE_PUSH = 'push'; /** Off until the owner switches it on, and then Dry run first. */ const DEFAULT_MODE = self::MODE_OFF; /** {at, source, mode, ok, error, wrote, left, plan}. Not autoloaded. */ const OPTION_STATE = 'csdt_edge_waf_state'; /** {hash => last seen}: administrators' addresses, hashed. Written on an admin login only. */ const OPTION_ADMIN_IPS = 'csdt_edge_admin_ips'; /** Five-minute reconcile. Booked only while the mode is not Off, never on a standby. */ const CRON_HOOK = 'csdt_edge_waf_reconcile'; /** Newest candidates kept in the desired set. */ const MAX_RULES = 200; /** API writes per run; the rest wait for the next run. */ const MAX_WRITES = 20; /** An automatic block is escalated from this offence on: the ladder's second rung. */ const ESCALATED_OFFENCES = 2; /** Automatic block signals that count as high severity on their own. */ const HIGH_SEVERITY_SIGNALS = array( 'firewall' ); /** An administrator's address stays protected this long after their last login. */ const ADMIN_WINDOW = 30 * DAY_IN_SECONDS; /** Most administrator address hashes kept. */ const ADMIN_MAX = 100; /** A failing run alerts at most once in this window. */ const ALERT_WINDOW = HOUR_IN_SECONDS; /** Rows of each plan list kept in the stored state. */ const STATE_ROWS = 200; /** * Ranges never pushed beyond what filter_var() already calls private or reserved (which * covers RFC 1918, loopback, link-local, 0/8, 240/4, ::1, ::, IPv4-mapped, fc00::/7 and * fe80::/10): shared address space, special-purpose, documentation, benchmarking, * multicast, translated and 6to4 IPv6, discard-only, ORCHID and documentation IPv6. */ const RESERVED = array( '100.64.0.0/10', '192.0.0.0/24', // IANA special-purpose: protocol assignments. '192.0.2.0/24', '192.88.99.0/24', // IANA special-purpose: deprecated 6to4 relay anycast. '198.18.0.0/15', // IANA special-purpose: benchmarking. '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '64:ff9b::/96', '64:ff9b:1::/48', '100::/64', '2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20', 'fc00::/7', 'fe80::/10', 'ff00::/8', ); /** * Test-only clock. Null in production. * * @var int|null */ public static $now_override = null; /** * Test-only provider. Null in production, which uses Cloudflare. * * @var CSDT_Edge_Provider|null */ public static $provider_override = null; /** Current time. */ private static function now(): int { return null !== self::$now_override ? self::$now_override : time(); } // ── Mode and provider ────────────────────────────────────────────── /** The mode, validated. */ public static function mode(): string { $mode = (string) get_option( self::OPTION_MODE, self::DEFAULT_MODE ); return in_array( $mode, array( self::MODE_OFF, self::MODE_DRY, self::MODE_PUSH ), true ) ? $mode : self::MODE_OFF; } /** Mode labels, in the order offered. */ public static function mode_labels(): array { return array( self::MODE_OFF => __( 'Off', 'cloudscale-cyber-devtools' ), self::MODE_DRY => __( 'Dry run (show the plan, change nothing)', 'cloudscale-cyber-devtools' ), self::MODE_PUSH => __( 'Push (add and remove rules at the edge)', 'cloudscale-cyber-devtools' ), ); } /** This site's ownership mark, "csdt:" and 8 hex characters of a hash of the home URL. */ public static function mark(): string { return 'csdt:' . substr( hash( 'sha256', rtrim( (string) home_url(), '/' ) ), 0, 8 ); } /** The edge provider. Cloudflare only for now. */ public static function provider(): CSDT_Edge_Provider { return null !== self::$provider_override ? self::$provider_override : new CSDT_Edge_Cloudflare( self::mark() ); } /** Whether this copy is a standby. */ private static function is_standby(): bool { return class_exists( 'CSDT_Site_Role' ) && CSDT_Site_Role::is_standby(); } // ── Addresses ────────────────────────────────────────────────────── /** * One address in canonical text form, '' when it is not a single valid address. * * @param string $ip Address. * @return string */ public static function canonical( string $ip ): string { $ip = trim( $ip ); if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return ''; } $bin = inet_pton( $ip ); $out = false === $bin ? false : inet_ntop( $bin ); return is_string( $out ) ? strtolower( $out ) : ''; } /** Whether an address is private, loopback, link-local or otherwise reserved. */ public static function is_reserved( string $ip ): bool { if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return true; } foreach ( self::RESERVED as $cidr ) { if ( CSDT_DevTools::ip_matches_cidr( $ip, $cidr ) ) { return true; } } return false; } /** The hash an administrator's address is stored under. */ private static function admin_hash( string $ip ): string { return (string) wp_hash( 'csdt-edge-admin|' . $ip ); } /** * Hooked to wp_login (2 args): remembers an administrator's address, hashed, for 30 days. * The login already writes (session token, activity log), so this adds no write to a * request that would not have written. * * @param mixed $_login Username, unused. * @param mixed $user WP_User. * @return void */ public static function on_login( $_login = '', $user = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- wp_login passes the username first try { if ( ! is_object( $user ) || ! method_exists( $user, 'has_cap' ) || ! $user->has_cap( 'manage_options' ) ) { return; } $ip = self::canonical( (string) CSDT_DevTools::get_client_ip() ); if ( '' === $ip ) { return; } self::remember_admin_ip( $ip ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Stores one administrator address hash, pruning old ones. * * @param string $ip Canonical address. * @return void */ public static function remember_admin_ip( string $ip ): void { $now = self::now(); $seen = get_option( self::OPTION_ADMIN_IPS, array() ); $seen = is_array( $seen ) ? $seen : array(); foreach ( $seen as $h => $at ) { if ( $now - (int) $at > self::ADMIN_WINDOW ) { unset( $seen[ $h ] ); } } $seen[ self::admin_hash( $ip ) ] = $now; arsort( $seen ); update_option( self::OPTION_ADMIN_IPS, array_slice( $seen, 0, self::ADMIN_MAX, true ), false ); } /** Whether an administrator logged in from this address in the last 30 days. */ public static function is_admin_ip( string $ip ): bool { $seen = get_option( self::OPTION_ADMIN_IPS, array() ); $at = is_array( $seen ) ? (int) ( $seen[ self::admin_hash( $ip ) ] ?? 0 ) : 0; return $at > 0 && self::now() - $at <= self::ADMIN_WINDOW; } /** The site's own server addresses: what it has seen itself arrive from, and SERVER_ADDR. */ private static function own_ips(): array { $ips = class_exists( 'CSDT_Auto_Block' ) ? CSDT_Auto_Block::own_public_ips() : array(); if ( isset( $_SERVER['SERVER_ADDR'] ) ) { $ips[] = CSDT_Request::server( 'SERVER_ADDR', 64 ); } return array_values( array_filter( array_map( array( __CLASS__, 'canonical' ), $ips ) ) ); } /** * Why an address must never be pushed, or '' when it may be. * * @param string $ip Canonical address. * @param string $request_ip The admin's own address when the run is an admin action, else ''. * @return string */ public static function guard( string $ip, string $request_ip = '' ): string { if ( '' === $ip ) { return __( 'not a single valid address', 'cloudscale-cyber-devtools' ); } if ( self::is_reserved( $ip ) ) { return __( 'private, loopback, link-local or reserved range', 'cloudscale-cyber-devtools' ); } if ( '' !== $request_ip && self::canonical( $request_ip ) === $ip ) { return __( 'your own address (the admin running this)', 'cloudscale-cyber-devtools' ); } if ( CSDT_DevTools::ip_is_trusted_proxy( $ip ) ) { return __( 'Cloudflare\'s own range or a trusted proxy', 'cloudscale-cyber-devtools' ); } if ( in_array( $ip, self::own_ips(), true ) ) { return __( 'this site\'s own server address', 'cloudscale-cyber-devtools' ); } if ( self::is_admin_ip( $ip ) ) { return __( 'an administrator logged in from it in the last 30 days', 'cloudscale-cyber-devtools' ); } $allow = class_exists( 'CSDT_Auto_Block' ) ? (string) get_option( CSDT_Auto_Block::OPTION_ALLOWLIST, '' ) : ''; foreach ( preg_split( '/[\s,]+/', $allow, -1, PREG_SPLIT_NO_EMPTY ) as $entry ) { if ( self::canonical( $entry ) === $ip || ( false !== strpos( $entry, '/' ) && CSDT_DevTools::ip_matches_cidr( $ip, $entry ) ) ) { return __( 'on the Auto-Block never-block list', 'cloudscale-cyber-devtools' ); } } // The rest of Auto-Block's never-block rules (a live administrator session, above all). if ( class_exists( 'CSDT_Auto_Block' ) && CSDT_Auto_Block::is_exempt( $ip ) ) { return __( 'exempt from Auto-Block (for example a live administrator session)', 'cloudscale-cyber-devtools' ); } return ''; } // ── The desired set ──────────────────────────────────────────────── /** * Candidates from the block stores, before the guards: ip => {reason, at}. * Null when a store cannot be read, which stops the run before any call. * * @return array|null */ public static function candidates(): ?array { if ( ! class_exists( 'CSDT_Auto_Block' ) ) { return null; } $now = self::now(); $list = get_option( 'csdt_ip_blocklist', array() ); $offs = get_option( CSDT_Auto_Block::OPTION_OFFENCES, array() ); if ( ! is_array( $list ) || ! is_array( $offs ) ) { return null; } $out = array(); foreach ( $list as $raw_ip => $row ) { $ip = self::canonical( (string) $raw_ip ); $e = CSDT_Auto_Block::normalise_entry( $row, (string) $raw_ip ); if ( '' === $ip || CSDT_Auto_Block::entry_has_expired( $e, $now ) ) { continue; } $reason = ''; if ( CSDT_Auto_Block::SOURCE_MANUAL === $e['source'] ) { $reason = 'manual'; } elseif ( in_array( $e['signal'], self::HIGH_SEVERITY_SIGNALS, true ) ) { $reason = 'firewall attack'; } elseif ( (int) ( $offs[ (string) $raw_ip ] ?? 0 ) >= self::ESCALATED_OFFENCES ) { $reason = 'repeat offender'; } if ( '' !== $reason ) { $out[ $ip ] = array( 'reason' => $reason, 'at' => (int) $e['blocked_at'], ); } } // A proven fake crawler is refused on every request while the check is in Block mode. if ( class_exists( 'CSDT_Crawler_Verify' ) && CSDT_Crawler_Verify::MODE_BLOCK === CSDT_Crawler_Verify::mode() ) { $verdicts = get_option( CSDT_Crawler_Verify::OPTION_VERDICTS, array() ); if ( ! is_array( $verdicts ) ) { return null; } foreach ( $verdicts as $raw_ip => $v ) { $ip = self::canonical( (string) $raw_ip ); if ( '' === $ip || isset( $out[ $ip ] ) || 'fake' !== CSDT_Crawler_Verify::verdict( (string) $raw_ip, $now ) ) { continue; } $out[ $ip ] = array( 'reason' => 'fake crawler', 'at' => (int) ( $v['t'] ?? 0 ), ); } } return $out; } /** * The desired set after the guards, newest MAX_RULES, and what the guards refused. * * @param string $request_ip The admin's own address when the run is an admin action. * @return array{want:array,refused:array}|null */ public static function desired( string $request_ip = '' ): ?array { $cand = self::candidates(); if ( null === $cand ) { return null; } uasort( $cand, static fn( $a, $b ) => $b['at'] <=> $a['at'] ); $want = array(); $refused = array(); foreach ( $cand as $ip => $c ) { $why = self::guard( (string) $ip, $request_ip ); if ( '' !== $why ) { $refused[] = array( 'ip' => (string) $ip, 'why' => $why ); continue; } if ( count( $want ) < self::MAX_RULES ) { $want[ (string) $ip ] = $c; } } return array( 'want' => $want, 'refused' => $refused ); } /** * The plan: desired set against what is ours at the edge. * * @param array $desired desired(). * @param array $ours list_ours(). * @return array{add:array,remove:array,present:array,refused:array} */ public static function plan( array $desired, array $ours ): array { $at_edge = array(); $remove = array(); foreach ( $ours as $rule ) { if ( isset( $at_edge[ $rule['ip'] ] ) || ! isset( $desired['want'][ $rule['ip'] ] ) ) { $remove[] = array( 'ip' => $rule['ip'], 'id' => $rule['id'] ); // No longer blocked here, or a duplicate. continue; } $at_edge[ $rule['ip'] ] = true; } $add = array(); foreach ( $desired['want'] as $ip => $c ) { if ( ! isset( $at_edge[ $ip ] ) ) { $add[] = array( 'ip' => (string) $ip, 'reason' => $c['reason'], 'at' => $c['at'] ); } } return array( 'add' => $add, 'remove' => $remove, 'present' => array_keys( $at_edge ), 'refused' => $desired['refused'], ); } // ── Reconcile ────────────────────────────────────────────────────── /** * One reconcile. Off: nothing. Dry: the read call and the plan. Push: up to MAX_WRITES * changes, removals first (a lifted block should stop refusing at the edge soonest). * * @param string $source 'cron' or 'admin'. * @param string $request_ip The admin's own address when $source is 'admin'. * @param bool $write Whether Push may write on this call (false: plan only). * @return array{ok:bool,ran:bool,error:string,wrote:int,left:int,plan:?array} */ public static function run( string $source = 'cron', string $request_ip = '', bool $write = true ): array { $out = array( 'ok' => false, 'ran' => false, 'error' => '', 'wrote' => 0, 'left' => 0, 'plan' => null ); $mode = self::mode(); if ( self::MODE_OFF === $mode ) { $out['error'] = __( 'Edge WAF Push is off. Nothing was read or changed.', 'cloudscale-cyber-devtools' ); return $out; } if ( self::is_standby() ) { $out['error'] = __( 'This is a standby copy. It never pushes to the edge.', 'cloudscale-cyber-devtools' ); return $out; } $out['ran'] = true; $desired = null; try { $desired = self::desired( $request_ip ); } catch ( \Throwable $e ) { $desired = null; } if ( null === $desired ) { // Fails open: nothing at the edge is touched when the desired set is unknown. $out['error'] = __( 'The block lists could not be read, so nothing at the edge was read or changed.', 'cloudscale-cyber-devtools' ); return self::finish( $out, $source, $mode ); } $p = self::provider(); $ours = $p->list_ours(); if ( null === $ours ) { $out['error'] = $p->last_error(); self::alert_failure( $out['error'] ); return self::finish( $out, $source, $mode ); } $plan = self::plan( $desired, $ours ); $out['plan'] = $plan; $out['ok'] = true; if ( self::MODE_PUSH !== $mode || ! $write ) { return self::finish( $out, $source, $mode ); } $mark = self::mark(); $jobs = array(); foreach ( $plan['remove'] as $r ) { $jobs[] = array( 'remove', $r ); } foreach ( $plan['add'] as $a ) { $jobs[] = array( 'add', $a ); } foreach ( $jobs as $i => $job ) { if ( $out['wrote'] >= self::MAX_WRITES ) { $out['left'] = count( $jobs ) - $i; break; } $res = 'remove' === $job[0] ? $p->unblock( (string) $job[1]['id'] ) : $p->block( (string) $job[1]['ip'], $mark . ' ' . $job[1]['reason'] ); if ( ! $res['ok'] ) { $out['ok'] = false; $out['error'] = $res['error']; $out['left'] = count( $jobs ) - $i; self::alert_failure( $res['error'] ); return self::finish( $out, $source, $mode ); } ++$out['wrote']; } return self::finish( $out, $source, $mode ); } /** * Stores the outcome for the panel and returns it. * * @param array $out run() result. * @param string $source cron or admin. * @param string $mode Mode it ran in. * @return array */ private static function finish( array $out, string $source, string $mode ): array { $plan = $out['plan']; if ( is_array( $plan ) ) { foreach ( array( 'add', 'remove', 'present', 'refused' ) as $k ) { $plan[ 'n_' . $k ] = count( $plan[ $k ] ); $plan[ $k ] = array_slice( $plan[ $k ], 0, self::STATE_ROWS ); } } update_option( self::OPTION_STATE, array( 'at' => self::now(), 'source' => $source, 'mode' => $mode, 'ok' => $out['ok'], 'error' => $out['error'], 'wrote' => $out['wrote'], 'left' => $out['left'], 'plan' => $plan, ), false ); return $out; } /** * One SECURITY alert per hour while the push fails. * * @param string $error The provider's words. * @return void */ private static function alert_failure( string $error ): void { if ( ! class_exists( 'CSDT_Alert_Window' ) || ! CSDT_Alert_Window::claim( 'edge_waf_fail', self::ALERT_WINDOW, self::now() ) ) { return; } if ( class_exists( 'CSDT_Alert_Events' ) ) { CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, "CSDT: Edge WAF Push failed\n\nBlocked addresses are not reaching the edge. " . $error . "\n\nThis repeats at most once an hour while it fails. Firewall tab, Edge WAF Push.", 'Edge WAF Push', 'warning' ); } } /** The stored outcome of the last run, or null. */ public static function state(): ?array { $s = get_option( self::OPTION_STATE, null ); return is_array( $s ) ? $s : null; } // ── Schedule ─────────────────────────────────────────────────────── /** * Books the five-minute reconcile while the mode is Dry or Push on a primary; clears it * otherwise. Off books nothing. * * @return void */ public static function sync_schedule(): void { if ( ! function_exists( 'wp_next_scheduled' ) ) { return; } $hook = self::CRON_HOOK; $scheduled = wp_next_scheduled( $hook ); if ( self::MODE_OFF !== self::mode() && ! self::is_standby() ) { if ( ! $scheduled ) { wp_schedule_event( time() + 60, 'csdt_every_5min', $hook ); } return; } if ( $scheduled ) { wp_clear_scheduled_hook( $hook ); } } /** WP-Cron callback. */ public static function cron_run(): void { try { self::run( 'cron' ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Removes every rule of ours at the edge, at most 50 a click. Never automatic. * * @return array{ok:bool,removed:int,left:int,error:string} */ public static function remove_all(): array { $out = array( 'ok' => false, 'removed' => 0, 'left' => 0, 'error' => '' ); if ( self::is_standby() ) { $out['error'] = __( 'This is a standby copy. It never changes the edge.', 'cloudscale-cyber-devtools' ); return $out; } $p = self::provider(); $ours = $p->list_ours(); if ( null === $ours ) { $out['error'] = $p->last_error(); return $out; } foreach ( $ours as $i => $rule ) { if ( $out['removed'] >= 50 ) { $out['left'] = count( $ours ) - $i; break; } $r = $p->unblock( $rule['id'] ); if ( ! $r['ok'] ) { $out['error'] = $r['error']; $out['left'] = count( $ours ) - $i; return $out; } ++$out['removed']; } $out['ok'] = true; return $out; } // ── Admin ────────────────────────────────────────────────────────── /** Registers the login hook and the AJAX actions. */ public static function register(): void { add_action( 'wp_login', array( __CLASS__, 'on_login' ), 10, 2 ); // Show plan and Test connection only read (Test adds and at once removes one // documentation-address rule), so a test session may use them. Save (which can // switch Push on), Run now (writes in Push) and Remove all are refused it. CSDT_Ajax::register( 'wp_ajax_csdt_edge_waf_plan', array( __CLASS__, 'ajax_plan' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_edge_waf_test', array( __CLASS__, 'ajax_test' ) ); CSDT_Ajax::register( 'wp_ajax_csdt_edge_waf_save', array( __CLASS__, 'ajax_save' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_edge_waf_run', array( __CLASS__, 'ajax_run' ), array( 'refuse_test_session' => true ) ); CSDT_Ajax::register( 'wp_ajax_csdt_edge_waf_remove_all', array( __CLASS__, 'ajax_remove_all' ), array( 'refuse_test_session' => true ) ); } /** * Show plan: the plan without writing. Off computes the local side only, with no call. * * @return void */ public static function ajax_plan(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $ip = (string) CSDT_DevTools::get_client_ip(); if ( self::MODE_OFF === self::mode() ) { $d = self::desired( $ip ); $msg = null === $d ? __( 'The block lists could not be read.', 'cloudscale-cyber-devtools' ) : __( 'Edge WAF Push is off, so Cloudflare was not asked. This is what Dry run would compare.', 'cloudscale-cyber-devtools' ); $plan = null === $d ? null : array( 'add' => self::plan( $d, array() )['add'], 'remove' => array(), 'present' => array(), 'refused' => $d['refused'] ); wp_send_json_success( array( 'ok' => null !== $d, 'msg' => $msg, 'html' => self::plan_html( $plan, false ) ) ); } $r = self::run( 'admin', $ip, false ); wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $r['ok'] ? __( 'Plan read. Nothing was changed.', 'cloudscale-cyber-devtools' ) : $r['error'], 'html' => self::status_html() . self::plan_html( $r['plan'], true ), ) ); } /** Test connection. */ public static function ajax_test(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } if ( self::is_standby() ) { wp_send_json_success( array( 'ok' => false, 'msg' => __( 'This is a standby copy. It never changes the edge.', 'cloudscale-cyber-devtools' ) ) ); } $r = self::provider()->test(); wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $r['ok'] ? __( 'Connected. The token can read, add and remove IP Access Rules on this zone.', 'cloudscale-cyber-devtools' ) : $r['error'], ) ); } /** * The mode for a requested mode: switching on from Off always starts in Dry run. * * @param string $current Mode now. * @param string $requested Mode asked for. * @return string */ public static function next_mode( string $current, string $requested ): string { if ( ! in_array( $requested, array( self::MODE_OFF, self::MODE_DRY, self::MODE_PUSH ), true ) ) { return $current; } return ( self::MODE_OFF === $current && self::MODE_PUSH === $requested ) ? self::MODE_DRY : $requested; } /** Saves the mode. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $current = self::mode(); $requested = sanitize_key( wp_unslash( $_POST['mode'] ?? '' ) ); $mode = self::next_mode( $current, $requested ); update_option( self::OPTION_MODE, $mode, false ); self::sync_schedule(); if ( self::MODE_DRY === $mode && self::MODE_PUSH === $requested ) { $msg = __( 'Saved in Dry run: switching on always starts there. Read the plan, then choose Push.', 'cloudscale-cyber-devtools' ); } elseif ( self::MODE_OFF === $mode && self::MODE_PUSH === $current ) { $msg = __( 'Saved. Off: nothing more is pushed, and the rules already at the edge stay. Use Remove everything we pushed to take them away.', 'cloudscale-cyber-devtools' ); } elseif ( self::MODE_OFF === $mode ) { $msg = __( 'Saved. Edge WAF Push is off: no calls and no schedule.', 'cloudscale-cyber-devtools' ); } else { /* translators: %s: mode label */ $msg = sprintf( __( 'Saved: %s. The reconcile runs every 5 minutes.', 'cloudscale-cyber-devtools' ), self::mode_labels()[ $mode ] ); } wp_send_json_success( array( 'ok' => true, 'msg' => $msg, 'mode' => $mode, 'html' => self::status_html() ) ); } /** Run now: one reconcile, which writes only in Push. */ public static function ajax_run(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::run( 'admin', (string) CSDT_DevTools::get_client_ip() ); if ( ! $r['ran'] || ! $r['ok'] ) { $msg = $r['error']; } elseif ( self::MODE_PUSH !== self::mode() ) { $msg = __( 'Dry run: the plan was read and nothing was changed.', 'cloudscale-cyber-devtools' ); } else { /* translators: 1: changes made, 2: changes left for the next run */ $msg = sprintf( __( 'Pushed %1$d change(s). %2$d left for the next run.', 'cloudscale-cyber-devtools' ), $r['wrote'], $r['left'] ); } wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $msg, 'html' => self::status_html() . self::plan_html( $r['plan'], true ) ) ); } /** Remove everything we pushed. */ public static function ajax_remove_all(): void { check_ajax_referer( CSDT_DevTools::SECURITY_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::remove_all(); if ( $r['ok'] ) { /* translators: 1: rules removed, 2: rules left */ $msg = sprintf( __( 'Removed %1$d of our rules. %2$d left (click again). Rules that are not ours were not touched.', 'cloudscale-cyber-devtools' ), $r['removed'], $r['left'] ); } else { /* translators: 1: rules removed, 2: the error */ $msg = sprintf( __( 'Removed %1$d, then stopped: %2$s', 'cloudscale-cyber-devtools' ), $r['removed'], $r['error'] ); } wp_send_json_success( array( 'ok' => $r['ok'], 'msg' => $msg ) ); } /** When, in words. */ private static function when( int $ts ): string { if ( $ts <= 0 ) { return __( 'never', 'cloudscale-cyber-devtools' ); } return function_exists( 'wp_date' ) ? (string) wp_date( 'Y-m-d H:i', $ts ) : gmdate( 'Y-m-d H:i', $ts ) . ' UTC'; } /** * The status block, escaped, from stored data only (no request). * * @return string */ public static function status_html(): string { $mode = self::mode(); $st = self::state(); $token = CSDT_Secrets::state( CSDT_CF_Comment_Rule::OPTION_TOKEN ); $zone = '' !== trim( (string) get_option( CSDT_CF_Comment_Rule::OPTION_ZONE, '' ) ); $cred = array( CSDT_Secrets::STATE_SET => __( 'token set (stored encrypted)', 'cloudscale-cyber-devtools' ), CSDT_Secrets::STATE_UNSET => __( 'no token saved', 'cloudscale-cyber-devtools' ), CSDT_Secrets::STATE_UNREADABLE => __( 'token unreadable on this site, enter it again', 'cloudscale-cyber-devtools' ), ); $pill = array( self::MODE_OFF => array( '#f1f5f9', '#334155', __( 'Off', 'cloudscale-cyber-devtools' ) ), self::MODE_DRY => array( '#fef3c7', '#92400e', __( 'Dry run', 'cloudscale-cyber-devtools' ) ), self::MODE_PUSH => array( '#dcfce7', '#166534', __( 'Push', 'cloudscale-cyber-devtools' ) ), )[ $mode ]; if ( self::is_standby() ) { $pill = array( '#e0e7ff', '#3730a3', __( 'Standby copy', 'cloudscale-cyber-devtools' ) ); } $plan = is_array( $st['plan'] ?? null ) ? $st['plan'] : null; $rows = array( array( __( 'Provider', 'cloudscale-cyber-devtools' ), 'Cloudflare, IP Access Rules: ' . ( $zone ? __( 'zone ID set', 'cloudscale-cyber-devtools' ) : __( 'no zone ID saved', 'cloudscale-cyber-devtools' ) ) . ', ' . ( $cred[ $token ] ?? $cred[ CSDT_Secrets::STATE_UNSET ] ) ), array( __( 'Token permission', 'cloudscale-cyber-devtools' ), __( 'Zone > Firewall Services > Edit (Test connection proves it)', 'cloudscale-cyber-devtools' ) ), array( __( 'Last run', 'cloudscale-cyber-devtools' ), null === $st ? __( 'never', 'cloudscale-cyber-devtools' ) : self::when( (int) $st['at'] ) . ' (' . (string) $st['source'] . ', ' . (string) $st['mode'] . ( (int) $st['wrote'] > 0 ? ', ' . (int) $st['wrote'] . ' written' : '' ) . ( (int) $st['left'] > 0 ? ', ' . (int) $st['left'] . ' left' : '' ) . ')' ), array( __( 'Last error', 'cloudscale-cyber-devtools' ), null === $st || '' === (string) $st['error'] ? __( 'none', 'cloudscale-cyber-devtools' ) : (string) $st['error'] ), array( __( 'Plan', 'cloudscale-cyber-devtools' ), null === $plan ? __( 'not computed yet', 'cloudscale-cyber-devtools' ) : sprintf( '%d to add, %d to remove, %d already at the edge, %d refused by a guard', (int) $plan['n_add'], (int) $plan['n_remove'], (int) $plan['n_present'], (int) $plan['n_refused'] ) ), ); $h = '' . esc_html( $pill[2] ) . ''; $h .= ''; foreach ( $rows as $r ) { $h .= ''; } return $h . '
' . esc_html( $r[0] ) . '' . esc_html( $r[1] ) . '
'; } /** * The plan tables, escaped. * * @param array|null $plan plan(), or null. * @param bool $edge_read Whether the edge was read (else "already at edge" is unknown). * @return string */ public static function plan_html( ?array $plan, bool $edge_read ): string { if ( null === $plan ) { return ''; } $sections = array( array( __( 'To add', 'cloudscale-cyber-devtools' ), array_map( static fn( $r ) => array( $r['ip'], $r['reason'] ), $plan['add'] ) ), array( __( 'To remove (no longer blocked here)', 'cloudscale-cyber-devtools' ), array_map( static fn( $r ) => array( $r['ip'], '' ), $plan['remove'] ) ), array( __( 'Already at the edge', 'cloudscale-cyber-devtools' ), $edge_read ? array_map( static fn( $ip ) => array( $ip, '' ), $plan['present'] ) : null ), array( __( 'Refused by a guard', 'cloudscale-cyber-devtools' ), array_map( static fn( $r ) => array( $r['ip'], $r['why'] ), $plan['refused'] ) ), ); $h = '
'; foreach ( $sections as $s ) { $h .= '

' . esc_html( $s[0] ) . ' (' . esc_html( null === $s[1] ? '?' : (string) count( $s[1] ) ) . ')

'; if ( null === $s[1] ) { $h .= '

' . esc_html__( 'Not read: the mode is Off, so Cloudflare was not asked.', 'cloudscale-cyber-devtools' ) . '

'; continue; } if ( array() === $s[1] ) { $h .= '

' . esc_html__( 'None.', 'cloudscale-cyber-devtools' ) . '

'; continue; } $h .= ''; foreach ( array_slice( $s[1], 0, self::STATE_ROWS ) as $row ) { $h .= ''; } $h .= '
' . esc_html( (string) $row[0] ) . '' . esc_html( (string) $row[1] ) . '
'; } return $h . '
'; } /** The panel, after IP Reputation on the Firewall tab. */ public static function render_panel(): void { $mode = self::mode(); $st = self::state(); ?>
☁ 'Why', 'rec' => 'Info', 'html' => '

A block in WordPress still costs a PHP worker for every request: the address reaches your server, PHP starts, the plugin refuses. Pushed to Cloudflare as an IP Access Rule, the same request is refused at the edge and never reaches your server.

', ), array( 'name' => 'What is pushed', 'rec' => 'Info', 'html' => '

Only addresses blocked here now that are one of: blocked by hand; a repeat offender (its second automatic block or later); blocked for a firewall attack in Block mode; or a proven fake crawler while the Fake Crawler Check is in Block mode. The newest 200. When a block ends here, the next run removes it at the edge.

', ), array( 'name' => 'What is never pushed', 'rec' => 'Info', 'html' => '

Checked on every run, whatever the block list says: an address an administrator logged in from in the last 30 days (stored as a hash, not the address), the Auto-Block never-block list and its other exemptions, private, loopback, link-local and reserved ranges, Cloudflare\'s own ranges and trusted proxies, this site\'s own server address, and your own address when you run it. The plan lists each refusal with its reason.

', ), array( 'name' => 'Modes', 'rec' => 'Info', 'html' => '

Off (default): no calls and no schedule. Dry run: every 5 minutes it reads our rules and shows the plan, and changes nothing. Switching on always starts here. Push: adds and removes rules, at most 20 changes a run, the rest on the next. Switching Push off leaves the rules in place; Remove everything we pushed takes them away. A standby copy never pushes.

', ), array( 'name' => 'Your other rules are safe', 'rec' => 'Info', 'html' => '

Each rule this plugin adds carries a note starting csdt: and a short code for this site. Rules without that exact mark, yours, another tool\'s or another site\'s on the same zone, are never changed or deleted, even for the same address.

', ), array( 'name' => 'Setup and errors', 'rec' => 'Info', 'html' => '

Uses the zone ID and API token saved on the Images tab (Cloudflare Cache Purge). The token needs Zone > Firewall Services > Edit. Test connection lists the rules, then adds and at once removes one rule for the documentation address 192.0.2.1. A Cloudflare error stops the run, is shown here in Cloudflare\'s words, and alerts at most once an hour while it continues. If the block lists cannot be read, nothing at the edge is changed.

', ), ) ); ?>