feat: add signed image repository sync

This commit is contained in:
2026-08-06 14:50:08 +00:00
parent a953ab4154
commit 234ec919bc
9 changed files with 193 additions and 37 deletions
+9 -2
View File
@@ -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에게 문의하여 주십시오.
만약 별도의 라이선스를 적용하고자 할 경우 Hide_D에게 문의하여 주십시오.
+11 -4
View File
@@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
<div class="form-group row">
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/icons" />
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/icons" />
</div>
</div>
<div class="form-group row">
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/game" />
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/game" />
</div>
</div>
<div class="form-group row">
<label for="image_request_path" class="col-sm-4 col-form-label">이미지 갱신 API</label>
<div class="col-sm-8">
<input type="url" class="form-control" name="image_request_path" id="image_request_path" value="https://sam-image.hided.net/v1/sync" required />
</div>
</div>
<div class="form-group row">
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
<div class="input-group col-sm-8">
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 hook/HashKey.php의 값과 동일하게" value="" />
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 core 동기화 비밀값과 동일하게" value="" />
<div class="input-group-text">
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
</div>
@@ -189,4 +196,4 @@ require(__DIR__ . '/../vendor/autoload.php');
</div>
</body>
</html>
</html>
+18 -7
View File
@@ -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,
+1 -3
View File
@@ -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;
}
/**
+10 -7
View File
@@ -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 () {
});
});
+12 -14
View File
@@ -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();
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace sammo;
require dirname(__DIR__) . '/vendor/autoload.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
$url = getenv('IMAGE_SYNC_URL') ?: 'https://sam-image.hided.net/v1/sync';
$secretFile = getenv('IMAGE_SYNC_SECRET_FILE') ?: '/run/secrets/image_sync_core_secret';
if (!is_file($secretFile)) {
fwrite(STDERR, "IMAGE_SYNC_SECRET_FILE is not readable.\n");
exit(2);
}
$secret = trim(file_get_contents($secretFile));
$result = ImageSyncClient::sync($url, 'core', $secret, $argv[1] ?? null);
fwrite(STDOUT, Json::encode($result) . PHP_EOL);
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace sammo;
final class ImageSyncClient
{
/**
* @return array{body:string,headers:list<string>,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<string,mixed> */
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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
use PHPUnit\Framework\TestCase;
use sammo\ImageSyncClient;
require_once dirname(__DIR__) . '/src/sammo/ImageSyncClient.php';
final class ImageSyncClientTest extends TestCase
{
public function testBuildRequestSignsTheExactBody(): void
{
$secret = str_repeat('c', 32);
$request = ImageSyncClient::buildRequest(
'core',
$secret,
str_repeat('a', 40),
1786013000000,
'core-request-1234'
);
$headers = implode("\n", $request['headers']);
$expected = hash_hmac(
'sha256',
"1786013000000.core-request-1234.{$request['body']}",
$secret
);
self::assertStringContainsString("X-Image-Signature: {$expected}", $headers);
self::assertSame('{"commit":"' . str_repeat('a', 40) . '"}', $request['body']);
}
public function testBuildRequestRejectsAbbreviatedCommit(): void
{
$this->expectException(InvalidArgumentException::class);
ImageSyncClient::buildRequest('core', str_repeat('c', 32), 'deadbeef');
}
}