feat(server/ops): 运维动作真实下发内核、审计持久化与幂等去重
OpsController 由「假成功队列」改为经投影反射 API 真实下发 SimpleLite,按 appsettings 的 Ops:Dispatch 映射 opCode→kind:Method,并做白名单 + JWT ops claim 双重校验; 审计改用 OpsAuditStore 原子落盘(取代进程内队列,重启不丢),新增 IdempotencyKey 幂等去重; ExtractNumericId 取首段数字避免 AGV-12-3 误合并。appsettings 同步加 Ops:Dispatch 说明与 sl-route 鉴权策略。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MiGu.Server.Infra;
|
||||||
|
|
||||||
|
namespace MiGu.Server.Configs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 运维操作审计存储:内存队列 + <c>data/ops-audit.json</c> 原子持久化。
|
||||||
|
///
|
||||||
|
/// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢,
|
||||||
|
/// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘,
|
||||||
|
/// 仅保留最近 <see cref="MaxEntries"/> 条避免无限增长。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpsAuditStore
|
||||||
|
{
|
||||||
|
public sealed record AuditEntry(
|
||||||
|
string Id,
|
||||||
|
DateTimeOffset Ts,
|
||||||
|
string User,
|
||||||
|
string Scope,
|
||||||
|
string OpCode,
|
||||||
|
string Target,
|
||||||
|
string Result,
|
||||||
|
string? Message,
|
||||||
|
string? IdempotencyKey = null);
|
||||||
|
|
||||||
|
private const int MaxEntries = 500;
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly string _file;
|
||||||
|
private readonly ILogger<OpsAuditStore> _logger;
|
||||||
|
private readonly List<AuditEntry> _entries = new();
|
||||||
|
private long _seq;
|
||||||
|
|
||||||
|
private readonly JsonSerializerOptions _json = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
public OpsAuditStore(IWebHostEnvironment env, ILogger<OpsAuditStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
var dir = Path.Combine(env.ContentRootPath, "data");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
_file = Path.Combine(dir, "ops-audit.json");
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_file)) return;
|
||||||
|
var list = JsonSerializer.Deserialize<List<AuditEntry>>(File.ReadAllText(_file), _json);
|
||||||
|
if (list is { Count: > 0 })
|
||||||
|
{
|
||||||
|
_entries.AddRange(list.Count > MaxEntries ? list.GetRange(list.Count - MaxEntries, MaxEntries) : list);
|
||||||
|
// 续上序号,避免重启后 id 从 A000001 重新开始造成重复。
|
||||||
|
_seq = _entries.Select(e => ParseSeq(e.Id)).DefaultIfEmpty(0).Max();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "ops 审计文件 {File} 加载失败,将以空记录开始并备份损坏文件", _file);
|
||||||
|
AtomicFile.BackupCorrupt(_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ParseSeq(string id) => long.TryParse(id.TrimStart('A'), out var n) ? n : 0;
|
||||||
|
|
||||||
|
/// <summary>追加一条审计并原子落盘。返回生成的条目(含 Id)。</summary>
|
||||||
|
public AuditEntry Append(string user, string scope, string opCode, string target, string result, string? message, string? idempotencyKey = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = new AuditEntry(
|
||||||
|
$"A{++_seq:D6}", DateTimeOffset.UtcNow,
|
||||||
|
user, scope, opCode, target, result, message, idempotencyKey);
|
||||||
|
_entries.Add(entry);
|
||||||
|
if (_entries.Count > MaxEntries)
|
||||||
|
_entries.RemoveRange(0, _entries.Count - MaxEntries);
|
||||||
|
Persist();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。
|
||||||
|
/// 仅匹配成功记录:上次失败的请求允许重试重新下发。
|
||||||
|
/// </summary>
|
||||||
|
public AuditEntry? FindSuccessByIdempotencyKey(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
for (var i = _entries.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var e = _entries[i];
|
||||||
|
if (e.Result == "ok" && string.Equals(e.IdempotencyKey, key, StringComparison.Ordinal))
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>最近的审计(倒序,最新在前)。</summary>
|
||||||
|
public IReadOnlyList<AuditEntry> Recent()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var copy = new List<AuditEntry>(_entries);
|
||||||
|
copy.Reverse();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Persist()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Security.Claims;
|
||||||
using System.Security.Claims;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MiGu.Server.Auth;
|
||||||
|
using MiGu.Server.Configs;
|
||||||
|
using MiGu.Server.Launcher;
|
||||||
|
|
||||||
namespace MiGu.Server.Controllers;
|
namespace MiGu.Server.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 运维白名单网关(占位)。真实落地时按架构 §5.1 + §10.5:
|
/// 运维白名单网关。运营端(RCSMonitor)通过本控制器执行受控运维动作。
|
||||||
/// - 校验 scope=RCSMonitor 是否允许该 op;
|
|
||||||
/// - YARP 转发到 SimpleLite /api/ops/*;
|
|
||||||
/// - 写 OpsAuditLog(simple_main.db) + 写 OpsLogs(platform.db)。
|
|
||||||
///
|
///
|
||||||
/// AR-4: 全 class 加 [Authorize] —— 至少要求登录,再按 op 白名单 + JWT ops claim 双校验。
|
/// AR-4:[Authorize] 要求登录,再按 op 白名单 + JWT ops claim 双校验。
|
||||||
|
///
|
||||||
|
/// M4 修复(运维操作真实下发 + 审计落库):
|
||||||
|
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
|
||||||
|
/// “暂停成功”但内核毫无反应,且重启审计全丢);
|
||||||
|
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute,
|
||||||
|
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
|
||||||
|
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
|
||||||
|
///
|
||||||
|
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
|
||||||
|
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
|
||||||
|
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
|
||||||
|
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -20,10 +33,6 @@ public class OpsController : ControllerBase
|
|||||||
{
|
{
|
||||||
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
||||||
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||||
public record AuditEntry(string Id, DateTimeOffset Ts, string User, string Scope, string OpCode, string Target, string Result, string? Message);
|
|
||||||
|
|
||||||
private static readonly ConcurrentQueue<AuditEntry> Audits = new();
|
|
||||||
private static long _seq = 0;
|
|
||||||
|
|
||||||
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
@@ -32,14 +41,54 @@ public class OpsController : ControllerBase
|
|||||||
"ops.task.boostPriority", "monitor.note.write"
|
"ops.task.boostPriority", "monitor.note.write"
|
||||||
};
|
};
|
||||||
|
|
||||||
[HttpPost("execute")]
|
private readonly OpsAuditStore _audits;
|
||||||
public ActionResult<ExecuteResponse> Execute([FromBody] ExecuteRequest req)
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly InternalTokenStore _internalToken;
|
||||||
|
private readonly SimpleLiteOptions _sl;
|
||||||
|
private readonly ILogger<OpsController> _log;
|
||||||
|
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
|
||||||
|
|
||||||
|
public OpsController(
|
||||||
|
OpsAuditStore audits,
|
||||||
|
IHttpClientFactory httpFactory,
|
||||||
|
InternalTokenStore internalToken,
|
||||||
|
IOptions<SimpleLiteOptions> sl,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<OpsController> log)
|
||||||
{
|
{
|
||||||
|
_audits = audits;
|
||||||
|
_httpFactory = httpFactory;
|
||||||
|
_internalToken = internalToken;
|
||||||
|
_sl = sl.Value;
|
||||||
|
_log = log;
|
||||||
|
_dispatch = LoadDispatch(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从 appsettings <c>Ops:Dispatch</c> 读取 opCode → "kind:Method" 映射(忽略空值与 _ 注释键)。</summary>
|
||||||
|
private static IReadOnlyDictionary<string, (string, string)> LoadDispatch(IConfiguration config)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, (string, string)>(StringComparer.Ordinal);
|
||||||
|
foreach (var kv in config.GetSection("Ops:Dispatch").GetChildren())
|
||||||
|
{
|
||||||
|
var op = kv.Key;
|
||||||
|
var spec = kv.Value;
|
||||||
|
if (op.StartsWith('_') || string.IsNullOrWhiteSpace(spec)) continue;
|
||||||
|
var parts = spec.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length == 2 && parts[0].Length > 0 && parts[1].Length > 0)
|
||||||
|
map[op] = (parts[0], parts[1]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("execute")]
|
||||||
|
public async Task<ActionResult<ExecuteResponse>> Execute([FromBody] ExecuteRequest req)
|
||||||
|
{
|
||||||
|
if (req is null || string.IsNullOrWhiteSpace(req.OpCode))
|
||||||
|
return BadRequest(new { message = "opCode 不能为空" });
|
||||||
if (!Whitelist.Contains(req.OpCode))
|
if (!Whitelist.Contains(req.OpCode))
|
||||||
return BadRequest(new { message = $"非白名单 op:{req.OpCode}" });
|
return BadRequest(new { message = $"非白名单 op:{req.OpCode}" });
|
||||||
|
|
||||||
// AR-4: JWT ops claim 二次校验 —— JWT 颁发时已写入用户被授权的 ops 列表(空格分隔),
|
// AR-4:JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 特判通过。
|
||||||
// 这里要求 (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 会被特判通过。
|
|
||||||
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
||||||
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
|
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
|
||||||
@@ -48,17 +97,98 @@ public class OpsController : ControllerBase
|
|||||||
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
||||||
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||||
|
|
||||||
var id = $"A{Interlocked.Increment(ref _seq):D6}";
|
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
|
||||||
var entry = new AuditEntry(id, DateTimeOffset.UtcNow, user, scope,
|
if (!string.IsNullOrWhiteSpace(req.IdempotencyKey))
|
||||||
req.OpCode, req.TargetId, "ok", req.Reason);
|
{
|
||||||
Audits.Enqueue(entry);
|
var dup = _audits.FindSuccessByIdempotencyKey(req.IdempotencyKey);
|
||||||
while (Audits.Count > 200 && Audits.TryDequeue(out _)) { }
|
if (dup is not null)
|
||||||
return Ok(new ExecuteResponse(true, id, null));
|
return Ok(new ExecuteResponse(true, dup.Id, dup.Message ?? "幂等命中:已执行过相同请求,未重复下发"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitor.note.write:运营备注,非内核动作,仅审计。
|
||||||
|
if (req.OpCode == "monitor.note.write")
|
||||||
|
return Ok(Done(user, scope, req, "ok", req.Reason));
|
||||||
|
|
||||||
|
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
|
||||||
|
if (!_dispatch.TryGetValue(req.OpCode, out var map))
|
||||||
|
return Ok(Done(user, scope, req, "unmapped",
|
||||||
|
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
|
||||||
|
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
|
||||||
|
ok: false));
|
||||||
|
|
||||||
|
var numericId = ExtractNumericId(req.TargetId);
|
||||||
|
if (numericId is null)
|
||||||
|
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
|
||||||
|
|
||||||
|
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
|
||||||
|
string result;
|
||||||
|
string? message;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
|
||||||
|
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
|
||||||
|
using var client = _httpFactory.CreateClient();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(8);
|
||||||
|
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
|
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
|
||||||
|
if (!string.IsNullOrEmpty(_internalToken.Token))
|
||||||
|
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
|
||||||
|
using var resp = await client.SendAsync(msg);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
|
||||||
|
result = success ? "ok" : "failed";
|
||||||
|
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}:{ExtractMessage(body)}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = "failed";
|
||||||
|
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
|
||||||
|
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("audits")]
|
[HttpGet("audits")]
|
||||||
public IActionResult Audits200()
|
public IActionResult Audits200() => Ok(_audits.Recent());
|
||||||
|
|
||||||
|
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
|
||||||
|
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
|
||||||
{
|
{
|
||||||
return Ok(Audits.Reverse());
|
var entry = _audits.Append(user, scope, req.OpCode, req.TargetId, result, message ?? req.Reason, req.IdempotencyKey);
|
||||||
|
return new ExecuteResponse(ok, entry.Id, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
|
||||||
|
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
|
||||||
|
/// </summary>
|
||||||
|
private static int? ExtractNumericId(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(raw, @"\d+");
|
||||||
|
return m.Success && int.TryParse(m.Value, out var n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ParseSuccess(string body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
return doc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractMessage(string body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
|
||||||
|
return m.GetString() ?? "";
|
||||||
|
}
|
||||||
|
catch { /* ignore,下面回退裁剪原文 */ }
|
||||||
|
return body.Length <= 200 ? body : body[..200] + "…";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,16 @@
|
|||||||
"ops": { "Password": "ops" }
|
"ops": { "Password": "ops" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"_comment_Ops": "运维操作真实下发映射(M4)。opCode → 'kind:Method',Method 必须是 SimpleLite 反射 [MethodMember] 的真实方法名(车辆 kind=car,见 Car.cs:OnlineCar/OfflineCar/DisableCar/EnableCar/Repair/Blown/Reset 等;任务 kind=mission)。运营白名单的 pause/resume/gohome/manualCharge 内核暂无一一对应方法——留空则仅记审计并向前端如实返回『未下发』,按现场内核能力填写后即真实生效。示例: 'ops.car.gohome': 'car:Reset'",
|
||||||
|
"Ops": {
|
||||||
|
"Dispatch": {
|
||||||
|
}
|
||||||
|
},
|
||||||
"ReverseProxy": {
|
"ReverseProxy": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"sl-route": {
|
"sl-route": {
|
||||||
"ClusterId": "sl-cluster",
|
"ClusterId": "sl-cluster",
|
||||||
|
"AuthorizationPolicy": "AnyAuthed",
|
||||||
"Match": { "Path": "/api/sl/{**catch-all}" },
|
"Match": { "Path": "/api/sl/{**catch-all}" },
|
||||||
"Transforms": [
|
"Transforms": [
|
||||||
{ "PathRemovePrefix": "/api/sl" }
|
{ "PathRemovePrefix": "/api/sl" }
|
||||||
|
|||||||
Reference in New Issue
Block a user