Files

125 lines
5.1 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* 阿里云 ECS 实例元数据:RAM 角色临时凭据 + 地域。
* (从 MovieLog_Server 移植)
*
* 为什么值得支持这种方式:这样**服务器上不用落任何长期密钥**。
* 实例绑一个 RAM 角色,程序现用现取 STS 临时凭据;即使站点被拖库,
* 拿到的也只是几十分钟就过期的临时 token。
*
* ⚠️ 一台 ECS 只能绑一个 RAM 角色;该角色必须被授权访问目标 Bucket,
* 否则报 AccessDenied(NoPermissionType=ImplicitDeny)。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class EcsMeta
{
private const BASE = 'http://100.100.100.200/latest/meta-data/';
private const TIMEOUT = 3;
/**
* 取 RAM 角色的 STS 临时凭据。
* $roleName 留空则自动取实例绑定的第一个角色。
*/
public static function ramCredentials(int $familyId, string $roleName = '', bool $force = false): array
{
static $memo = [];
$memoKey = 'aliyun:' . $roleName;
if (!$force && isset($memo[$memoKey])) {
return $memo[$memoKey];
}
// 跨请求缓存:STS 一般 >= 1 小时,到期前 5 分钟就刷新
if (!$force) {
$cached = json_decode((string)Setting::get($familyId, 'cache_sts_aliyun', ''), true);
if (is_array($cached)
&& !empty($cached['expires']) && $cached['expires'] > time() + 300
&& ($cached['role'] ?? '') === $roleName) {
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
}
}
// IMDSv2:先拿一次性 token(拿不到就降级走 v1,兼容老实例)
$token = '';
$t = Http::send('PUT', self::BASE . '../api/token',
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, self::TIMEOUT);
if ($t['ok'] && trim((string)$t['body']) !== '') {
$token = trim($t['body']);
}
$auth = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
$credsUrl = self::BASE . 'ram/security-credentials/';
if ($roleName === '') {
$r = Http::send('GET', $credsUrl, $auth, null, self::TIMEOUT);
if (!$r['ok']) {
return $memo[$memoKey] = self::fail('无法访问 ECS 元数据服务(这台服务器可能不是阿里云 ECS)');
}
$first = trim((string)strtok((string)$r['body'], "\r\n"));
if ($first === '') {
return $memo[$memoKey] = self::fail('该实例没有绑定 RAM 角色');
}
$roleName = $first;
}
$r = Http::send('GET', $credsUrl . rawurlencode($roleName), $auth, null, self::TIMEOUT);
if (!$r['ok']) {
return $memo[$memoKey] = self::fail("取 RAM 角色 {$roleName} 的临时凭据失败");
}
$json = json_decode((string)$r['body'], true);
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
$code = is_array($json) ? (string)($json['Code'] ?? '') : '';
return $memo[$memoKey] = self::fail('元数据返回的内容看不懂' . ($code !== '' ? "({$code})" : ''));
}
$cred = [
'ok' => true,
'id' => (string)$json['AccessKeyId'],
'secret' => (string)$json['AccessKeySecret'],
'token' => (string)($json['SecurityToken'] ?? ''),
'expires' => !empty($json['Expiration']) ? (int)strtotime((string)$json['Expiration']) : time() + 1800,
'error' => '',
'role' => $roleName,
];
Setting::set($familyId, 'cache_sts_aliyun', json_encode($cred, JSON_UNESCAPED_UNICODE));
return $memo[$memoKey] = $cred;
}
/** 本机地域,用于判断内网 endpoint 是否可用(1 小时缓存) */
public static function region(int $familyId, bool $force = false): string
{
static $memo = null;
if ($memo !== null && !$force) {
return $memo;
}
if (!$force) {
$c = json_decode((string)Setting::get($familyId, 'cache_ecs_region', ''), true);
if (is_array($c) && !empty($c['expires']) && $c['expires'] > time()) {
return $memo = (string)($c['region'] ?? '');
}
}
$r = Http::send('GET', self::BASE . 'region-id', [], null, self::TIMEOUT);
$region = $r['ok'] ? trim((string)$r['body']) : '';
Setting::set($familyId, 'cache_ecs_region',
json_encode(['region' => $region, 'expires' => time() + 3600]));
return $memo = $region;
}
/** 清掉全部元数据缓存(换角色/改配置后调用) */
public static function clearCache(int $familyId): void
{
foreach (['cache_sts_aliyun', 'cache_ecs_region', 'cache_oss_internal'] as $k) {
Setting::set($familyId, $k, '');
}
}
private static function fail(string $msg): array
{
return ['ok' => false, 'id' => '', 'secret' => '', 'token' => '',
'expires' => 0, 'error' => $msg, 'role' => ''];
}
}