|null */ private ?array $cache = null; /** @return array */ public static function defaults(): array { return array( 'connection_mode' => self::MODE_OUTBOUND_ONLY, 'code_analysis_mode' => 'structural', 'collect_interval_s' => 300, 'flush_interval_s' => 120, 'poll_interval_s' => 60, 'request_sample_rate' => 0.02, 'spool_cap_mb' => 20, 'discovery_interval_s' => 86400, 'capability_allowlist' => array(), 'trusted_proxies' => array(), 'inbound_ip_allowlist' => array(), // ADR-047 §7: the plugin's half of the host-resources switch; the dashboard's half arrives as // `host_resources_enabled` in remote config. Off in either place is off. 'host_resources' => true, 'host_resources_enabled' => true, // Whether events are recorded at all, which is whether the site is paired. Kept here, in the autoloaded // option, so the request path never reads the (non-autoloaded) credentials to find out. Null until the // first collect or the pairing response decides it. 'events_enabled' => null, // The counts behind the heartbeat's fixed checks (`HeartbeatCollector`), written by the collect pass. 'watch_summary' => null, // When the store was last seen able to take payment, show its pages and sell a product (`StoreState`). 'store_state_seen' => array(), 'api_url' => defined( 'OPERANSE_API_URL' ) ? (string) constant( 'OPERANSE_API_URL' ) : 'https://api.operanse.com', 'last_discovery_at' => 0, 'last_flush_at' => 0, 'last_flush_ok' => null, 'last_error' => '', 'last_error_at' => 0, 'internal_error_count' => 0, 'remote_config_at' => 0, 'backoff_until' => 0, 'backoff_seconds' => 0, 'last_collect_at' => 0, 'cron_spawn_ok' => null, 'cron_spawn_error' => '', 'cron_spawn_status' => null, 'cron_spawn_checked_at' => 0, ); } /** @return array */ public function all(): array { if ( $this->cache === null ) { $stored = get_option( self::OPTION, array() ); $this->cache = array_merge( self::defaults(), is_array( $stored ) ? $stored : array() ); } return $this->cache; } public function get( string $key, mixed $default = null ): mixed { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $default; } public function set( string $key, mixed $value ): void { $this->set_many( array( $key => $value ) ); } /** * Read-modify-write against what is stored **now**, not against a snapshot this object took earlier. * * One `Settings` instance can live for half an hour: `wp operanse stream` holds one for the whole daemon process * and, since milestone 4, writes through it every few seconds (ADR-018 §8). Merging onto a stale snapshot would * silently revert everything another process wrote in between — the connection mode the customer just chose on * the admin page, the cron collector's `last_collect_at`, the WP-Cron spawn probe — and the daemon's own * heartbeat would then report those reverted values to the cloud as fact. * * @param array $values */ public function set_many( array $values ): void { $this->reload(); $all = array_merge( $this->all(), $values ); $this->cache = $all; update_option( self::OPTION, $all, self::AUTOLOAD ); } /** * Forget both this object's copy and WordPress's own object cache, so the next read is the stored value. * A change made by another process is invisible to a long-running one otherwise. * * **Both cache keys, because the option is autoloaded (ADR-031 §2).** `get_option()` looks in the `alloptions` * array *first* and only falls through to the per-option `options` key for an option that is not autoloaded. So * deleting `self::OPTION` alone — which was the whole of this method while the option was non-autoloaded, and * was correct then — stopped invalidating anything the moment the option joined `alloptions`, and * `set_many()`'s read-modify-write would have merged onto a snapshot taken when the process started. * * That is the property ADR-018 §8 needs: `wp operanse stream` holds one `Settings` for up to half an hour and * writes through it every few seconds, so a stale read there silently reverts whatever another process wrote. * The cost is one `alloptions` reload per write, on cron, admin and daemon paths only — never on a visitor * request, which reads and never writes. */ public function reload(): void { $this->cache = null; if ( function_exists( 'wp_cache_delete' ) ) { // Both, unconditionally. `alloptions` is where the option lives while `AUTOLOAD` is true, and the // per-option key is where it lives on an install whose row still says otherwise — an upgrade before // `Plugin::force_autoload()` has run, or a deployment that turned the constant off. Clearing the one // that is not in use costs a reload that was going to happen anyway on the next write. wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); } } /** * Whether events may be recorded: true once the site is paired, false before and after an unpair. Null means * not yet decided (an install that predates the key, before its first collect); the caller decides from the * credentials then, once. */ public function events_enabled(): ?bool { $v = $this->get( 'events_enabled' ); return is_bool( $v ) ? $v : null; } public function connection_mode(): string { $mode = $this->get( 'connection_mode' ); return in_array( $mode, array( self::MODE_DIRECT_INBOUND, self::MODE_PERSISTENT_STREAM ), true ) ? (string) $mode : self::MODE_OUTBOUND_ONLY; } /** The API this site talks to when nothing says otherwise. */ public const DEFAULT_API_URL = 'https://api.operanse.com'; /** * Where the agent sends everything it sends. The `OPERANSE_API_URL` constant wins when it is defined — it is * in `wp-config.php`, which an attacker with a database write cannot reach — and otherwise the stored value is * used only when `api_url_allowed()` accepts it. A stored URL on some other host (written through the option by * a plugin, a compromised admin session or a `wp option patch`) would otherwise redirect signed requests to a * server of the attacker's choosing, together with everything they carry; it falls back to the product's API. */ public function api_url(): string { if ( defined( 'OPERANSE_API_URL' ) && (string) constant( 'OPERANSE_API_URL' ) !== '' ) { return rtrim( (string) constant( 'OPERANSE_API_URL' ), '/' ); } $stored = rtrim( (string) $this->get( 'api_url' ), '/' ); return $this->api_url_allowed( $stored ) ? $stored : self::DEFAULT_API_URL; } /** * Whether a stored API URL may be used: `https://` on `api.operanse.com` or a `*.operanse.com` host. The dev * flag `OPERANSE_ALLOW_INSECURE_HTTP` lifts both (the local stack pairs against `http://host.docker.internal`); * it is a constant, so it too lives outside the database. */ public function api_url_allowed( string $url ): bool { if ( $url === '' ) { return false; } if ( $this->allows_insecure_http() ) { return true; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || ( $parts['scheme'] ?? '' ) !== 'https' || ! isset( $parts['host'] ) ) { return false; } $host = strtolower( (string) $parts['host'] ); return $host === 'api.operanse.com' || str_ends_with( $host, '.operanse.com' ); } public function allows_insecure_http(): bool { return defined( 'OPERANSE_ALLOW_INSECURE_HTTP' ) && constant( 'OPERANSE_ALLOW_INSECURE_HTTP' ); } public function code_analysis_mode(): string { return in_array( $this->get( 'code_analysis_mode' ), array( 'metadata', 'structural' ), true ) ? (string) $this->get( 'code_analysis_mode' ) : 'structural'; } /** Both halves of the host-resources switch (ADR-047 §7): the local one and the one the dashboard sent. */ public function host_resources_enabled(): bool { return (bool) $this->get( 'host_resources', true ) && (bool) $this->get( 'host_resources_enabled', true ); } public function request_sample_rate(): float { return max( 0.0, min( 1.0, (float) $this->get( 'request_sample_rate', 0.02 ) ) ); } public function spool_cap_bytes(): int { return max( 1, (int) $this->get( 'spool_cap_mb', 20 ) ) * 1024 * 1024; } /** @return list */ public function capability_allowlist(): array { $list = $this->get( 'capability_allowlist', array() ); return is_array( $list ) ? array_values( array_filter( array_map( 'strval', $list ) ) ) : array(); } /** @return list */ public function inbound_ip_allowlist(): array { $list = $this->get( 'inbound_ip_allowlist', array() ); return is_array( $list ) ? array_values( array_filter( array_map( 'strval', $list ) ) ) : array(); } /** @return list */ public function trusted_proxies(): array { if ( defined( 'OPERANSE_TRUSTED_PROXIES' ) ) { return array_values( array_filter( array_map( 'trim', explode( ',', (string) constant( 'OPERANSE_TRUSTED_PROXIES' ) ) ) ) ); } $list = $this->get( 'trusted_proxies', array() ); return is_array( $list ) ? array_values( array_filter( array_map( 'strval', $list ) ) ) : array(); } /** * Apply configuration received from the cloud. Only operational tuning keys are accepted; the * connection mode and IP allow-lists are never remotely changed. * * @param array $config Remote config. */ public function apply_remote_config( array $config ): void { // code_analysis_mode is deliberately NOT remotely settable: it is the operator's privacy choice. $allowed = array( 'collect_interval_s', 'flush_interval_s', 'poll_interval_s', 'request_sample_rate', 'spool_cap_mb', 'discovery_interval_s', 'capability_allowlist', 'host_resources_enabled' ); $update = array(); foreach ( $allowed as $key ) { if ( array_key_exists( $key, $config ) ) { $update[ $key ] = $config[ $key ]; } } $update['collect_interval_s'] = max( 60, (int) ( $update['collect_interval_s'] ?? $this->get( 'collect_interval_s' ) ) ); $update['flush_interval_s'] = max( 30, (int) ( $update['flush_interval_s'] ?? $this->get( 'flush_interval_s' ) ) ); $update['poll_interval_s'] = max( 15, (int) ( $update['poll_interval_s'] ?? $this->get( 'poll_interval_s' ) ) ); $update['spool_cap_mb'] = max( 1, min( 500, (int) ( $update['spool_cap_mb'] ?? $this->get( 'spool_cap_mb' ) ) ) ); $update['request_sample_rate'] = max( 0.0, min( 1.0, (float) ( $update['request_sample_rate'] ?? $this->get( 'request_sample_rate' ) ) ) ); $update['discovery_interval_s'] = max( 3600, min( 7 * 86400, (int) ( $update['discovery_interval_s'] ?? $this->get( 'discovery_interval_s', 86400 ) ) ) ); $update['host_resources_enabled'] = (bool) ( $update['host_resources_enabled'] ?? $this->get( 'host_resources_enabled', true ) ); // The allow-list can only *narrow* the closed registry: a name that is not a capability is dropped rather // than stored, so a wrong or hostile value cannot leave an unknown string in the option, and an empty list // still means "everything in the registry" (Registry::permitted). if ( array_key_exists( 'capability_allowlist', $update ) ) { $list = is_array( $update['capability_allowlist'] ) ? $update['capability_allowlist'] : array(); $update['capability_allowlist'] = array_values( array_filter( array_map( 'strval', $list ), static fn( string $n ): bool => isset( \Operanse\Agent\Capabilities\Registry::CAPABILITIES[ $n ] ) ) ); } // Remote config only ever arrives on a paired site (the pair response, the heartbeat), so events are on. // Every key the service sends that is not named above is ignored: what the service keeps, learns from and // alerts on is decided on the service, never by switching anything off here. $update['events_enabled'] = true; $update['remote_config_at'] = time(); $this->set_many( $update ); } public function set_last_discovery_at( int $ts ): void { $this->set( 'last_discovery_at', $ts ); } /** * `last_error` leaves the site on `get_runtime_summary` and is shown on the settings page, so the message is * cut at the stack trace, its paths reduced to basenames and redacted before it is stored (invariant 6). */ public function record_internal_error( string $message ): void { $this->set_many( array( 'last_error' => substr( \Operanse\Agent\Privacy\Redactor::error_message( $message ), 0, 500 ), 'last_error_at' => time(), 'internal_error_count' => (int) $this->get( 'internal_error_count', 0 ) + 1, ) ); } }