覆盖包库回传、任务下发、CDM 任务同步与报警采集,并为包/任务 ID 与上传文件名加上路径安全校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
369 lines
12 KiB
C#
369 lines
12 KiB
C#
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;
|
|
}
|
|
}
|