diff --git a/README.md b/README.md index fc97ff1..0b98a01 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时 手动启动时一定会弹出面板(登录任务用 `--minimized` 启动,只驻留托盘),不会再让人以为程序没启动而反复双击。 +### 放在哪个目录运行 + +程序要改路由和 DNS,会以管理员身份运行,请把它放在自己的用户目录下,例如 `%LOCALAPPDATA%\Programs\GatewayKeepalive\`。 + +不要从“低完整性”目录(沙箱、共享、被打了 Low 标签的目录,例如本机开发目录 `D:\dsh workspace` 下的产物)启动:Windows 会用低完整性令牌运行那里的 exe,它连自己的 `%LOCALAPPDATA%` 都写不了,登录自启就会停在“Access to the path … is denied”。判断办法是跑一次 `.\GatewayKeepalive.exe --logcheck`,看输出里的“数据目录 … 可写=True/False”和“实际目录”。程序也有兜底:首选日志目录写不了会依次退到 `%LOCALAPPDATA%Low`、`%TEMP%`、`%ProgramData%`,并在日志里写明“日志目录改为 …”,但数据目录(`session.json`、`settings.json`)必须是可写的用户目录。 + - 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。 - 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。 - 暂停:继续监测,停止自动改网络。 diff --git a/docs/validation.md b/docs/validation.md index b28259d..d106826 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -2,6 +2,29 @@ 日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。 +## 第八轮修复(2026-10-24,重启后登录自启报“Access to the path … is denied”) + +症状:重启电脑后没有托盘图标,只弹一个对话框「程序无法继续:Access to the path 'C:\Users\emm\AppData\Local\GatewayKeepalive\logs\gateway-2026-10-24.log' is denied.」。 + +排查与真因: + +- 计划任务本身没问题:`schtasks /Query /XML` 显示 `UserId=S-1-5-21-1629374972-835245509-3346384658-1001`、`LogonType=InteractiveToken`、`RunLevel=HighestAvailable`、动作 `GatewayKeepalive.exe --minimized`,触发器等登录用户。`schtasks /Run` 能复现同样的对话框。 +- 权限表也没问题:日志目录、日志文件、以及 `C:\Users\emm\AppData\Local` 三层 `icacls` 全都有 `DESKTOP\emm:(F)`,没有被拒绝的 ACE;同样的脚本用同一个账号可以写。 +- 真因在**所在目录的完整性级别**:`D:\dsh workspace` 被打了 Low 强制标签(`icacls` → `Mandatory Label\Low Mandatory Level:(OI)(CI)(NW)`),存在这里的 exe 会被 Windows 用**低完整性令牌**运行。低完整性进程写不了中完整性的 `%LOCALAPPDATA%`,于是第一步写日志就抛 `UnauthorizedAccessException`。把同一个 exe 复制到 `%LOCALAPPDATA%\Programs\GatewayKeepalive\` 再跑,`--logcheck` 立刻变成“数据目录 可写=True / 结论=写入成功”。 +- 也就是说:这不是提权问题、不是任务配置问题、不是 ACL 问题,而是“程序放在哪运行”。修了两头——给出正确的放置位置,并让程序不再因为写不了日志而整个起不来。 + +修法: + +- `FileLog` 改成 fail-safe:写入先进缓冲区,按「首选目录 → `%LOCALAPPDATA%Low` → `%TEMP%` → `%ProgramData%`」依次尝试,任何一步失败都不抛异常;`ActiveDirectory` / `LastFailure` 暴露真实落点与失败原因,切换目录时在日志里写一行“日志目录改为 …”。 +- 启动时探测数据目录是否真的可写(建临时文件再删),自检行里带上「数据目录=… 可写=… 日志目录=…」;致命对话框不再只说一句错误,而是写出实际生效的日志目录与“程序可能放在低完整性目录里”的判断。 +- 新增 `--logcheck`:只用当前身份验证“能不能写日志/数据目录”,不起托盘、不提权,输出「程序路径 / 数据目录 可写 / 首选目录 / 实际目录 / 结论」,方便用户自查。 + +本轮验证: + +- 新增两条回归测试:首选目录可写时就用首选且不记失败;首选目录不可用(被同名文件占住)时不抛异常、回退到可用目录并把失败原因写进 `LastFailure`。本地测试总数 47 → 49,全绿;探测离线测试 16/16;Release 构建 0 警告 0 错误。 +- 实测三种落点:`%LOCALAPPDATA%\Programs\GatewayKeepalive\GatewayKeepalive.exe --logcheck` → 可写=True、实际目录=`…\AppData\Local\GatewayKeepalive\logs`、写入成功;`artifacts\publish`(低完整性)下的同一 exe → 可写=False、实际目录=`…\AppData\LocalLow\GatewayKeepalive\logs`、写入成功(已回退),日志里确有该行。 +- **未验证**:桌面托盘与登录任务仍需把 exe 换到用户目录后由用户重启确认一次(本机命令行没有提权能力,不能改计划任务的动作路径)。 + ## 第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断) 症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。 diff --git a/src/GatewayKeepalive.Core/Persistence.cs b/src/GatewayKeepalive.Core/Persistence.cs index 6b4b6ee..d564bd1 100644 --- a/src/GatewayKeepalive.Core/Persistence.cs +++ b/src/GatewayKeepalive.Core/Persistence.cs @@ -35,21 +35,102 @@ public sealed class JsonStore(string directory) public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file)); } +/// +/// 追加式日志。日志本身永远不能让程序停下:首选目录写不进去就换下一个候选目录, +/// 并把失败原因(路径 + 异常类型 + Win32 错误码)记在 里, +/// 由界面/诊断输出展示,而不是把异常抛给启动流程。 +/// public sealed class FileLog(string directory) { + private const int PendingLimit = 200; private readonly object _gate = new(); + private readonly List _pending = []; + private string? _active; + private string? _failure; + + /// 当前真正写入的日志目录(首选目录可用时就是构造参数)。 + public string ActiveDirectory { get { lock (_gate) return _active ?? directory; } } + + /// 最近一次写入失败的原因;全部候选都失败时才非空。 + 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(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 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 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 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); diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs index f6c8623..8672f6f 100644 --- a/src/GatewayKeepalive.Tray/Program.cs +++ b/src/GatewayKeepalive.Tray/Program.cs @@ -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(); + /// + /// 判断数据目录是否真的能写。低完整性进程(例如 exe 落在沙箱目录里)会在 + /// LOCALAPPDATA 上拿到 UnauthorizedAccessException,这不是配置问题,得说清楚。 + /// + 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; + } + } + /// /// 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; diff --git a/tests/GatewayKeepalive.Tests/LogFallbackTests.cs b/tests/GatewayKeepalive.Tests/LogFallbackTests.cs new file mode 100644 index 0000000..27c78e6 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/LogFallbackTests.cs @@ -0,0 +1,43 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class LogFallbackTests +{ + public static Task WritablePrimaryDirectoryIsUsedAsync() + { + using var temp = new TempDirectory(); + var target = Path.Combine(temp.DirectoryPath, "logs"); + var log = new FileLog(target); + + log.Write("测试:首选目录可写"); + + Check.Equal(Path.GetFullPath(target), Path.GetFullPath(log.ActiveDirectory), "A writable primary directory must stay active"); + Check.True(log.LastFailure is null, "No failure should be recorded when the primary directory works"); + var file = Path.Combine(target, $"gateway-{DateTime.Now:yyyy-MM-dd}.log"); + Check.True(File.Exists(file), "The log file should exist in the primary directory"); + Check.Contains("测试:首选目录可写", File.ReadAllText(file), "The line should land in the primary log"); + return Task.CompletedTask; + } + + public static Task UnusablePrimaryDirectoryFallsBackAsync() + { + using var temp = new TempDirectory(); + var blocked = Path.Combine(temp.DirectoryPath, "blocked"); + File.WriteAllText(blocked, "a file, so no directory can be created here"); + var log = new FileLog(blocked); + + log.Write("测试:日志回退"); + + Check.True( + !string.Equals(Path.GetFullPath(blocked), Path.GetFullPath(log.ActiveDirectory), StringComparison.OrdinalIgnoreCase), + "An unusable primary directory must be replaced by a fallback"); + Check.True(log.LastFailure is not null, "The unusable primary directory should be reported as a failure"); + Check.Contains("blocked", log.LastFailure!, "The failure text should name the unusable directory"); + + var fallback = Path.Combine(log.ActiveDirectory, $"gateway-{DateTime.Now:yyyy-MM-dd}.log"); + Check.True(File.Exists(fallback), "The line should be written to the fallback directory"); + Check.Contains("测试:日志回退", File.ReadAllText(fallback), "The line should be present in the fallback log"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/Program.cs b/tests/GatewayKeepalive.Tests/Program.cs index 8922bc2..8ee5dbd 100644 --- a/tests/GatewayKeepalive.Tests/Program.cs +++ b/tests/GatewayKeepalive.Tests/Program.cs @@ -50,6 +50,8 @@ internal static class Program ("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync), ("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync), ("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync), + ("FileLog: a writable primary directory is used and reports no failure", LogFallbackTests.WritablePrimaryDirectoryIsUsedAsync), + ("FileLog: an unusable primary directory falls back instead of throwing", LogFallbackTests.UnusablePrimaryDirectoryFallsBackAsync), ("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync), ("LoginStartup: logon task XML starts minimized with highest privileges", LoginStartupTests.LogonTaskXmlAsync), ("LoginStartup: missing-task detection follows HRESULT, not exception type", LoginStartupTests.TaskMissingDetectionAsync),