修复登录自启的权限问题:低完整性目录、日志兜底与数据目录自检

登录任务本身没问题(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:
chuzhongzai
2026-10-24 11:18:09 +08:00
parent 7be117057a
commit 545d96174d
6 changed files with 222 additions and 10 deletions
+87 -6
View File
@@ -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);
+61 -4
View File
@@ -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;