Меню вики
Приём данных AR16 Sentinel на своём сайте
Как сайт принимает данные, которые AR16 Sentinel шлёт с сервера DayZ: multipart, подпись HMAC, повторы, ловушка PHP и готовый приёмник на PHP.
AR16 Sentinel на игровом сервере может сам отправлять на ваш сайт данные модов: папки и файлы, которые выбрал владелец сервера, каждый раз, когда они меняются. Здесь всё, что нужно сайту, чтобы принять такие отправки, проверить подпись и сохранить, с готовым приёмником на PHP и тестовой отправкой.
Работает с Sentinel 1.0.71 и новее. Один POST на источник, multipart/form-data, только HTTPS, подпись HMAC-SHA256.
Как это устроено
Игровой сервер обычно стоит за роутером, и сайт до него не достучится. Поэтому соединение открывает сама машина: когда источник меняется, Sentinel отправляет его вам целиком. Порты и настройка роутера не нужны.
- Вы делаете адрес приёма, например
https://ваш-сайт/ar16/receiver.php. - Владелец сервера в Sentinel открывает Data feeds, Send to a site, вставляет ваш адрес и выбирает источники.
- Sentinel один раз показывает секрет
ar16s_…. Владелец передаёт его вам. - Sentinel шлёт каждый источник при изменении. Вы проверяете подпись этим секретом и отвечаете 200.
Каждая отправка содержит весь источник целиком, а не изменения. Хранить достаточно последней версии: новая просто заменяет старую.
Запрос
| Заголовок | Значение |
|---|---|
Content-Type | multipart/form-data; boundary=ar16-<32 hex> |
X-AR16-Signature | sha256=<64 hex строчными>, см. «Подпись» |
X-AR16-Timestamp | время отправки, секунды Unix (UTC) |
X-AR16-Delivery | id этой доставки, 32 hex строчными; у каждой попытки новый |
X-AR16-Feed | имя источника, например tracker |
X-AR16-Seq | версия источника, растёт при каждом изменении содержимого |
Content-Encoding | gzip, только если у вашего сайта в Sentinel включено «Compress the body» |
User-Agent | AR16-Sentinel/<версия> |
POST идёт на адрес, который ввёл владелец сервера. Перенаправления (3xx) Sentinel не выполняет: подписанное тело уходит только на этот адрес. Сертификат вашего сайта проверяется обычным образом, поэтому нужен настоящий, например от Let's Encrypt.
Тело
Первая часть называется meta: описание версии и список файлов. Дальше идут сами файлы, байт в байт как на диске, частями f0, f1… в том же порядке, что в meta.files.
--ar16-6f1c…
Content-Disposition: form-data; name="meta"
Content-Type: application/json; charset=utf-8
{"v":1,"feed":"tracker","seq":12,"files":[{"part":"f0","path":"players.json",…}],…}
--ar16-6f1c…
Content-Disposition: form-data; name="f0"; filename="players.json"
Content-Type: application/json
…file bytes…
--ar16-6f1c…
Content-Disposition: form-data; name="f1"; filename="inv/76561198000000001.json"
Content-Type: application/json
…file bytes…
--ar16-6f1c…--Путь файла берите из meta, а не из filename. PHP и многие фреймворки оставляют в имени файла только последнюю часть пути, и inv/76561198000000001.json превращается в 76561198000000001.json. Полный путь всегда есть в meta.files[i].path для части с именем meta.files[i].part.
Поля meta
| Поле | Что значит |
|---|---|
v | версия формата, сейчас 1 |
feed, title | имя источника (латиница, цифры, - и _, до 40 знаков) и его название для людей |
kind | folder, file или missing: по пути пока ничего нет, тогда files пустой |
seq, hash | версия и хэш содержимого источника. seq может начаться снова с 1, если владелец удалит папку Sentinel, поэтому меньший seq не стоит отклонять |
changedAt, sentAt | когда содержимое изменилось и когда ушла эта отправка, ISO 8601 UTC |
reason | changed, interval (повтор без изменений по расписанию) или manual (кнопка Send now) |
files[] | part, path (путь внутри источника, через /), size, modified, sha256; stale: true, если это прошлая версия файла, а новая была недописана |
skipped[] | файлы, которые не вошли, с причиной (path, why): например, больше 8 МБ |
complete | false, если источник упёрся в предел и пришёл не полностью |
error | есть, только если папку не удалось прочитать |
delivery, intervalSec, count, bytes | id доставки, как часто машина читает источник, число файлов и их общий размер |
Подпись
Подпись доказывает, что отправка пришла от машины, которой владелец выдал секрет, и что её не меняли по дороге.
X-AR16-Signature = "sha256=" + hex( HMAC-SHA256( key = the secret as a UTF-8 string, whole, with ar16s_,
data = X-AR16-Timestamp + "." + X-AR16-Delivery + "." + body ) )- Тело байт в байт, как пришло. Не после разбора multipart и не после распаковки gzip: при
Content-Encoding: gzipподписаны сжатые байты. - hex строчными, 64 знака после
sha256=. - Сравнивайте за постоянное время: в PHP
hash_equals(), не==. - Время. Отклоняйте
X-AR16-Timestamp, который расходится с часами сайта больше чем на 300 секунд. - Повторы. Помните
X-AR16-Deliveryминут 15 и на тот же id отвечайте 200, ничего не делая.
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_AR16_SIGNATURE'] ?? '')) { http_response_code(401); exit; }Ответ и повторы
| Ответ | Когда |
|---|---|
200 | сохранено; также повтор той же доставки |
401 | неверная подпись или время |
404 | неизвестный адрес или метка машины, если они у вас есть |
413 | больше ваших пределов |
400, 415, 422 | тело не разбирается, не multipart или не сходится sha256 файла |
429, 503 | слишком часто или временная беда хранилища: Sentinel подождёт и повторит |
Любой код 2xx значит «принято». Всё остальное (другой код, сетевая ошибка, 120 секунд без ответа) Sentinel считает неудачей и повторяет ту же последнюю версию через 10 с, 30 с, 1 мин, 5 мин, 15 мин и дальше раз в 15 минут без конца, с разбросом ±20%. Если за это время источник изменился, после восстановления придёт уже новая версия, а не все пропущенные. Владелец сервера видит в Sentinel ваш код и первые 200 знаков ответа, поэтому короткий JSON вроде {"error":"bad_signature"} помогает ему понять, что не так.
Одному сайту Sentinel шлёт по одной отправке за раз, источники по очереди. Пределы на машине: файл до 8 МБ, источник до 32 МиБ (33 554 432 байта суммы файлов) и до 5000 файлов. Свои пределы не ставьте ниже, иначе большой источник будет вечно получать 413.
Ловушка PHP
С настройками по умолчанию подпись в PHP не проверить. Для multipart/form-data PHP сам разбирает тело в $_POST и $_FILES, а php://input остаётся пустым. Подпись же считается по сырому телу. Нужно выключить enable_post_data_reading для приёмника: тогда php://input отдаёт тело целиком, а разбирает его сам приёмник.
Это настройка уровня каталога, ini_set() её не меняет. Положите приёмник в отдельную папку и выключите настройку только там:
| Где работает PHP | Как выключить |
|---|---|
| PHP-FPM или CGI | .user.ini в папке приёмника: enable_post_data_reading = Off. Применяется в течение 5 минут (user_ini.cache_ttl) |
| Apache с mod_php | .htaccess в папке приёмника: php_flag enable_post_data_reading Off |
| Отдельный пул FPM | php_admin_value[enable_post_data_reading] = Off. Через fastcgi_param PHP_VALUE в nginx лучше не делать: значение прилипает к процессу FPM и достаётся чужим скриптам |
| Встроенный сервер для проверки | php -d enable_post_data_reading=0 -S 127.0.0.1:8080 |
Приёмник ниже отвечает 400 {"error":"empty_body_check_enable_post_data_reading"}, пока настройка включена. Заодно проверьте размеры: client_max_body_size 48m в nginx (по умолчанию 1 МБ) или LimitRequestBody в Apache, и memory_limit PHP от 256M: тело держится в памяти несколько раз.
Готовый приёмник
receiver.php, PHP 8.1 и новее, без зависимостей. Проверяет время, подпись и повтор, распаковывает gzip, разбирает multipart, сверяет sha256 каждого файла, отклоняет опасные пути и подменяет папку источника целиком. Проверен 19 октября 2026 на настоящих отправках Sentinel 1.0.71: обычной, сжатой и с чужим секретом.
- Впишите секрет в
SECRET, а лучше читайте его из переменной окружения или из файла вне сайта. STOREвынесите за пределы публичной папки сайта: туда попадают файлы модов как есть.- Если машин несколько, дайте каждой свой адрес и свой секрет, например
receiver.php?m=<метка>со своей папкой.
<?php
// receiver.php: receiver for AR16 Sentinel "Send to a site" deliveries.
// PHP 8.1+. This script needs enable_post_data_reading = Off (see "The PHP trap").
const SECRET = 'ar16s_PASTE_THE_SECRET_FROM_SENTINEL'; // shown once by Sentinel
const STORE = __DIR__ . '/feeds-data'; // latest version of every source; keep it outside the web root
const MAX_SKEW = 300; // allowed clock difference, seconds
const SEEN_TTL = 900; // how long delivery ids are remembered, seconds
function answer(int $code, array $body): never
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($body);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') answer(405, ['error' => 'method']);
// 1. The raw body. Empty with multipart means PHP parsed it itself: set enable_post_data_reading = Off.
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') answer(400, ['error' => 'empty_body_check_enable_post_data_reading']);
// 2. Signature: HMAC-SHA256 with the secret over "<timestamp>.<delivery>." and the body exactly as received.
$ts = $_SERVER['HTTP_X_AR16_TIMESTAMP'] ?? '';
$delivery = $_SERVER['HTTP_X_AR16_DELIVERY'] ?? '';
$sig = $_SERVER['HTTP_X_AR16_SIGNATURE'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int)$ts) > MAX_SKEW) answer(401, ['error' => 'stale_or_bad_time']);
if (!preg_match('/^[0-9a-f]{32}$/', $delivery)) answer(400, ['error' => 'bad_delivery_id']);
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $raw, SECRET);
if (!hash_equals($expected, $sig)) answer(401, ['error' => 'bad_signature']);
// 3. The same delivery again: answer 200 and do nothing.
if (!is_dir(STORE)) mkdir(STORE, 0700, true);
$seenFile = STORE . '/.seen.json';
$seen = is_file($seenFile) ? (json_decode((string)file_get_contents($seenFile), true) ?: []) : [];
$now = time();
$seen = array_filter($seen, fn($t) => $now - $t < SEEN_TTL);
if (isset($seen[$delivery])) answer(200, ['ok' => true, 'duplicate' => true]);
$seen[$delivery] = $now;
file_put_contents($seenFile, json_encode($seen), LOCK_EX);
// 4. gzip ("Compress the body" in Sentinel): unpack only after the signature was checked.
$body = $raw;
if (strtolower($_SERVER['HTTP_CONTENT_ENCODING'] ?? '') === 'gzip') {
$body = gzdecode($raw);
if ($body === false) answer(400, ['error' => 'bad_gzip']);
}
// 5. Parse the multipart body.
if (!preg_match('/boundary=([^;\s]+)/', $_SERVER['CONTENT_TYPE'] ?? '', $m)) answer(415, ['error' => 'not_multipart']);
$parts = parse_multipart($body, trim($m[1], '"'));
$meta = json_decode($parts['meta'] ?? '', true);
if (!is_array($meta) || !isset($meta['feed'], $meta['seq'], $meta['files']) || !is_array($meta['files'])) {
answer(422, ['error' => 'bad_meta']);
}
$feed = (string)$meta['feed'];
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,39}$/', $feed)) answer(422, ['error' => 'bad_feed']);
// 6. Check every file before writing anything.
foreach ($meta['files'] as $f) {
$data = $parts[$f['part'] ?? ''] ?? null;
if ($data === null) answer(422, ['error' => 'missing_part', 'path' => $f['path'] ?? null]);
if (hash('sha256', $data) !== ($f['sha256'] ?? '')) answer(422, ['error' => 'bad_sha256', 'path' => $f['path'] ?? null]);
if (!safe_path((string)($f['path'] ?? ''))) answer(422, ['error' => 'bad_path', 'path' => $f['path'] ?? null]);
}
// 7. A source always arrives whole: build a new folder and swap it in.
$dir = STORE . '/' . $feed;
$tmp = $dir . '.new-' . bin2hex(random_bytes(4));
mkdir($tmp, 0700, true);
foreach ($meta['files'] as $f) {
$target = $tmp . '/' . $f['path'];
if (!is_dir(dirname($target))) mkdir(dirname($target), 0700, true);
file_put_contents($target, $parts[$f['part']]);
}
file_put_contents($tmp . '/.meta.json', $parts['meta']);
$old = null;
if (is_dir($dir)) { $old = $dir . '.old-' . bin2hex(random_bytes(4)); rename($dir, $old); }
rename($tmp, $dir);
if ($old !== null) remove_tree($old);
answer(200, ['ok' => true, 'feed' => $feed, 'seq' => $meta['seq'], 'files' => count($meta['files'])]);
// ---- helpers -----------------------------------------------------------------------------------
/** Multipart parts by name. Sentinel makes sure "--<boundary>" never occurs inside a file. */
function parse_multipart(string $body, string $boundary): array
{
$delim = '--' . $boundary;
$parts = [];
$pos = strpos($body, $delim);
if ($pos === false) return [];
$pos += strlen($delim);
while (substr($body, $pos, 2) !== '--') {
$pos += 2; // \r\n after the delimiter
$headEnd = strpos($body, "\r\n\r\n", $pos);
if ($headEnd === false) break;
$next = strpos($body, "\r\n" . $delim, $headEnd + 4);
if ($next === false) break;
$head = substr($body, $pos, $headEnd - $pos);
if (preg_match('/name="([^"]*)"/', $head, $n)) {
$parts[$n[1]] = substr($body, $headEnd + 4, $next - $headEnd - 4);
}
$pos = $next + 2 + strlen($delim);
}
return $parts;
}
/** A relative path inside the source: no "..", no drive and no leading "/". */
function safe_path(string $path): bool
{
if ($path === '' || $path[0] === '/' || str_contains($path, '\\') || str_contains($path, ':')) return false;
foreach (explode('/', $path) as $seg) if ($seg === '' || $seg === '.' || $seg === '..') return false;
return true;
}
function remove_tree(string $dir): void
{
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST) as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}Тестовая отправка
Чтобы проверить приёмник до подключения настоящей машины. send-test.php собирает запрос ровно так же, как Sentinel: тот же multipart, те же заголовки, та же подпись. Нужен PHP 8.1+ с расширением curl.
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET # two test files
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET ./folder # your own folder
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET --gzip # compressedОжидаемо: 200 {"ok":true,"feed":"test","seq":1,"files":2}. С чужим секретом должно быть 401 {"error":"bad_signature"}; если там 200, подпись не проверяется.
<?php
// send-test.php: a test delivery built exactly like AR16 Sentinel builds one. Needs PHP 8.1+ with curl.
// php send-test.php <receiver url> <secret> [folder] [--gzip]
// Without a folder it sends two test files: players.json and sub/blob.bin.
$args = array_values(array_filter(array_slice($argv, 1), fn($a) => $a !== '--gzip'));
$gzip = in_array('--gzip', $argv, true);
[$url, $secret, $folder] = $args + [null, null, null];
if (!$url || !$secret) {
fwrite(STDERR, "php send-test.php <url> <secret> [folder] [--gzip]\n");
exit(2);
}
// Files: path inside the source (with "/") => bytes.
$files = [];
if ($folder) {
$base = rtrim(realpath($folder), '\\/');
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if ($file->isFile()) $files[str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1))] = file_get_contents($file->getPathname());
}
ksort($files, SORT_STRING);
} else {
$files = [
'players.json' => '{"v":1,"players":[{"name":"Test","hp":100}]}',
'sub/blob.bin' => "\x00\x01\x02--\r\n\xff",
];
}
$delivery = bin2hex(random_bytes(16));
$sentAt = gmdate('Y-m-d\TH:i:s') . '.000Z';
$i = 0;
$list = [];
foreach ($files as $path => $bytes) {
$list[] = ['part' => 'f' . $i++, 'path' => $path, 'size' => strlen($bytes), 'modified' => $sentAt, 'sha256' => hash('sha256', $bytes)];
}
$meta = json_encode([
'v' => 1, 'feed' => 'test', 'title' => 'Test', 'kind' => $folder ? 'folder' : 'folder', 'seq' => 1,
'hash' => hash('sha256', implode("\n", array_column($list, 'sha256'))), 'changedAt' => $sentAt, 'intervalSec' => 60,
'complete' => true, 'count' => count($files), 'bytes' => array_sum(array_column($list, 'size')), 'skipped' => [],
'delivery' => $delivery, 'sentAt' => $sentAt, 'reason' => 'manual', 'files' => $list,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$boundary = 'ar16-' . bin2hex(random_bytes(16));
$body = "--$boundary\r\nContent-Disposition: form-data; name=\"meta\"\r\nContent-Type: application/json; charset=utf-8\r\n\r\n$meta\r\n";
foreach ($list as $f) {
$body .= "--$boundary\r\nContent-Disposition: form-data; name=\"{$f['part']}\"; filename=\"{$f['path']}\"\r\n"
. "Content-Type: application/octet-stream\r\n\r\n" . $files[$f['path']] . "\r\n";
}
$body .= "--$boundary--\r\n";
if ($gzip) $body = gzencode($body, 1);
$ts = (string)time();
$signature = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $body, $secret);
$headers = [
"Content-Type: multipart/form-data; boundary=$boundary",
"X-AR16-Delivery: $delivery",
"X-AR16-Timestamp: $ts",
'X-AR16-Feed: test',
'X-AR16-Seq: 1',
"X-AR16-Signature: $signature",
'User-Agent: AR16-Sentinel/test',
];
if ($gzip) $headers[] = 'Content-Encoding: gzip';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 120,
]);
$answer = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
echo $error ? "error: $error\n" : "$code $answer\n";
echo count($files) . " file(s), " . strlen($body) . " byte(s) sent" . ($gzip ? " (gzip)" : "") . ", delivery $delivery\n";
exit($code >= 200 && $code < 300 ? 0 : 1);Перед подключением
- Адрес открывается по HTTPS с настоящим сертификатом и не перенаправляет.
enable_post_data_readingвыключен для папки приёмника.send-test.phpсо своим секретом даёт 200, с чужим даёт 401.- Тот же тест с
--gzipдаёт 200. - Лимиты веб-сервера и PHP пропускают тело до 48 МБ.
- Папка с принятыми файлами не открывается из браузера.
- Владелец ввёл адрес в Sentinel, а секрет из Sentinel есть у вас. В Sentinel у вашего сайта видно «Delivered at …».
Если сайт забирает данные сам
Есть и обратный путь: сайт сам ходит к машине. Он работает, только если машина доступна из интернета. Владелец включает в Sentinel «Hand out data», пробрасывает на роутере TCP-порт (по умолчанию 8416), открывает его в брандмауэре и передаёт вам внешний адрес, ключ ar16f_… и пин сертификата sha256//…. Сертификат машины самоподписанный, поэтому её подлинность проверяется по пину.
$ch = curl_init('https://203.0.113.7:8416/v1/feeds/tracker');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ar16f_…', 'Accept: application/json'],
CURLOPT_SSL_VERIFYPEER => false, // the certificate is self-signed…
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_PINNEDPUBLICKEY => 'sha256//…', // …so the pin is what proves the machine
CURLOPT_ENCODING => '', // accept gzip
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$feed = json_decode(curl_exec($ch), true); // { feed, seq, files: [{ path, size, sha256, json | text | base64 }] }GET /v1/feeds перечисляет источники, доступные ключу. С заголовком If-None-Match и ETag прошлого ответа машина отвечает 304 без тела, если ничего не изменилось. Не больше 10 запросов в секунду на ключ; после 20 отказов за 10 минут адрес закрывается на 15 минут.
Как менять файлы на машине
Тем же портом и ключом сайт может и менять файлы источника: прочитать один файл, задать значение внутри JSON, заменить файл целиком или удалить его. Машина отвечает сразу, в том же запросе. Работает с Sentinel 1.0.74 и новее. Запись выключена у каждого ключа, пока владелец сервера не разрешит её этому ключу и этому источнику в Data feeds («May change files in»); ключ только на чтение получает 403 read_only.
Файл лежит по адресу /v1/feeds/<источник>/files/<путь>, путь внутри источника такой же, как в списке файлов самого источника. Доступны только файлы, которые источник отдаёт сам: те, что берёт его фильтр, подпапки только если источник их читает, без ссылок. Новый файл можно создать только там, где его взял бы фильтр, в уже существующей папке. На всё остальное ответ 404, как на несуществующий файл.
Никто никого не перезаписывает. Чтение отвечает заголовком ETag: "<sha256>", это сумма байтов файла. Каждая запись возвращает её в If-Match; If-Match: * не принимается. Если файл изменился после вашего чтения, машина отвечает 412 stale с нынешним ETag файла и ничего не меняет: прочитайте заново, внесите правку в новую версию и отправьте снова. Новый файл создаётся с If-None-Match: *.
Как задать значение. PATCH с телом JSON {"set": [{"at": "roles[0].canKick", "value": true}]}. at это путь: ключи через точку, индексы массива в скобках. Ключ, в котором сама есть точка или скобка, пишется списком: ["roles", "a.b", 0]. Родитель должен существовать. В объект можно добавить новый ключ, в массив новый элемент нельзя: для этого файл заменяется целиком. Все set одного запроса применяются вместе или ни один. Порядок ключей, BOM и концы строк сохраняются; машина ставит отступ в два пробела, поэтому новый ETag берите из ответа. Перед ответом машина заново собирает источник, так что GET /v1/feeds/<источник> уже видит правку.
<?php
// write.php: read a file on the machine, change one value in it, then make, replace and delete a file.
// Through the Data feeds port of AR16 Sentinel, with a key the server owner allowed to write to this source.
const BASE = 'https://203.0.113.7:8416/v1/feeds/admin/files/';
const KEY = 'ar16f_…';
const PIN = 'sha256//…';
/** One request. Returns [HTTP code, body, ETag of the answer]. */
function call(string $method, string $path, array $headers = [], ?string $body = null): array
{
$etag = '';
$ch = curl_init(BASE . implode('/', array_map('rawurlencode', explode('/', $path))));
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => array_merge(['Authorization: Bearer ' . KEY], $headers),
CURLOPT_SSL_VERIFYPEER => false, // the certificate is self-signed…
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_PINNEDPUBLICKEY => PIN, // …so the pin is what proves the machine
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$etag) {
if (stripos($line, 'etag:') === 0) $etag = trim(substr($line, 5));
return strlen($line);
},
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$out = curl_exec($ch);
if ($out === false) exit('no answer: ' . curl_error($ch) . "\n");
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $out, $etag];
}
// 1. Read the file. Its ETag is the sha256 of its bytes: the version you are editing.
[$code, $text, $etag] = call('GET', 'permissions.json');
echo "read: $code\n";
if ($code !== 200) exit("$text\n");
$read = $etag;
// 2. Set one value. Everything else in the file stays as it was.
$patch = json_encode(['set' => [['at' => 'roles[0].canKick', 'value' => true]]]);
[$code, $text, $etag] = call('PATCH', 'permissions.json', ['Content-Type: application/json', 'If-Match: ' . $etag], $patch);
echo "patched: $code\n";
if ($code !== 200) exit("$text\n"); // 412 stale: the file changed after you read it. Read it again.
// 3. The same change sent on the version read before is refused, and nothing is written.
[$code, $text] = call('PATCH', 'permissions.json', ['Content-Type: application/json', 'If-Match: ' . $read], $patch);
echo "stale: $code $text\n";
// 4. Make a new file, replace it whole, delete it. Each write answers the new ETag.
[$code, , $etag] = call('PUT', 'notes.json', ['If-None-Match: *'], '{"note": "first"}');
echo "made: $code\n";
[$code, , $etag] = call('PUT', 'notes.json', ['If-Match: ' . $etag], '{"note": "second"}');
echo "replaced: $code\n";
[$code] = call('DELETE', 'notes.json', ['If-Match: ' . $etag]);
echo "deleted: $code\n";Методы
| Метод | Что делает | Что нужно |
|---|---|---|
<code>GET</code>, <code>HEAD</code> | файл с диска сейчас, байты как есть, с ETag | |
<code>PATCH</code> | задаёт значения по JSON-путям; отвечает {"sha256": …} | |
<code>PUT</code> | заменяет файл целиком телом запроса | |
<code>DELETE</code> | удаляет файл; отвечает 204 |
Как прибавить к числу
Для балансов и счётчиков файл не нужно читать заранее. Отправьте PATCH со списком add: машина сама прочитает текущее значение, прибавит ваше и запишет файл за один шаг, под своей блокировкой файла, поэтому два платежа в одну секунду оба дойдут. Отрицательное число отнимает. Работает с Sentinel 1.0.76 и новее.
{"add": [{"at": "players[3].balance", "value": -250, "min": 0}]}
- Значение по пути
atдолжно существовать и быть числом JSON. Нет ключа, индекс за концом массива или значение не число:422 not_number; нет родителя по пути:422 bad_patch. minиmaxнеобязательны. Результат за их пределами отклоняется с422 out_of_range, ничего не записывается, поэтому покупка не уведёт баланс ниже нуля.If-Matchдля запроса только сaddне нужен. Если прислать, он проверяется.Idempotency-Keyобязателен, например id платежа (до 128 знаков: буквы, цифры и. _ : -). Машина помнит применённую правку 7 дней: тот же ключ с тем же телом возвращает первый ответ и ничего не прибавляет (Idempotency-Replayed: true). Поэтому запрос, отправленный повторно, потому что ответ не дошёл, никогда не посчитается дважды. Тот же ключ с другим телом (другие байты, даже тот же JSON с другим порядком ключей):422 idempotency_key_reused. Отклонённая правка не запоминается, её можно спокойно отправить снова.- Числа складываются точно, без округления плавающей точки:
0.1 + 0.2даёт0.3, а у суммы столько знаков после точки, сколько у более точного слагаемого. - Не больше 10 запросов в секунду на ключ. Если шлёте пачкой, повторяйте ответ
429с тем жеIdempotency-Key. - Ответ:
{"sha256": …, "values": [{"at": …, "before": 50, "after": 150}]}.
Чего машина не исправит. Если мод держит значение в памяти и потом сохраняет его поверх файла, ваша правка пропадёт, как и любая правка снаружи. Моду, который так работает с деньгами, лучше принимать платежи отдельными файлами в папке входящих (PUT с If-None-Match: *, файл на платёж).
<?php
// donate.php: credit a player's balance after a payment and charge it for a purchase, without reading the file first.
// Through the Data feeds port of AR16 Sentinel, with a key the server owner allowed to write to this source.
// Sentinel reads the number, adds and writes it under its own lock, so two payments at once both land.
const BASE = 'https://203.0.113.7:8416/v1/feeds/admin/files/';
const KEY = 'ar16f_…';
const PIN = 'sha256//…';
/**
* Adds $amount (below zero takes away) to the number at $at in $file. $once names this change for good, such as the
* payment's id: sent again with the same $once, it is answered again and not applied twice. Returns [HTTP code,
* answer as an array, whether it was a repeat].
*/
function add(string $file, string $at, int|float $amount, string $once, int|float|null $min = null): array
{
$item = ['at' => $at, 'value' => $amount];
if ($min !== null) $item['min'] = $min; // never below this: a charge over the balance is refused, not written
$replayed = false;
$ch = curl_init(BASE . implode('/', array_map('rawurlencode', explode('/', $file))));
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => json_encode(['add' => [$item]]),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . KEY, 'Content-Type: application/json', 'Idempotency-Key: ' . $once],
CURLOPT_SSL_VERIFYPEER => false, // the certificate is self-signed…
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_PINNEDPUBLICKEY => PIN, // …so the pin is what proves the machine
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$replayed) {
if (stripos($line, 'idempotency-replayed: true') === 0) $replayed = true;
return strlen($line);
},
]);
$out = curl_exec($ch);
if ($out === false) exit('no answer: ' . curl_error($ch) . "\n");
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), json_decode($out, true), $replayed];
}
// 1. Payment 81723 paid 100: credit it. No read first and no If-Match: the machine adds to what is there now.
[$code, $answer] = add('balances.json', 'players[0].balance', 100, 'payment-81723');
echo "credited: $code ", json_encode($answer['values'] ?? $answer), "\n";
// 2. The answer was lost on the way? Send exactly the same again: the same answer, and the 100 is not added twice.
[$code, $answer, $replayed] = add('balances.json', 'players[0].balance', 100, 'payment-81723');
echo "sent again: $code, replayed ", $replayed ? 'yes' : 'no', ", balance ", $answer['values'][0]['after'], "\n";
// 3. A purchase for 30: take it away, but never below zero.
[$code, $answer] = add('balances.json', 'players[0].balance', -30, 'order-5512', min: 0);
echo "charged: $code ", json_encode($answer['values'] ?? $answer), "\n";
// 4. A purchase for more than the balance: 422 out_of_range, and nothing is written.
[$code, $answer] = add('balances.json', 'players[0].balance', -1000, 'order-5513', min: 0);
echo "too much: $code {$answer['error']}\n";Отказы при записи
| Код | error | Когда |
|---|---|---|
404 | not_found | |
403 | read_only | |
428 | if_match_required | |
412 | stale | |
415 | not_json | |
422 | bad_patch | |
413 | too_large | |
409 | io | |
503 | busy | |
428 | idempotency_key_required | |
422 | not_number | |
422 | out_of_range | |
422 | idempotency_key_reused |
