修复登录自启的权限问题:低完整性目录、日志兜底与数据目录自检
登录任务本身没问题(InteractiveToken + HighestAvailable + --minimized), ICACLS 也没有拒绝 ACE;真因是 exe 所在的 D:\dsh workspace 带 Low 强制标签, Windows 用低完整性令牌运行这里的 exe,它写不了中完整性的 %LOCALAPPDATA%, 于是第一步写日志就 UnauthorizedAccessException,托盘起不来。 - FileLog 改为 fail-safe:首选目录失败依次回退 LocalLow / TEMP / ProgramData, 缓存待写行,永不抛异常,暴露 ActiveDirectory 与 LastFailure; - 启动自检增加数据目录可写性探测,致命对话框写出真实日志目录与低完整性判断; - 新增 --logcheck 只读自检:输出程序路径、数据目录可写性、实际日志目录与结论; - README 增加放置目录说明,validation.md 记录第八轮; - 新增两条 FileLog 回归测试(本地 49/49,探测 16/16,Release 0 警告 0 错误)。
This commit is contained in:
@@ -35,21 +35,102 @@ public sealed class JsonStore(string directory)
|
||||
public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 追加式日志。日志本身永远不能让程序停下:首选目录写不进去就换下一个候选目录,
|
||||
/// 并把失败原因(路径 + 异常类型 + Win32 错误码)记在 <see cref="LastFailure"/> 里,
|
||||
/// 由界面/诊断输出展示,而不是把异常抛给启动流程。
|
||||
/// </summary>
|
||||
public sealed class FileLog(string directory)
|
||||
{
|
||||
private const int PendingLimit = 200;
|
||||
private readonly object _gate = new();
|
||||
private readonly List<string> _pending = [];
|
||||
private string? _active;
|
||||
private string? _failure;
|
||||
|
||||
/// <summary>当前真正写入的日志目录(首选目录可用时就是构造参数)。</summary>
|
||||
public string ActiveDirectory { get { lock (_gate) return _active ?? directory; } }
|
||||
|
||||
/// <summary>最近一次写入失败的原因;全部候选都失败时才非空。</summary>
|
||||
public string? LastFailure { get { lock (_gate) return _failure; } }
|
||||
|
||||
public void Write(string message)
|
||||
{
|
||||
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}";
|
||||
lock (_gate)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var today = DateTime.Now;
|
||||
File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}");
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log"))
|
||||
if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
|
||||
try { File.Delete(file); } catch (IOException) { }
|
||||
_pending.Add(line);
|
||||
if (_pending.Count > PendingLimit) _pending.RemoveRange(0, _pending.Count - PendingLimit);
|
||||
TryFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryFlush()
|
||||
{
|
||||
var failures = new List<string>(3);
|
||||
foreach (var candidate in Candidates())
|
||||
{
|
||||
if (Append(candidate, _pending, out var error))
|
||||
{
|
||||
var switched = _active is not null && !string.Equals(_active, candidate, StringComparison.OrdinalIgnoreCase);
|
||||
_active = candidate;
|
||||
_pending.Clear();
|
||||
_failure = failures.Count == 0 ? null : string.Join(";", failures);
|
||||
if (switched)
|
||||
Append(candidate, [$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] 日志目录改为 {candidate}{Environment.NewLine}"], out _);
|
||||
Prune(candidate);
|
||||
return;
|
||||
}
|
||||
failures.Add(error!);
|
||||
}
|
||||
_failure = string.Join(";", failures);
|
||||
}
|
||||
|
||||
private IEnumerable<string> Candidates()
|
||||
{
|
||||
yield return directory;
|
||||
// 低完整性(低 IL)进程写不了 LOCALAPPDATA/TEMP,但 LocalLow 正是为它们准备的。
|
||||
foreach (var low in LowIntegrityDirectories()) yield return low;
|
||||
var temp = Environment.GetEnvironmentVariable("TEMP");
|
||||
if (!string.IsNullOrWhiteSpace(temp)) yield return Path.Combine(temp, "GatewayKeepalive", "logs");
|
||||
var common = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(common)) yield return Path.Combine(common, "GatewayKeepalive", "logs");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> LowIntegrityDirectories()
|
||||
{
|
||||
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrWhiteSpace(profile)) yield return Path.Combine(profile, "AppData", "LocalLow", "GatewayKeepalive", "logs");
|
||||
yield return Path.Combine(Path.GetTempPath(), "Low", "GatewayKeepalive", "logs");
|
||||
}
|
||||
|
||||
private static bool Append(string target, List<string> lines, out string? error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
var path = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log");
|
||||
File.AppendAllText(path, string.Concat(lines));
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException or ArgumentException)
|
||||
{
|
||||
error = $"{target}({exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()})";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Prune(string target)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(target, "gateway-*.log"))
|
||||
if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14))
|
||||
try { File.Delete(file); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException) { }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt);
|
||||
|
||||
@@ -21,9 +21,29 @@ internal static class Program
|
||||
return 0;
|
||||
}
|
||||
if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult();
|
||||
if (args.Contains("--logcheck"))
|
||||
{
|
||||
// 只验证“这个身份、这个时机能不能写日志”,不启动托盘、不申请提权。
|
||||
using var who = WindowsIdentity.GetCurrent();
|
||||
var target = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "logs");
|
||||
var check = new FileLog(target);
|
||||
check.Write($"日志自检:用户={who.Name} 管理员={new WindowsPrincipal(who).IsInRole(WindowsBuiltInRole.Administrator)}");
|
||||
var dataDir = Path.GetDirectoryName(target)!;
|
||||
var dataWritable = CanWrite(dataDir, out var dataError);
|
||||
Console.WriteLine($"程序路径={Environment.ProcessPath}");
|
||||
Console.WriteLine($"数据目录={dataDir} 可写={dataWritable}" + (dataWritable ? string.Empty : $" 原因={dataError}"));
|
||||
var fellBack = !string.Equals(Path.GetFullPath(check.ActiveDirectory), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase);
|
||||
var verdict = check.LastFailure is null
|
||||
? "写入成功(首选目录)"
|
||||
: fellBack ? "写入成功(首选目录失败,已回退)" : "写入失败";
|
||||
Console.WriteLine($"首选目录={target}");
|
||||
Console.WriteLine($"实际目录={check.ActiveDirectory}");
|
||||
Console.WriteLine($"结论={verdict}" + (check.LastFailure is null ? string.Empty : $";首选原因={check.LastFailure}"));
|
||||
return check.LastFailure is null || fellBack ? 0 : 1;
|
||||
}
|
||||
if (args.Contains("--help"))
|
||||
{
|
||||
MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
|
||||
MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--logcheck:检查当前身份能否写日志(控制台输出)\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活");
|
||||
return 0;
|
||||
}
|
||||
var backend = new WindowsNetworkBackend();
|
||||
@@ -62,16 +82,30 @@ internal static class Program
|
||||
var logger = new FileLog(Path.Combine(directory, "logs"));
|
||||
try
|
||||
{
|
||||
var writable = CanWrite(directory, out var writeError);
|
||||
logger.Write($"数据目录:{directory} 可写={writable}" + (writable ? string.Empty : $"({writeError})") +
|
||||
(string.Equals(logger.ActiveDirectory, Path.Combine(directory, "logs"), StringComparison.OrdinalIgnoreCase) ? string.Empty : $" 日志改写至:{logger.ActiveDirectory}"));
|
||||
if (!writable && !args.Contains("--minimized"))
|
||||
{
|
||||
// 只砸在交互式启动上;登录自启别弹窗,日志里有记录就够了。
|
||||
MessageBox.Show(
|
||||
"无法写入数据目录:" + directory + "\n原因:" + writeError +
|
||||
"\n\n最常见的原因是程序放在了低完整性(沙箱)目录里,例如 D:\\dsh workspace 下的产物:" +
|
||||
"Windows 会用低完整性令牌运行这里的 exe,于是写不进当前用户目录。\n\n" +
|
||||
"处理办法:把 GatewayKeepalive.exe 复制到普通目录(例如 %LOCALAPPDATA%\\Programs\\GatewayKeepalive)后再运行," +
|
||||
"然后在设置里重新开启一次开机自启。\n\n本次日志写到:" + logger.ActiveDirectory,
|
||||
"网关保活", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
var store = new JsonStore(directory);
|
||||
var startup = new LoginStartup(logger.Write);
|
||||
// 每次启动先留一行自检:出问题时光看日志就知道是不是提权/自启任务的问题。
|
||||
try
|
||||
{
|
||||
logger.Write($"启动自检:管理员={backend.IsAdministrator} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))}");
|
||||
logger.Write($"启动自检:管理员={backend.IsAdministrator} 用户={identity.Name} 登录自启={startup.IsEnabled} 进程={Environment.ProcessPath} 参数={(args.Length == 0 ? "(无)" : string.Join(' ', args))} 日志目录={logger.ActiveDirectory}");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.Write("启动自检失败:" + exception.Message);
|
||||
logger.Write("启动自检失败:" + exception);
|
||||
}
|
||||
// 校正旧任务指向要连 COM、必要时还会拉起 schtasks,放到后台做,别让托盘图标准备好之前先卡住。
|
||||
_ = Task.Run(() =>
|
||||
@@ -104,13 +138,36 @@ internal static class Program
|
||||
catch (Exception e)
|
||||
{
|
||||
try { logger.Write("启动/退出失败:" + e); } catch (Exception) { }
|
||||
MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
var logNote = logger.LastFailure is null ? "" : "\n日志写入问题:" + logger.LastFailure + "\n实际日志目录:" + logger.ActiveDirectory;
|
||||
MessageBox.Show("程序无法继续:" + e.Message + logNote + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel();
|
||||
|
||||
/// <summary>
|
||||
/// 判断数据目录是否真的能写。低完整性进程(例如 exe 落在沙箱目录里)会在
|
||||
/// LOCALAPPDATA 上拿到 UnauthorizedAccessException,这不是配置问题,得说清楚。
|
||||
/// </summary>
|
||||
private static bool CanWrite(string directory, out string error)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var probe = Path.Combine(directory, $".write-probe-{Environment.ProcessId}");
|
||||
File.WriteAllText(probe, "ok");
|
||||
File.Delete(probe);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException)
|
||||
{
|
||||
error = $"{exception.GetType().Name} 0x{exception.HResult:X8} {exception.Message.Trim()}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-file apps extract the WinForms theming manifest to the temp directory. If that
|
||||
/// location is restricted, visual styles are decorative only for this fully custom-drawn UI;
|
||||
|
||||
Reference in New Issue
Block a user