208 lines
8.6 KiB
PHP
208 lines
8.6 KiB
PHP
<?php
|
||||
|
|
// ============================================
|
|||
|
|
// 文件: lib/storage/meta.php
|
|||
|
|
// 说明: 云主机实例元数据鉴权(免密钥访问对象存储)
|
|||
|
|
// - 阿里云 ECS:RAM 角色 → STS 临时凭据
|
|||
|
|
// - 腾讯云 CVM:CAM 角色 → 临时密钥
|
|||
|
|
// 服务器不在对应云上时,元数据地址不可达,会返回明确错误。
|
|||
|
|
// ============================================
|
|||
|
|
|
|||
|
|
// 元数据地址不可达时的超时(秒)——必须短,避免拖慢页面
|
|||
|
|
const STORAGE_META_TIMEOUT = 3;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 阿里云 ECS:获取 RAM 角色的 STS 临时凭据。
|
|||
|
|
*
|
|||
|
|
* @param string $roleName 指定的 RAM 角色名;留空则自动发现实例绑定的第一个角色
|
|||
|
|
* @param bool $force 忽略缓存强制刷新
|
|||
|
|
* @return array ['ok'=>bool,'id'=>string,'secret'=>string,'token'=>string,'expires'=>int,'error'=>string]
|
|||
|
|
*/
|
|||
|
|
function aliyunEcsRamCredentials($roleName = '', $force = false) {
|
|||
|
|
static $memo = [];
|
|||
|
|
$roleName = trim((string)$roleName);
|
|||
|
|
$memoKey = 'aliyun:' . $roleName;
|
|||
|
|
if (!$force && isset($memo[$memoKey])) return $memo[$memoKey];
|
|||
|
|
|
|||
|
|
// 跨请求缓存(临时凭据有效期通常 >= 1 小时)
|
|||
|
|
if (!$force) {
|
|||
|
|
$cached = getSettingJson('cache_meta_aliyun', []);
|
|||
|
|
if (!empty($cached['expires']) && $cached['expires'] > time() + 300
|
|||
|
|
&& ($cached['role'] ?? '') === $roleName) {
|
|||
|
|
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$base = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/';
|
|||
|
|
|
|||
|
|
// IMDSv2:先取一次性 token(实例关闭 v1 时必须)
|
|||
|
|
$token = '';
|
|||
|
|
$t = storageHttp('PUT', 'http://100.100.100.200/latest/api/token',
|
|||
|
|
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, STORAGE_META_TIMEOUT);
|
|||
|
|
if ($t['ok'] && trim($t['body']) !== '') {
|
|||
|
|
$token = trim($t['body']);
|
|||
|
|
}
|
|||
|
|
$authHeader = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
|
|||
|
|
|
|||
|
|
// 角色名
|
|||
|
|
if ($roleName === '') {
|
|||
|
|
$r = storageHttp('GET', $base, $authHeader, null, STORAGE_META_TIMEOUT);
|
|||
|
|
if (!$r['ok']) {
|
|||
|
|
return $memo[$memoKey] = aliyunMetaFail($r, '无法访问 ECS 元数据服务');
|
|||
|
|
}
|
|||
|
|
$first = trim(strtok($r['body'], "\r\n"));
|
|||
|
|
if ($first === '') {
|
|||
|
|
return $memo[$memoKey] = aliyunMetaFail(null, '该实例未绑定 RAM 角色');
|
|||
|
|
}
|
|||
|
|
$roleName = $first;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$r = storageHttp('GET', $base . rawurlencode($roleName), $authHeader, null, STORAGE_META_TIMEOUT);
|
|||
|
|
if (!$r['ok']) {
|
|||
|
|
return $memo[$memoKey] = aliyunMetaFail($r, "获取 RAM 角色 {$roleName} 的临时凭据失败");
|
|||
|
|
}
|
|||
|
|
$json = json_decode($r['body'], true);
|
|||
|
|
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
|
|||
|
|
$returned = is_array($json) ? ($json['Code'] ?? '') : '';
|
|||
|
|
return $memo[$memoKey] = aliyunMetaFail(null, '元数据返回内容异常' . ($returned ? "({$returned})" : ''));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$expires = !empty($json['Expiration']) ? (int)strtotime($json['Expiration']) : time() + 1800;
|
|||
|
|
$cred = [
|
|||
|
|
'ok' => true,
|
|||
|
|
'id' => (string)$json['AccessKeyId'],
|
|||
|
|
'secret' => (string)$json['AccessKeySecret'],
|
|||
|
|
'token' => (string)($json['SecurityToken'] ?? ''),
|
|||
|
|
'expires' => $expires,
|
|||
|
|
'error' => '',
|
|||
|
|
'role' => $roleName,
|
|||
|
|
];
|
|||
|
|
setSettingJson('cache_meta_aliyun', $cred);
|
|||
|
|
return $memo[$memoKey] = $cred;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function aliyunMetaFail($resp, $prefix) {
|
|||
|
|
$detail = $resp === null ? '' : ($resp['error'] ?? '');
|
|||
|
|
return [
|
|||
|
|
'ok' => false, 'id' => '', 'secret' => '', 'token' => '', 'expires' => 0,
|
|||
|
|
'error' => $prefix . ($detail ? ":{$detail}" : '')
|
|||
|
|
. '(请确认服务器位于阿里云 ECS,且实例已绑定 RAM 角色;否则请改用 AccessKey 方式)',
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 当前 ECS 所在地域 ID(如 cn-qingdao、cn-beijing)。不在阿里云 ECS 上时返回空串。
|
|||
|
|
*
|
|||
|
|
* 用途:判断「内网 endpoint 能不能用」。`oss-<region>-internal.aliyuncs.com`
|
|||
|
|
* **只在与该桶同地域的 ECS 上可路由**;跨地域时 DNS 照常解析(秒回 A 记录)
|
|||
|
|
* 但 TCP 永远连不通,于是每次读取都要先白等一次连接超时——这是「服务端代理
|
|||
|
|
* 慢到不可用」的典型成因。先比地域,可以完全免掉这类探测。
|
|||
|
|
*
|
|||
|
|
* 地域不会变,结果缓存 1 小时。
|
|||
|
|
*
|
|||
|
|
* @param bool $force 忽略缓存强制重取
|
|||
|
|
* @return string 地域 ID,未知时为空串
|
|||
|
|
*/
|
|||
|
|
function aliyunEcsRegion($force = false) {
|
|||
|
|
static $memo = null;
|
|||
|
|
if (!$force && $memo !== null) return $memo;
|
|||
|
|
|
|||
|
|
if (!$force) {
|
|||
|
|
$cached = getSettingJson('cache_ecs_region', []);
|
|||
|
|
if (!empty($cached['expires']) && $cached['expires'] > time()) {
|
|||
|
|
return $memo = (string)($cached['region'] ?? '');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$r = storageHttp('GET', 'http://100.100.100.200/latest/meta-data/region-id',
|
|||
|
|
[], null, STORAGE_META_TIMEOUT, false, 2);
|
|||
|
|
$region = $r['ok'] ? trim((string)$r['body']) : '';
|
|||
|
|
if ($region !== '' && !preg_match('#^[A-Za-z0-9-]+$#', $region)) $region = '';
|
|||
|
|
|
|||
|
|
setSettingJson('cache_ecs_region', ['region' => $region, 'expires' => time() + 3600]);
|
|||
|
|
return $memo = $region;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 腾讯云 CVM:获取 CAM 角色的临时密钥。
|
|||
|
|
*
|
|||
|
|
* @param string $roleName 指定的 CAM 角色名;留空则自动发现
|
|||
|
|
* @param bool $force 忽略缓存强制刷新
|
|||
|
|
* @return array ['ok'=>bool,'id'=>string,'secret'=>string,'token'=>string,'expires'=>int,'error'=>string]
|
|||
|
|
*/
|
|||
|
|
function tencentCvmCamCredentials($roleName = '', $force = false) {
|
|||
|
|
static $memo = [];
|
|||
|
|
$roleName = trim((string)$roleName);
|
|||
|
|
$memoKey = 'tencent:' . $roleName;
|
|||
|
|
if (!$force && isset($memo[$memoKey])) return $memo[$memoKey];
|
|||
|
|
|
|||
|
|
if (!$force) {
|
|||
|
|
$cached = getSettingJson('cache_meta_tencent', []);
|
|||
|
|
if (!empty($cached['expires']) && $cached['expires'] > time() + 300
|
|||
|
|
&& ($cached['role'] ?? '') === $roleName) {
|
|||
|
|
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 腾讯云元数据服务需要先获取会话 token
|
|||
|
|
$token = '';
|
|||
|
|
$t = storageHttp('PUT', 'http://metadata.tencentyun.com/latest/api/token',
|
|||
|
|
['X-ttc-metadata-token-ttl-seconds: 21600'], null, STORAGE_META_TIMEOUT);
|
|||
|
|
if ($t['ok'] && trim($t['body']) !== '') {
|
|||
|
|
$token = trim($t['body']);
|
|||
|
|
}
|
|||
|
|
$authHeader = $token !== '' ? ["X-ttc-metadata-token: {$token}"] : [];
|
|||
|
|
|
|||
|
|
$base = 'http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/';
|
|||
|
|
|
|||
|
|
if ($roleName === '') {
|
|||
|
|
$r = storageHttp('GET', $base, $authHeader, null, STORAGE_META_TIMEOUT);
|
|||
|
|
if (!$r['ok']) {
|
|||
|
|
return $memo[$memoKey] = tencentMetaFail($r, '无法访问 CVM 元数据服务');
|
|||
|
|
}
|
|||
|
|
$first = trim(strtok($r['body'], "\r\n"));
|
|||
|
|
if ($first === '') {
|
|||
|
|
return $memo[$memoKey] = tencentMetaFail(null, '该实例未绑定 CAM 角色');
|
|||
|
|
}
|
|||
|
|
$roleName = $first;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$r = storageHttp('GET', $base . rawurlencode($roleName), $authHeader, null, STORAGE_META_TIMEOUT);
|
|||
|
|
if (!$r['ok']) {
|
|||
|
|
return $memo[$memoKey] = tencentMetaFail($r, "获取 CAM 角色 {$roleName} 的临时密钥失败");
|
|||
|
|
}
|
|||
|
|
$json = json_decode($r['body'], true);
|
|||
|
|
if (!is_array($json) || empty($json['TmpSecretId']) || empty($json['TmpSecretKey'])) {
|
|||
|
|
$returned = is_array($json) ? ($json['Code'] ?? '') : '';
|
|||
|
|
return $memo[$memoKey] = tencentMetaFail(null, '元数据返回内容异常' . ($returned ? "({$returned})" : ''));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$cred = [
|
|||
|
|
'ok' => true,
|
|||
|
|
'id' => (string)$json['TmpSecretId'],
|
|||
|
|
'secret' => (string)$json['TmpSecretKey'],
|
|||
|
|
'token' => (string)($json['Token'] ?? ''),
|
|||
|
|
'expires' => !empty($json['ExpiredTime']) ? (int)$json['ExpiredTime'] : time() + 1800,
|
|||
|
|
'error' => '',
|
|||
|
|
'role' => $roleName,
|
|||
|
|
];
|
|||
|
|
setSettingJson('cache_meta_tencent', $cred);
|
|||
|
|
return $memo[$memoKey] = $cred;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tencentMetaFail($resp, $prefix) {
|
|||
|
|
$detail = $resp === null ? '' : ($resp['error'] ?? '');
|
|||
|
|
return [
|
|||
|
|
'ok' => false, 'id' => '', 'secret' => '', 'token' => '', 'expires' => 0,
|
|||
|
|
'error' => $prefix . ($detail ? ":{$detail}" : '')
|
|||
|
|
. '(请确认服务器位于腾讯云 CVM,且实例已绑定 CAM 角色;否则请改用 SecretId 方式)',
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除元数据凭据缓存(设置页"重新探测"时使用)
|
|||
|
|
function clearMetaCredentialCache() {
|
|||
|
|
deleteSetting('cache_meta_aliyun');
|
|||
|
|
deleteSetting('cache_meta_tencent');
|
|||
|
|
deleteSetting('cache_ecs_region');
|
|||
|
|
deleteSetting('cache_oss_internal');
|
|||
|
|
}
|