From 234ec919bc08505421fa1a4c3ecbedaec56c3657 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 14:50:08 +0000 Subject: [PATCH] feat: add signed image repository sync --- README.md | 11 +++- f_install/install.php | 15 +++-- f_install/j_setup_db.php | 25 +++++--- f_install/templates/ServConfig.orig.php | 4 +- hwe/ts/gateway/install.ts | 17 +++--- j_updateServer.php | 26 ++++----- scripts/sync-image-repository.php | 20 +++++++ src/sammo/ImageSyncClient.php | 77 +++++++++++++++++++++++++ tests/ImageSyncClientTest.php | 35 +++++++++++ 9 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 scripts/sync-image-repository.php create mode 100644 src/sammo/ImageSyncClient.php create mode 100644 tests/ImageSyncClientTest.php diff --git a/README.md b/README.md index 4c6658d8..8723dbdb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,14 @@ sudo -u www-data git clone https://storage.hided.net/gitea/devsam/core.git sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git ``` -> 이미지는 hook/git_hook.php을 통해 동기화되며, 서버 설치 과정에 이미지 갱신 키를 지정하는 것으로 '훼' 서버 업데이트 시 동기화됩니다. 이미지 서버가 게임 서버와 별개여도 동작하나, php와 git을 지원해야합니다. +> 이미지는 Gitea webhook을 기본으로 동기화합니다. Webhook 전달이 누락된 경우에도 서버 설치 과정에서 이미지 갱신 API와 `core` 전용 비밀값을 지정하면 게임 서버 업데이트 명령이 서명된 동기화를 요청합니다. 이미지 서버의 브랜치 변경 권한은 부여되지 않습니다. + +CLI에서 수동으로 복구 동기화를 요청할 수도 있습니다. + +```sh +IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \ +php scripts/sync-image-repository.php +``` ### 설치 @@ -67,4 +74,4 @@ Database 수는 로그인 관리 서버 1개, 내부 서버 7개로, 총 8개의 * MIT License * GPL 2.0 또는 이후 -만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. \ No newline at end of file +만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오. diff --git a/f_install/install.php b/f_install/install.php index 751fe4da..b4f9b838 100644 --- a/f_install/install.php +++ b/f_install/install.php @@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
- +
- + +
+
+ +
+ +
+
- +
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
- \ No newline at end of file + diff --git a/f_install/j_setup_db.php b/f_install/j_setup_db.php index 96471480..18c22d41 100644 --- a/f_install/j_setup_db.php +++ b/f_install/j_setup_db.php @@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name'); $servHost = Util::getPost('serv_host'); $sharedIconPath = Util::getPost('shared_icon_path'); $gameImagePath = Util::getPost('game_image_path'); +$imageRequestPath = Util::getPost('image_request_path'); $imageRequestKey = Util::getPost('image_request_key'); $kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', ''); $kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', ''); -if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath) { +if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath || !$imageRequestPath) { Json::die([ 'result' => false, 'reason' => '입력 값이 올바르지 않습니다' @@ -32,6 +33,21 @@ if (!filter_var($servHost, FILTER_VALIDATE_URL)) { ]); } +if (!filter_var($imageRequestPath, FILTER_VALIDATE_URL) + || parse_url($imageRequestPath, PHP_URL_SCHEME) !== 'https') { + Json::die([ + 'result' => false, + 'reason' => '이미지 갱신 API는 HTTPS URL이어야 합니다.' + ]); +} + +if ($imageRequestKey !== null && $imageRequestKey !== '' && strlen($imageRequestKey) < 32) { + Json::die([ + 'result' => false, + 'reason' => '이미지 동기화 비밀값은 32자 이상이어야 합니다.' + ]); +} + if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) { Json::die([ 'result' => false, @@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16)); $sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost); $gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost); -$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost); -$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost); +$imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost); $result = Util::generateFileUsingSimpleTemplate( __DIR__ . '/templates/ServConfig.orig.php', @@ -209,10 +224,6 @@ $result = Util::generateFileUsingSimpleTemplate( true ); -if ($imageRequestKey) { - @file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey); -} - if ($result !== true) { Json::die([ 'result' => false, diff --git a/f_install/templates/ServConfig.orig.php b/f_install/templates/ServConfig.orig.php index 9160da14..ab3947aa 100644 --- a/f_install/templates/ServConfig.orig.php +++ b/f_install/templates/ServConfig.orig.php @@ -38,9 +38,7 @@ class ServConfig public static function getImagePullURI(): string { - $now = time(); - $req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey); - return static::$imageRequestPath . "?req={$req_hash}&time={$now}"; + return static::$imageRequestPath; } /** diff --git a/hwe/ts/gateway/install.ts b/hwe/ts/gateway/install.ts index c39501e2..32f0ffb8 100644 --- a/hwe/ts/gateway/install.ts +++ b/hwe/ts/gateway/install.ts @@ -69,11 +69,8 @@ function setupDBForm() { $('#btn_random_generate_key').on('click', function (e) { e.preventDefault(); - let token = ''; - while (token.length < 24) { - token += (Math.random() + 1).toString(36).substring(7); - } - token = token.substr(0, 24); + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); $('#image_request_key').val(token); }); @@ -86,6 +83,7 @@ function setupDBForm() { serv_host: string, shared_icon_path: string, game_image_path: string, + image_request_path: string, image_request_key: string, kakao_rest_key: string, kakao_admin_key: string, @@ -124,10 +122,14 @@ function setupDBForm() { required: true, type: 'string', }, + image_request_path: { + required: true, + type: 'string', + }, image_request_key: { required: false, type: 'string', - min: 16, + min: 32, }, kakao_rest_key: { required: false, @@ -163,6 +165,7 @@ function setupDBForm() { serv_host: values.serv_host, shared_icon_path: values.shared_icon_path, game_image_path: values.game_image_path, + image_request_path: values.image_request_path, image_request_key: values.image_request_key, kakao_rest_key: values.kakao_rest_key, kakao_admin_key: values.kakao_admin_key, @@ -313,4 +316,4 @@ $(function () { -}); \ No newline at end of file +}); diff --git a/j_updateServer.php b/j_updateServer.php index 7e8abf4b..53c8675e 100644 --- a/j_updateServer.php +++ b/j_updateServer.php @@ -335,20 +335,18 @@ if ($server == $baseServerName) { if (ServConfig::$imageRequestKey) { try { - $imagePullPath = ServConfig::getImagePullURI(); - $pullResult = @file_get_contents($imagePullPath); - if ($pullResult === false) { - throw new \ErrorException('Invalid URI'); - } - $pullResult = Json::decode($pullResult); - if ($pullResult['result']) { - $imgResult = true; - $imgDetail = $pullResult['version']; - } else { - $imgResult = false; - $imgDetail = $pullResult['reason']; - } - } catch (\Exception $e) { + $configuredPath = ServConfig::$imageRequestPath; + $legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php'); + $imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL') + ?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath); + $pullResult = ImageSyncClient::sync( + $imageSyncPath, + 'core', + ServConfig::$imageRequestKey + ); + $imgResult = true; + $imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current'); + } catch (\Throwable $e) { $imgResult = false; $imgDetail = $e->getMessage(); } diff --git a/scripts/sync-image-repository.php b/scripts/sync-image-repository.php new file mode 100644 index 00000000..2c19b146 --- /dev/null +++ b/scripts/sync-image-repository.php @@ -0,0 +1,20 @@ +,requestId:string} + */ + public static function buildRequest( + string $client, + string $secret, + ?string $commit = null, + ?int $timestampMs = null, + ?string $requestId = null + ): array { + if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,31}$/', $client)) { + throw new \InvalidArgumentException('Invalid image sync client'); + } + if (strlen($secret) < 32) { + throw new \InvalidArgumentException('Image sync secret must be at least 32 characters'); + } + if ($commit !== null && !preg_match('/^[0-9a-f]{40,64}$/i', $commit)) { + throw new \InvalidArgumentException('Image commit must be a full Git SHA'); + } + $body = Json::encode($commit === null ? (object)[] : ['commit' => $commit]); + $timestamp = (string)($timestampMs ?? (int)floor(microtime(true) * 1000)); + $requestId ??= bin2hex(random_bytes(16)); + $signature = hash_hmac('sha256', "{$timestamp}.{$requestId}.{$body}", $secret); + return [ + 'body' => $body, + 'requestId' => $requestId, + 'headers' => [ + 'Content-Type: application/json', + "X-Image-Client: {$client}", + "X-Image-Timestamp: {$timestamp}", + "X-Image-Request-Id: {$requestId}", + "X-Image-Signature: {$signature}", + ], + ]; + } + + /** @return array */ + public static function sync(string $url, string $client, string $secret, ?string $commit = null): array + { + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + if ($scheme !== 'https' && !in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) { + throw new \InvalidArgumentException('Image sync URL must use HTTPS except for loopback tests'); + } + $request = self::buildRequest($client, $secret, $commit); + $curl = curl_init($url); + if ($curl === false) { + throw new \RuntimeException('Unable to initialize image sync request'); + } + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $request['headers'], + CURLOPT_POSTFIELDS => $request['body'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + ]); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($response === false) { + throw new \RuntimeException("Image sync request failed: {$error}"); + } + $decoded = Json::decode($response); + if ($status < 200 || $status >= 300 || !($decoded['ok'] ?? false)) { + throw new \RuntimeException("Image sync rejected ({$status}): " . ($decoded['reason'] ?? 'unknown error')); + } + return $decoded; + } +} diff --git a/tests/ImageSyncClientTest.php b/tests/ImageSyncClientTest.php new file mode 100644 index 00000000..d1b185f7 --- /dev/null +++ b/tests/ImageSyncClientTest.php @@ -0,0 +1,35 @@ +expectException(InvalidArgumentException::class); + ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef'); + } +}