diff --git a/Panel/agent_event_receiver.php b/Panel/agent_event_receiver.php new file mode 100644 index 00000000..bf83cf4d --- /dev/null +++ b/Panel/agent_event_receiver.php @@ -0,0 +1,43 @@ + false, 'error' => 'method_not_allowed')); +} + +$db = createDatabaseConnection($db_type, $db_host, $db_user, $db_pass, $db_name, $table_prefix, isset($db_port) ? $db_port : NULL); +if (!$db instanceof OGPDatabase) { + gsp_agent_event_fail(503, 'database_unavailable', 'Could not connect to database.'); +} + +$raw_body = file_get_contents('php://input'); +if ($raw_body === false || strlen($raw_body) > 65536) { + gsp_agent_event_fail(400, 'invalid_body', 'Invalid or oversized request body.'); +} + +$remote = gsp_agent_event_authenticate($db, $raw_body); +$event = json_decode($raw_body, true); +if (!is_array($event)) { + gsp_agent_event_fail(400, 'invalid_json', 'Request body is not valid JSON.'); +} + +$header_remote_id = (int)gsp_agent_event_header('X-GSP-Agent-Id'); +$event_remote_id = isset($event['remote_server_id']) && is_numeric($event['remote_server_id']) ? (int)$event['remote_server_id'] : $header_remote_id; +if ($event_remote_id !== $header_remote_id) { + gsp_agent_event_fail(403, 'remote_id_mismatch', 'Payload remote server does not match authenticated agent.'); +} +$event['remote_server_id'] = $header_remote_id; +$event['source'] = 'agent'; + +$errors = gsp_agent_event_validate($event); +if ($errors) { + gsp_agent_event_fail(400, 'validation_failed', implode('; ', $errors)); +} + +$result = gsp_agent_event_insert($db, $event, $remote); +gsp_agent_event_json_response(200, $result); diff --git a/Panel/includes/agent_event_log.php b/Panel/includes/agent_event_log.php new file mode 100644 index 00000000..696a7b23 --- /dev/null +++ b/Panel/includes/agent_event_log.php @@ -0,0 +1,192 @@ + false, 'error' => $code)); +} + +function gsp_agent_event_text($value, $max = 255) { + $value = is_scalar($value) ? (string)$value : ''; + $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value); + return substr($value, 0, $max); +} + +function gsp_agent_event_validate($event) { + $errors = array(); + if (!is_array($event)) { + return array('payload must be an object'); + } + if (empty($event['event_uuid']) || !preg_match('/^[A-Za-z0-9._:-]{16,64}$/', (string)$event['event_uuid'])) { + $errors[] = 'invalid event_uuid'; + } + if (empty($event['timestamp_utc'])) { + $errors[] = 'missing timestamp_utc'; + } + if (empty($event['event_type']) || !in_array($event['event_type'], gsp_agent_event_allowed_types(), true)) { + $errors[] = 'invalid event_type'; + } + if (empty($event['severity']) || !in_array($event['severity'], gsp_agent_event_allowed_severities(), true)) { + $errors[] = 'invalid severity'; + } + if (isset($event['remote_server_id']) && $event['remote_server_id'] !== '' && !is_numeric($event['remote_server_id'])) { + $errors[] = 'invalid remote_server_id'; + } + if (isset($event['home_id']) && $event['home_id'] !== '' && !is_numeric($event['home_id'])) { + $errors[] = 'invalid home_id'; + } + return $errors; +} + +function gsp_agent_event_authenticate($db, $raw_body) { + $remote_server_id = gsp_agent_event_header('X-GSP-Agent-Id'); + $timestamp = gsp_agent_event_header('X-GSP-Agent-Timestamp'); + $signature = gsp_agent_event_header('X-GSP-Agent-Signature'); + if (!is_numeric($remote_server_id) || !preg_match('/^\d{10}$/', $timestamp)) { + gsp_agent_event_fail(401, 'auth_headers_missing', 'Missing or invalid agent authentication headers.'); + } + if (abs(time() - (int)$timestamp) > 300) { + gsp_agent_event_fail(401, 'stale_timestamp', 'Agent event timestamp outside allowed request window.'); + } + $remote = $db->getRemoteServer((int)$remote_server_id); + if (!$remote || empty($remote['encryption_key'])) { + gsp_agent_event_fail(401, 'unknown_agent', 'Unknown remote server.'); + } + $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw_body, $remote['encryption_key']); + if (!hash_equals($expected, $signature)) { + gsp_agent_event_fail(401, 'bad_signature', 'Agent signature verification failed.'); + } + return $remote; +} + +function gsp_agent_event_home_belongs_to_remote($db, $home_id, $remote_server_id) { + if (!is_numeric($home_id) || (int)$home_id <= 0) { + return true; + } + $home = $db->getGameHome((int)$home_id); + if (!$home) { + return false; + } + return (int)$home['remote_server_id'] === (int)$remote_server_id; +} + +function gsp_agent_event_format_message($event) { + $name = gsp_agent_event_text($event['game_server_name'] ?? $event['server_name'] ?? $event['game_name'] ?? 'server', 120); + $home_id = isset($event['home_id']) && $event['home_id'] !== '' ? (int)$event['home_id'] : 0; + $port = gsp_agent_event_text($event['game_port'] ?? $event['port'] ?? '', 16); + $pid = gsp_agent_event_text($event['detected_process_pid'] ?? $event['tracked_pid'] ?? $event['pid'] ?? '', 32); + $attempt = gsp_agent_event_text($event['restart_attempt_number'] ?? '', 16); + $base = "Agent event for server '$name'"; + if ($home_id > 0) { + $base .= " (home ID $home_id)"; + } + switch ($event['event_type']) { + case 'server_crash_detected': + return "$base: unexpected stop detected. PID/process/session or required port disappeared."; + case 'server_process_stopped': + return "$base: server process stopped."; + case 'server_process_started': + case 'server_start_confirmed': + return "$base: server start confirmed" . ($pid !== '' ? " with PID $pid" : "") . ($port !== '' ? " and port $port listening" : "") . "."; + case 'server_stop_confirmed': + return "$base: server stop confirmed. Process/session and required port are no longer active."; + case 'server_unresponsive': + return "$base: process/session exists but required game port did not become ready."; + case 'automatic_restart_started': + return "$base: automatic restart attempt" . ($attempt !== '' ? " $attempt" : "") . " started."; + case 'automatic_restart_succeeded': + return "$base: automatic restart succeeded" . ($pid !== '' ? " with PID $pid" : "") . ($port !== '' ? " and port $port listening" : "") . "."; + case 'automatic_restart_failed': + return "$base: automatic restart failed."; + case 'scheduled_restart_started': + return "$base: scheduled restart started."; + case 'scheduled_restart_succeeded': + return "$base: scheduled restart completed successfully."; + case 'scheduled_restart_failed': + return "$base: scheduled restart failed."; + case 'server_port_missing': + case 'server_process_found_without_port': + return "$base: process/session is active but expected port $port is not listening."; + case 'server_port_restored': + return "$base: expected port $port is listening again."; + case 'server_port_found_without_managed_process': + return "$base: expected port $port is listening but no managed process/session was found."; + case 'restart_attempt_limit_reached': + return "$base: restart attempt limit reached."; + case 'agent_event_delivery_failed': + return "Agent event delivery failed on " . gsp_agent_event_text($event['agent_hostname'] ?? 'agent', 120) . "."; + case 'agent_event_queue_replayed': + return "Agent event queue replayed by " . gsp_agent_event_text($event['agent_hostname'] ?? 'agent', 120) . "."; + } + return $base . ': ' . gsp_agent_event_text($event['message'] ?? $event['event_type'], 250); +} + +function gsp_agent_event_insert($db, $event, $remote) { + $remote_server_id = (int)$remote['remote_server_id']; + $home_id = isset($event['home_id']) && is_numeric($event['home_id']) ? (int)$event['home_id'] : null; + if (!gsp_agent_event_home_belongs_to_remote($db, $home_id, $remote_server_id)) { + gsp_agent_event_fail(403, 'home_remote_mismatch', 'Home does not belong to reporting remote server.'); + } + if ($db->loggerEventExists($event['event_uuid'])) { + return array('ok' => true, 'duplicate' => true); + } + $agent_label = gsp_agent_event_text($event['agent_hostname'] ?? $remote['remote_server_name'] ?? $remote['agent_ip'] ?? 'agent', 120); + $actor = trim(gsp_agent_event_text(($event['agent_os'] ?? 'Agent') . ': ' . $agent_label, 120)); + $metadata = $event; + unset($metadata['password'], $metadata['steam_password'], $metadata['encryption_key'], $metadata['database_password']); + $db->loggerEx(gsp_agent_event_format_message($event), array( + 'user_id' => 0, + 'ip' => $agent_label, + 'source_type' => 'agent', + 'category' => 'server_lifecycle', + 'event_type' => gsp_agent_event_text($event['event_type'], 80), + 'severity' => gsp_agent_event_text($event['severity'], 20), + 'remote_server_id' => $remote_server_id, + 'home_id' => $home_id, + 'event_uuid' => gsp_agent_event_text($event['event_uuid'], 64), + 'correlation_id' => gsp_agent_event_text($event['correlation_id'] ?? '', 64), + 'actor' => $actor, + 'metadata_json' => json_encode($metadata) + )); + return array('ok' => true, 'duplicate' => false); +} diff --git a/Panel/includes/database_mysqli.php b/Panel/includes/database_mysqli.php index e61632d9..4f39a773 100644 --- a/Panel/includes/database_mysqli.php +++ b/Panel/includes/database_mysqli.php @@ -3406,26 +3406,147 @@ class OGPDatabaseMySQL extends OGPDatabase public function logger($message){ $user_id = isset($_SESSION['user_id']) ? $_SESSION['user_id'] : 0; $client_ip = getClientIPAddress(); - $message = $this->realEscapeSingle($message); - $this->query("INSERT INTO OGP_DB_PREFIXlogger (date, user_id, ip, message) VALUE (FROM_UNIXTIME(UNIX_TIMESTAMP(), '%d-%m-%Y %H:%i:%s'), $user_id, '$client_ip', '$message');"); + $this->loggerEx($message, array( + 'user_id' => $user_id, + 'ip' => $client_ip, + 'source_type' => 'user', + 'category' => 'panel_action', + 'severity' => 'info' + )); } - public function get_logger_count($search_field) { + private function loggerColumnExists($column) { + $column = $this->realEscapeSingle($column); + $result = $this->resultQuery("SHOW COLUMNS FROM `".$this->table_prefix."logger` LIKE '$column'"); + return is_array($result) && count($result) > 0; + } + + public function ensureLoggerExtendedSchema() { + if (!$this->link) return false; + $this->query("ALTER TABLE `".$this->table_prefix."logger` MODIFY `ip` varchar(255) NOT NULL"); + $this->query("ALTER TABLE `".$this->table_prefix."logger` MODIFY `message` varchar(1000) NOT NULL"); + $columns = array( + 'source_type' => "varchar(20) NOT NULL DEFAULT 'user'", + 'category' => "varchar(40) NOT NULL DEFAULT 'panel_action'", + 'event_type' => "varchar(80) DEFAULT NULL", + 'severity' => "varchar(20) NOT NULL DEFAULT 'info'", + 'remote_server_id' => "int(11) DEFAULT NULL", + 'home_id' => "int(11) DEFAULT NULL", + 'event_uuid' => "varchar(64) DEFAULT NULL", + 'correlation_id' => "varchar(64) DEFAULT NULL", + 'actor' => "varchar(120) DEFAULT NULL", + 'metadata_json' => "text DEFAULT NULL" + ); + foreach ($columns as $column => $definition) { + if (!$this->loggerColumnExists($column)) { + $this->query("ALTER TABLE `".$this->table_prefix."logger` ADD `$column` $definition"); + } + } + $indexes = $this->resultQuery("SHOW INDEX FROM `".$this->table_prefix."logger`"); + $index_names = array(); + foreach ((array)$indexes as $index) { + $index_names[$index['Key_name']] = true; + } + if (!isset($index_names['idx_logger_event_uuid'])) { + $this->query("ALTER TABLE `".$this->table_prefix."logger` ADD INDEX `idx_logger_event_uuid` (`event_uuid`)"); + } + if (!isset($index_names['idx_logger_source_category'])) { + $this->query("ALTER TABLE `".$this->table_prefix."logger` ADD INDEX `idx_logger_source_category` (`source_type`,`category`,`severity`)"); + } + if (!isset($index_names['idx_logger_home'])) { + $this->query("ALTER TABLE `".$this->table_prefix."logger` ADD INDEX `idx_logger_home` (`home_id`)"); + } + return true; + } + + public function loggerEx($message, $options = array()){ + $this->ensureLoggerExtendedSchema(); + $user_id = isset($options['user_id']) ? (int)$options['user_id'] : (isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 0); + $ip = isset($options['ip']) ? $options['ip'] : getClientIPAddress(); + $source_type = isset($options['source_type']) ? $options['source_type'] : 'user'; + $category = isset($options['category']) ? $options['category'] : 'panel_action'; + $event_type = isset($options['event_type']) ? $options['event_type'] : null; + $severity = isset($options['severity']) ? $options['severity'] : 'info'; + $remote_server_id = isset($options['remote_server_id']) && is_numeric($options['remote_server_id']) ? (int)$options['remote_server_id'] : 'NULL'; + $home_id = isset($options['home_id']) && is_numeric($options['home_id']) ? (int)$options['home_id'] : 'NULL'; + $event_uuid = isset($options['event_uuid']) ? $options['event_uuid'] : null; + $correlation_id = isset($options['correlation_id']) ? $options['correlation_id'] : null; + $actor = isset($options['actor']) ? $options['actor'] : null; + $metadata_json = isset($options['metadata_json']) ? $options['metadata_json'] : null; + + $message = substr((string)$message, 0, 1000); + $ip = substr((string)$ip, 0, 255); + $source_type = substr((string)$source_type, 0, 20); + $category = substr((string)$category, 0, 40); + $event_type_sql = $event_type === null ? 'NULL' : "'".$this->realEscapeSingle(substr((string)$event_type, 0, 80))."'"; + $severity = substr((string)$severity, 0, 20); + $event_uuid_sql = $event_uuid === null ? 'NULL' : "'".$this->realEscapeSingle(substr((string)$event_uuid, 0, 64))."'"; + $correlation_id_sql = $correlation_id === null ? 'NULL' : "'".$this->realEscapeSingle(substr((string)$correlation_id, 0, 64))."'"; + $actor_sql = $actor === null ? 'NULL' : "'".$this->realEscapeSingle(substr((string)$actor, 0, 120))."'"; + $metadata_sql = $metadata_json === null ? 'NULL' : "'".$this->realEscapeSingle($metadata_json)."'"; + $message = $this->realEscapeSingle($message); + $ip = $this->realEscapeSingle($ip); + $source_type = $this->realEscapeSingle($source_type); + $category = $this->realEscapeSingle($category); + $severity = $this->realEscapeSingle($severity); + + $this->query("INSERT INTO `".$this->table_prefix."logger` + (date, user_id, ip, message, source_type, category, event_type, severity, remote_server_id, home_id, event_uuid, correlation_id, actor, metadata_json) + VALUE (FROM_UNIXTIME(UNIX_TIMESTAMP(), '%d-%m-%Y %H:%i:%s'), $user_id, '$ip', '$message', '$source_type', '$category', $event_type_sql, '$severity', $remote_server_id, $home_id, $event_uuid_sql, $correlation_id_sql, $actor_sql, $metadata_sql);"); + } + + public function loggerEventExists($event_uuid) { + $this->ensureLoggerExtendedSchema(); + $event_uuid = $this->realEscapeSingle($event_uuid); + $result = $this->resultQuery("SELECT log_id FROM `".$this->table_prefix."logger` WHERE event_uuid = '$event_uuid' LIMIT 1"); + return is_array($result) && count($result) > 0; + } + + private function buildLoggerWhere($search_field, $filters = array()) { + $this->ensureLoggerExtendedSchema(); + $where = array(); $search_field = $this->realEscapeSingle($search_field); - $sql = "SELECT COUNT(1) AS total FROM ".$this->table_prefix."logger "; - if (!empty($search_field)) { - $sql .= "WHERE ip = '$search_field' OR message LIKE '%$search_field%' - OR user_id IN (SELECT user_id FROM `".$this->table_prefix."users` WHERE users_login LIKE '%$search_field%')"; + $where[] = "(ip = '$search_field' OR message LIKE '%$search_field%' + OR actor LIKE '%$search_field%' + OR event_type LIKE '%$search_field%' + OR user_id IN (SELECT user_id FROM `".$this->table_prefix."users` WHERE users_login LIKE '%$search_field%'))"; + } + foreach (array('source_type','category','severity') as $field) { + if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== 'all') { + $value = $this->realEscapeSingle($filters[$field]); + $where[] = "$field = '$value'"; + } + } + if (isset($filters['user_id']) && is_numeric($filters['user_id'])) { + $where[] = "user_id = ".(int)$filters['user_id']; + } + if (isset($filters['remote_server_id']) && is_numeric($filters['remote_server_id'])) { + $where[] = "remote_server_id = ".(int)$filters['remote_server_id']; + } + if (isset($filters['home_id']) && is_numeric($filters['home_id'])) { + $where[] = "home_id = ".(int)$filters['home_id']; + } + if (isset($filters['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_from'])) { + $date = $this->realEscapeSingle($filters['date_from']); + $where[] = "STR_TO_DATE(date, '%d-%m-%Y %H:%i:%s') >= '$date 00:00:00'"; + } + if (isset($filters['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['date_to'])) { + $date = $this->realEscapeSingle($filters['date_to']); + $where[] = "STR_TO_DATE(date, '%d-%m-%Y %H:%i:%s') <= '$date 23:59:59'"; } + return count($where) ? " WHERE ".implode(" AND ", $where) : ""; + } + + public function get_logger_count($search_field, $filters = array()) { + $sql = "SELECT COUNT(1) AS total FROM ".$this->table_prefix."logger "; + $sql .= $this->buildLoggerWhere($search_field, $filters); return $this->resultQuery($sql); } - public function read_logger($page,$limit, $search_field) { - $search_field = $this->realEscapeSingle($search_field); - + public function read_logger($page,$limit, $search_field, $filters = array()) { $log_id = ($page - 1) * $limit; if(!is_numeric($log_id) || !is_numeric($limit)){ @@ -3433,11 +3554,7 @@ class OGPDatabaseMySQL extends OGPDatabase } $sql = "SELECT * FROM ".$this->table_prefix."logger "; - - if (!empty($search_field)) { - $sql .= "WHERE ip = '$search_field' OR message LIKE '%$search_field%' - OR user_id IN (SELECT user_id FROM `".$this->table_prefix."users` WHERE users_login LIKE '%$search_field%') "; - } + $sql .= $this->buildLoggerWhere($search_field, $filters); $sql .= "ORDER BY log_id DESC LIMIT $log_id, $limit;"; diff --git a/Panel/includes/lib_remote.php b/Panel/includes/lib_remote.php index a810138a..9522e1d3 100644 --- a/Panel/includes/lib_remote.php +++ b/Panel/includes/lib_remote.php @@ -746,11 +746,11 @@ class OGPRemoteLibrary public function remote_restart_server($home_id,$server_ip,$server_port, $control_protocol,$control_password,$control_type, - $home_path,$server_exe,$run_dir,$cmd,$cpu,$nice,$preStart, $envVars, $game_key, $console_log = "") + $home_path,$server_exe,$run_dir,$cmd,$cpu,$nice,$preStart, $envVars, $game_key, $console_log = "", $restart_reason = "") { $params_array = $this->encrypt_params($home_id,$server_ip,$server_port, $control_protocol,$control_password,$control_type, - $home_path,$server_exe,$run_dir,$cmd,$cpu,$nice,$preStart,$envVars, $game_key, $console_log); + $home_path,$server_exe,$run_dir,$cmd,$cpu,$nice,$preStart,$envVars, $game_key, $console_log, $restart_reason); $this->add_enc_chk($params_array); $request = xmlrpc_encode_request("restart_server", $params_array); diff --git a/Panel/modules/addonsmanager/server_content_actions.php b/Panel/modules/addonsmanager/server_content_actions.php index 66e29001..4265b278 100644 --- a/Panel/modules/addonsmanager/server_content_actions.php +++ b/Panel/modules/addonsmanager/server_content_actions.php @@ -433,6 +433,7 @@ function server_content_restart_home($home_id, $options = array()) } sleep($delay); } + $restart_reason = (isset($options['triggered_by']) && $options['triggered_by'] === 'scheduler') ? 'scheduled_restart' : 'panel_restart'; $remote_retval = $remote->remote_restart_server( $home_info['home_id'], @@ -450,7 +451,8 @@ function server_content_restart_home($home_id, $options = array()) $preStart, $envVars, $server_xml->game_key, - (isset($server_xml->console_log) ? $server_xml->console_log : "") + (isset($server_xml->console_log) ? $server_xml->console_log : ""), + $restart_reason ); if ($remote_retval !== 1) { return server_content_result('restart_required', 'Update completed but automatic restart failed.', array( diff --git a/Panel/modules/administration/module.php b/Panel/modules/administration/module.php index 26a14032..6ee79900 100644 --- a/Panel/modules/administration/module.php +++ b/Panel/modules/administration/module.php @@ -46,8 +46,20 @@ $install_queries[1] = array( `log_id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `date` varchar(20) NOT NULL, `user_id` int(11) NOT NULL, - `ip` varchar(15) NOT NULL, - `message` varchar(250) NOT NULL + `ip` varchar(255) NOT NULL, + `message` varchar(1000) NOT NULL, + `source_type` varchar(20) NOT NULL DEFAULT 'user', + `category` varchar(40) NOT NULL DEFAULT 'panel_action', + `event_type` varchar(80) DEFAULT NULL, + `severity` varchar(20) NOT NULL DEFAULT 'info', + `remote_server_id` int(11) DEFAULT NULL, + `home_id` int(11) DEFAULT NULL, + `event_uuid` varchar(64) DEFAULT NULL, + `correlation_id` varchar(64) DEFAULT NULL, + `actor` varchar(120) DEFAULT NULL, + `metadata_json` text DEFAULT NULL, + KEY `idx_logger_event_uuid` (`event_uuid`), + KEY `idx_logger_source_category` (`source_type`,`category`,`severity`), + KEY `idx_logger_home` (`home_id`) ) ENGINE=MyISAM DEFAULT CHARSET=latin1;"); ?> - diff --git a/Panel/modules/administration/watch_logger.php b/Panel/modules/administration/watch_logger.php index b60e8763..6920c191 100644 --- a/Panel/modules/administration/watch_logger.php +++ b/Panel/modules/administration/watch_logger.php @@ -29,6 +29,13 @@ function exec_ogp_module() { $search_field = (isset($_GET['search']) && !empty($_GET['search'])) ? $_GET['search'] : false; $p = (isset($_GET['page']) && (int)$_GET['page'] > 0) ? (int)$_GET['page'] : 1; $l = (isset($_GET['limit']) && (int)$_GET['limit'] > 0) ? (int)$_GET['limit'] : 10; + $filters = array(); + foreach (array('source_type','category','severity','user_id','remote_server_id','home_id','date_from','date_to') as $filter_key) { + if (isset($_GET[$filter_key]) && $_GET[$filter_key] !== '' && $_GET[$filter_key] !== 'all') { + $filters[$filter_key] = $_GET[$filter_key]; + } + } + $db->ensureLoggerExtendedSchema(); if(hasValue($loggedInUserInfo) && is_array($loggedInUserInfo) && $loggedInUserInfo["users_page_limit"] && !(isset($_GET['limit']) and !empty($_GET['limit']))){ $l = $loggedInUserInfo["users_page_limit"]; @@ -36,14 +43,19 @@ function exec_ogp_module() { echo "
| ".str_replace("_", "", substr($key,5))." | $value |
| ".htmlentities(str_replace("_", "", substr($key,5)), ENT_QUOTES, 'UTF-8')." | ".htmlentities($value, ENT_QUOTES, 'UTF-8')." |
| event type | ".htmlentities($log['event_type'], ENT_QUOTES, 'UTF-8')." |
| correlation id | ".htmlentities($log['correlation_id'], ENT_QUOTES, 'UTF-8')." |
| details | ".htmlentities($log['metadata_json'], ENT_QUOTES, 'UTF-8')." |