新增 OTA WatchDog 编排与车队健康/报警后端。
覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
using System.Text.Json;
|
||||
using MiGu.Server.Infra;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Ota;
|
||||
|
||||
public sealed class OtaStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly ILogger<OtaStore> _log;
|
||||
private readonly JsonSerializerOptions _json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public string Root { get; }
|
||||
public string PackagesDir { get; }
|
||||
public string JobsDir { get; }
|
||||
public string HistoryDir { get; }
|
||||
public string TargetFile { get; }
|
||||
public string SettingsFile { get; }
|
||||
|
||||
private string? _lastPullId;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _jobSeq;
|
||||
|
||||
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
|
||||
{
|
||||
_log = log;
|
||||
var cfg = options.Value.DataRoot;
|
||||
Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg);
|
||||
PackagesDir = Path.Combine(Root, "packages");
|
||||
JobsDir = Path.Combine(Root, "jobs");
|
||||
HistoryDir = Path.Combine(Root, "history");
|
||||
TargetFile = Path.Combine(Root, "target.json");
|
||||
SettingsFile = Path.Combine(Root, "settings.json");
|
||||
Directory.CreateDirectory(PackagesDir);
|
||||
Directory.CreateDirectory(JobsDir);
|
||||
Directory.CreateDirectory(HistoryDir);
|
||||
}
|
||||
|
||||
public OtaSettings GetSettings()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA settings load failed");
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public OtaSettings SaveSettings(OtaSettings settings)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget? GetTarget()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(TargetFile)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaTarget>(File.ReadAllText(TargetFile), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "OTA target load failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTarget(OtaTarget target)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json));
|
||||
var settings = GetSettingsUnlocked();
|
||||
if (!string.IsNullOrWhiteSpace(target.Name))
|
||||
{
|
||||
settings.NewVersionName = target.Name;
|
||||
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OtaSettings GetSettingsUnlocked()
|
||||
{
|
||||
if (!File.Exists(SettingsFile)) return new OtaSettings();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new OtaSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public string BeginPullPackage(string? sourceIp)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})";
|
||||
var id = baseId;
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
for (var i = 1; Directory.Exists(dir); i++)
|
||||
{
|
||||
id = $"{baseId}-{i:D2}";
|
||||
dir = Path.Combine(PackagesDir, id);
|
||||
}
|
||||
Directory.CreateDirectory(dir);
|
||||
foreach (var app in new[] { "M", "D", "C" })
|
||||
Directory.CreateDirectory(Path.Combine(dir, app));
|
||||
Directory.CreateDirectory(Path.Combine(dir, "M", "plugins"));
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null) _pullByIp[ip] = id;
|
||||
_lastPullId = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ActivePullId
|
||||
{
|
||||
get { lock (_gate) return _lastPullId; }
|
||||
}
|
||||
|
||||
public bool TryGetActivePullId(string? sourceIp, out string? id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
|
||||
{
|
||||
id = byIp;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ip == null && _lastPullId != null)
|
||||
{
|
||||
id = _lastPullId;
|
||||
return true;
|
||||
}
|
||||
|
||||
id = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearActivePull(string? sourceIp = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var ip = NormalizeIp(sourceIp);
|
||||
if (ip != null) _pullByIp.TryRemove(ip, out _);
|
||||
if (_pullByIp.IsEmpty) _lastPullId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
|
||||
private static string? NormalizeIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip)) return null;
|
||||
if (System.Net.IPAddress.TryParse(ip, out var addr))
|
||||
return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString();
|
||||
return ip.Trim();
|
||||
}
|
||||
|
||||
public string ResolveReceivePath(string routeKey, string? clientIp = null)
|
||||
{
|
||||
// routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll
|
||||
// Do not fall back to the latest session for a known-but-unregistered client.
|
||||
if (!TryGetActivePullId(clientIp, out var id) || id == null)
|
||||
throw new InvalidOperationException("无进行中的拉取会话");
|
||||
var dir = Path.Combine(PackagesDir, id);
|
||||
return routeKey.ToLowerInvariant() switch
|
||||
{
|
||||
"medullaexe" => Path.Combine(dir, "M", "Medulla.exe"),
|
||||
"medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"),
|
||||
"medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"),
|
||||
"detourexe" => Path.Combine(dir, "D", "Detour.exe"),
|
||||
"clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"),
|
||||
"clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"),
|
||||
"clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"),
|
||||
_ => throw new ArgumentException($"未知接收路由: {routeKey}")
|
||||
};
|
||||
}
|
||||
|
||||
public OtaPackageInfo ScanPackage(string id)
|
||||
{
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id);
|
||||
var info = new OtaPackageInfo
|
||||
{
|
||||
Id = id,
|
||||
CreatedAt = Directory.GetCreationTimeUtc(dir),
|
||||
SourceIp = ExtractSourceIp(id)
|
||||
};
|
||||
long total = 0;
|
||||
foreach (var key in OtaPathMap.ComponentKeys)
|
||||
{
|
||||
var rel = OtaPathMap.RelPathFor(key);
|
||||
if (rel == null) continue;
|
||||
var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(path)) continue;
|
||||
var fi = new FileInfo(path);
|
||||
total += fi.Length;
|
||||
info.Components[key] = new OtaFileArtifact
|
||||
{
|
||||
Hash = OtaHash.OfFile(path),
|
||||
Path = path,
|
||||
FileName = Path.GetFileName(path),
|
||||
Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
Size = fi.Length
|
||||
};
|
||||
}
|
||||
info.TotalBytes = total;
|
||||
var target = GetTarget();
|
||||
info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal);
|
||||
return info;
|
||||
}
|
||||
|
||||
public List<OtaPackageInfo> ListPackages()
|
||||
{
|
||||
if (!Directory.Exists(PackagesDir)) return new();
|
||||
var list = new List<OtaPackageInfo>();
|
||||
foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d))
|
||||
{
|
||||
try { list.Add(ScanPackage(Path.GetFileName(dir))); }
|
||||
catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public void DeletePackage(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var target = GetTarget();
|
||||
if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("不能删除当前目标版本");
|
||||
var dir = ResolveUnder(PackagesDir, id);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
public OtaTarget ActivatePackage(string id, string? name)
|
||||
{
|
||||
var pkg = ScanPackage(id);
|
||||
if (pkg.Components.Count == 0)
|
||||
throw new InvalidOperationException("包内无有效组件文件");
|
||||
var safeId = RequireSafeId(id);
|
||||
var target = new OtaTarget
|
||||
{
|
||||
PackageId = safeId,
|
||||
Name = name ?? safeId,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
Components = pkg.Components
|
||||
};
|
||||
SetTarget(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
public string PackageDir(string id) => ResolveUnder(PackagesDir, id);
|
||||
|
||||
public void SaveJob(OtaJob job)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json");
|
||||
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json));
|
||||
}
|
||||
}
|
||||
|
||||
public OtaJob? GetJob(string id)
|
||||
{
|
||||
var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(path), _json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "job load failed {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public List<OtaJob> ListJobs(int take = 100)
|
||||
{
|
||||
if (!Directory.Exists(JobsDir)) return new();
|
||||
return Directory.GetFiles(JobsDir, "*.json")
|
||||
.Select(f =>
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
|
||||
catch { return null; }
|
||||
})
|
||||
.Where(j => j != null)
|
||||
.Cast<OtaJob>()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string NextJobId()
|
||||
{
|
||||
// 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。
|
||||
var seq = System.Threading.Interlocked.Increment(ref _jobSeq);
|
||||
return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}";
|
||||
}
|
||||
|
||||
private static string? ExtractSourceIp(string id)
|
||||
{
|
||||
var open = id.IndexOf('(');
|
||||
var close = id.IndexOf(')');
|
||||
if (open >= 0 && close > open) return id[(open + 1)..close];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SafeIdPart(string raw)
|
||||
{
|
||||
var safe = raw.Trim();
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
safe = safe.Replace(ch, '_');
|
||||
return string.IsNullOrWhiteSpace(safe) ? "upload" : safe;
|
||||
}
|
||||
|
||||
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
|
||||
private static string RequireSafeId(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("无效标识");
|
||||
var trimmed = id.Trim();
|
||||
if (trimmed is "." or ".."
|
||||
|| trimmed.Contains('/') || trimmed.Contains('\\')
|
||||
|| trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new ArgumentException("无效标识");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private string ResolveUnder(string root, string id)
|
||||
{
|
||||
var safe = RequireSafeId(id);
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var full = Path.GetFullPath(Path.Combine(fullRoot, safe));
|
||||
var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("无效标识");
|
||||
return full;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user