v5.8: service-folder repair — fix the 5.6 file-wedging ACL bug + record the real user through elevation
Root cause found while REPRODUCING the suspected wrong-owner bug in a gate test (the repro failed in a way the theory couldn't explain, and the empirical icacls run showed why): the 5.6 hardening's /T sweep applied (OI)(CI) grants to FILES, where such ACEs are inherit-only — they grant the file itself NOTHING. Every file existing at harden time was left with /inheritance:r + inherit-only ACEs = an effectively EMPTY ACL: unreadable/unwritable by the user, admins, even SYSTEM. That is the Jonathan report (2026-08-06) end to end: profile save "access denied" even elevated, logs unreadable in Notepad, his own icacls /T "fix" adding useless inherit-only ACEs (137 processed, nothing healed), new files fine (echo test), and the service failing to start (SYSTEM can't read a wedged profile). The fix, in layers: - BuildServiceDirAclArgs no longer sweeps /T: the lockdown applies to the FOLDER only. Existing children are rebuilt by a new /reset pass (BuildResetChildrenArgs) as purely- inherited from the hardened folder ACL — real file access again, stale/planted explicit ACEs removed, and it HEALS files wedged by 5.6. Regression-pinned in the gate (the folder args must never contain /T again). - Second bug fixed in the same area: the elevated helper recorded ITS OWN token as the "installing user" — under over-the-shoulder elevation that's the separate admin account whose password was typed, not the person at the keyboard. Elevated verbs now carry --as-user <SID> from the non-elevated app (validated: real user SIDs only — service identities and builtin groups rejected) and install/repair re-record it, so a stale wrong owner can't persist through reinstalls or self-update re-hardens. - Self-heal everywhere: new --repair-service-access verb (re-record owner + re-harden); "Repair service folder access" in the Service menu; a startup write-probe that offers the repair when the folder is broken (settled startup sequence, ForegroundDialog, skipped on --silent); the profile-save UnauthorizedAccessException catch offers it at the exact wall users hit; and the service self-update's existing re-harden now runs the FIXED sequence, so wedged fleet machines heal automatically when 5.8 rolls out — no user action needed. - Logs readable again: Users get read-only on service\logs (inheritable, no /T needed — propagation covers existing files) and on service-events.log. The profile stays locked (it holds the obfuscated password). GrantUsersWriteToBin deleted — the folder ACL's inherited user-Modify covers bin, and the reset wiped its explicit grants anyway. - DoStart/DoStop no longer swallow the reason: exception recorded to service events, and distinct exit codes (6 timeout, 7 SCM refused, 9 repair-didn't-stick) let the dialog say what happened instead of the bare "(code 1)" that cost this diagnosis a day. Gate: new "Service folder repair" step reproduces BOTH bugs for real in a scratch folder — wedges a file with the exact 5.6 spec (proves reads genuinely die), locks the folder to the wrong owner, asserts the app's probe reports broken, runs the exact shipped repair sequence (ApplyServiceDirAcl), and asserts folder writable + wedged file readable again. Plus a SID pass-through step (validation, parse, arg-building, logs-grant shape). 72/72 + relay 7. Docs: Service-menu repair item + troubleshooting entry in readme; About + RELEASE_NOTES rewritten for 5.8; MANUAL regenerated. Version 5.8. NOT released — awaiting Ed's test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1363,6 +1363,7 @@ Everything lives in the **Service** menu on the menu bar:
|
||||
2. **Install service** — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to **start it now** (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up — and start it — for you, so you may have done this already.)
|
||||
3. **Start service** / **Stop service** — run or halt it now without waiting for a reboot.
|
||||
4. **Uninstall service** — removes it entirely.
|
||||
5. **Repair service folder access** — fixes the permissions on the service's settings folder so your account can save the service profile and read the service logs again. You should never need this in normal use, but if saving the service profile ever fails with an “access denied” message, or the service's log files won't open, run this once (Windows asks for administrator permission) and everything is put right. RemSound also checks the folder itself every time it starts and offers this same repair automatically if it finds a problem.
|
||||
|
||||
|
||||
|
||||
@@ -1456,6 +1457,10 @@ RemSound notices when the computer has just woken up, waits a moment for any USB
|
||||
|
||||
If a sound card you're playing received audio through is unplugged and then plugged back in, RemSound now re-opens it on its own and the sound resumes — you don't have to re-tick it in the output list. This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket. If you move it to a different USB socket and Windows treats it as a brand-new device, just tick it again in the output list.
|
||||
|
||||
### Saving the service profile says “access denied”, or the service's log files won't open
|
||||
|
||||
Your account has lost its permissions on the service's settings folder — this could happen after some older-version reinstalls, and it also blocks the service itself from starting properly. The fix is one menu item: **Service → Repair service folder access**. Windows asks for administrator permission once, the folder is put right, and saving and log-reading work again. RemSound also spots this state by itself when it starts and offers the same repair automatically, and the service applies the fix on its own when it installs a RemSound update — so on an up-to-date machine you should never actually meet this problem.
|
||||
|
||||
### UPnP says “no router found” even though my router supports it
|
||||
|
||||
The most common reasons:
|
||||
|
||||
+10
-21
@@ -1,28 +1,17 @@
|
||||
# RemSound v5.7
|
||||
# RemSound v5.8
|
||||
|
||||
Stronger security, and it works with every version again.
|
||||
A repair for the lock-screen service's settings folder.
|
||||
|
||||
This release puts the password handling back the way it was, so RemSound talks to older versions and the iPhone app again. You still set a password — your audio is always encrypted — RemSound just suggests a strong one now instead of requiring it. If you already use a good password, you won't notice any difference.
|
||||
Some machines ended up with the service's settings folder locked so tightly that nothing could use it — saving the service profile failed with "access denied", the service's log files wouldn't open even in Notepad, and on some machines the service itself couldn't start. It came from a permissions bug in a recent release, and reinstalling didn't clear it.
|
||||
|
||||
## Signed updates
|
||||
This release fixes the cause and heals affected machines automatically:
|
||||
|
||||
Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us — so even if the download page were ever tampered with, a fake update couldn't install itself on your machine.
|
||||
|
||||
## Remote volume, password-protected
|
||||
|
||||
The remote volume and mute controls are locked to your password, so only someone who shares it can use them. (Both ends need 5.6 or newer for the remote-volume feature; ordinary audio works with any version.)
|
||||
|
||||
## Set the machine's volume when the service starts
|
||||
|
||||
In the service's Additional options you can have an unattended machine unmute itself and set its Windows volume to a level you choose — on the first start after each boot, or on every service start.
|
||||
|
||||
## Updates on your schedule
|
||||
|
||||
In Preferences you can restrict automatic updates to a daily time range — say 1am to 6am — so an update never closes RemSound and interrupts you. Found outside the range, it quietly waits and installs the moment the range opens.
|
||||
- The service applies the fix on its own when it installs this update — for most people that's it, nothing to do.
|
||||
- RemSound also checks the folder every time it starts, and offers a one-click repair if it finds a problem.
|
||||
- And there's a "Repair service folder access" item in the Service menu you can run any time. One administrator prompt, and saving the service profile and reading the logs work again.
|
||||
|
||||
## Also in this release
|
||||
|
||||
- The app releases its high-priority and keep-awake settings when you're not actually streaming — kinder to laptops left idling in the tray.
|
||||
- Diagnostic logs cap their own size on long sessions, and old crash reports are tidied automatically.
|
||||
- The remembered-applications list explains itself when empty.
|
||||
- A large amount of behind-the-scenes hardening from a full security audit.
|
||||
- The service's log files are readable from every account on the machine again, so you can always open them in Notepad if you need to look at one or send it in. (The service's settings stay protected as before.)
|
||||
- If a service action fails, the message now says what actually went wrong instead of showing a bare error code, and the reason is recorded in the service's own log.
|
||||
- The repair, and the folder protection itself, now always apply to the account that's actually using RemSound — even on PCs where a different account's password is typed at the administrator prompt.
|
||||
|
||||
@@ -1421,6 +1421,7 @@ RemSound.exe --connect 192.168.1.42
|
||||
<li><strong>Install service</strong> — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to <strong>start it now</strong> (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up — and start it — for you, so you may have done this already.)</li>
|
||||
<li><strong>Start service</strong> / <strong>Stop service</strong> — run or halt it now without waiting for a reboot.</li>
|
||||
<li><strong>Uninstall service</strong> — removes it entirely.</li>
|
||||
<li><strong>Repair service folder access</strong> — fixes the permissions on the service's settings folder so your account can save the service profile and read the service logs again. You should never need this in normal use, but if saving the service profile ever fails with an “access denied” message, or the service's log files won't open, run this once (Windows asks for administrator permission) and everything is put right. RemSound also checks the folder itself every time it starts and offers this same repair automatically if it finds a problem.</li>
|
||||
</ol>
|
||||
<p>The top of the Service menu always shows the current state: not installed, installed and running, or installed and stopped.</p>
|
||||
|
||||
@@ -1498,6 +1499,9 @@ RemSound.exe --connect 192.168.1.42
|
||||
<h3>A sound card you were listening through was unplugged</h3>
|
||||
<p>If a sound card you're playing received audio through is unplugged and then plugged back in, RemSound now re-opens it on its own and the sound resumes — you don't have to re-tick it in the output list. This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket. If you move it to a different USB socket and Windows treats it as a brand-new device, just tick it again in the output list.</p>
|
||||
|
||||
<h3>Saving the service profile says “access denied”, or the service's log files won't open</h3>
|
||||
<p>Your account has lost its permissions on the service's settings folder — this could happen after some older-version reinstalls, and it also blocks the service itself from starting properly. The fix is one menu item: <strong>Service → Repair service folder access</strong>. Windows asks for administrator permission once, the folder is put right, and saving and log-reading work again. RemSound also spots this state by itself when it starts and offers the same repair automatically, and the service applies the fix on its own when it installs a RemSound update — so on an up-to-date machine you should never actually meet this problem.</p>
|
||||
|
||||
<h3>UPnP says “no router found” even though my router supports it</h3>
|
||||
<p>The most common reasons:</p>
|
||||
<ul>
|
||||
|
||||
@@ -20,6 +20,14 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v5.8
|
||||
|
||||
A repair for the lock-screen service's settings folder.
|
||||
|
||||
Some machines ended up with the service's settings folder locked so tightly that nothing could use it — saving the service profile failed with "access denied", the service's log files wouldn't open, and on some machines the service itself couldn't start. This release fixes the cause and heals affected machines automatically: the service applies the fix on its own when it updates, RemSound checks the folder every time it starts and offers a one-click repair if anything is wrong, and there's a "Repair service folder access" item in the Service menu you can run any time.
|
||||
|
||||
The service's log files are also readable again from every account on the machine, so you can always open them in Notepad if you're curious or need to send one in. And if a service action ever fails, the message now says what actually went wrong instead of just showing a bare error code.
|
||||
|
||||
RemSound v5.7
|
||||
|
||||
Stronger security, and it works with every version again.
|
||||
|
||||
@@ -1666,6 +1666,43 @@ public sealed partial class MainForm : Form
|
||||
MaybeWarnAboutRealtekAsio();
|
||||
if (IsDisposed) return;
|
||||
MaybeWarnMicBlockedOnStartup();
|
||||
if (IsDisposed) return;
|
||||
MaybeOfferServiceFolderRepair();
|
||||
}
|
||||
|
||||
/// <summary>If the user has a service folder they can no longer write (the 5.6 wrong-owner bug, or
|
||||
/// any outside interference), offer the one-click elevated repair. Runs in the settled startup
|
||||
/// sequence so the dialog gets real focus for a screen reader; asks every launch while the folder
|
||||
/// stays broken (that's genuine breakage — the profile can't save — not a nag), and never appears
|
||||
/// once healthy. Skipped for --silent (automated) launches like every other startup notice.</summary>
|
||||
private void MaybeOfferServiceFolderRepair()
|
||||
{
|
||||
if (CuePlayer.GloballyMuted) return;
|
||||
bool broken;
|
||||
try { broken = System.IO.Directory.Exists(ServiceStore.Directory) && !ServiceControl.CurrentUserCanWriteServiceDir(); }
|
||||
catch { return; }
|
||||
if (!broken) return;
|
||||
logFile.Event("service folder access check: current user CANNOT write the service folder - offering repair");
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = AppName,
|
||||
Heading = "The service settings folder needs repairing",
|
||||
Text = "RemSound is not allowed to change its own service settings folder, so saving the "
|
||||
+ "service profile would fail. This can happen after a reinstall or an update.\n\n"
|
||||
+ "Repair it now? Windows will ask for administrator permission, and your account "
|
||||
+ "gets its access back. This is also available later as \"Repair service folder "
|
||||
+ "access\" in the Service menu.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
};
|
||||
var yes = new TaskDialogButton("&Repair now");
|
||||
var no = new TaskDialogButton("&Not now");
|
||||
page.Buttons.Add(yes);
|
||||
page.Buttons.Add(no);
|
||||
page.DefaultButton = yes;
|
||||
if (ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)) == yes)
|
||||
RunServiceVerbAsync(ServiceControl.RepairVerb, "access repair");
|
||||
else
|
||||
logFile.Event("service folder repair declined at startup");
|
||||
}
|
||||
|
||||
|
||||
@@ -2246,12 +2283,17 @@ public sealed partial class MainForm : Form
|
||||
activityLog.Click += (_, _) => OpenServiceLog();
|
||||
var updateLog = new ToolStripMenuItem("View service update &log") { AccessibleName = "View service update log" };
|
||||
updateLog.Click += (_, _) => OpenServiceUpdateLog();
|
||||
// The self-heal for a service folder whose permissions ended up wrong (the 5.6 wrong-owner bug,
|
||||
// or anything else that locks the user out of their own profile/logs). Also offered automatically
|
||||
// at startup and on a failed profile save; kept in the menu so it's discoverable and repeatable.
|
||||
var repair = new ToolStripMenuItem("&Repair service folder access") { AccessibleName = "Repair service folder access" };
|
||||
repair.Click += (_, _) => ServiceAction(ServiceControl.RepairVerb, "access repair", confirm: false);
|
||||
|
||||
serviceMenu.DropDownItems.AddRange(new ToolStripItem[]
|
||||
{
|
||||
status, new ToolStripSeparator(),
|
||||
configure, new ToolStripSeparator(),
|
||||
install, uninstall, start, stop, new ToolStripSeparator(),
|
||||
install, uninstall, start, stop, repair, new ToolStripSeparator(),
|
||||
activityLog, updateLog,
|
||||
});
|
||||
serviceMenu.DropDownOpening += (_, _) =>
|
||||
@@ -2407,6 +2449,18 @@ public sealed partial class MainForm : Form
|
||||
: "Service profile saved.",
|
||||
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// The exact wall a locked-out user hits (support case 2026-08-06): don't just report the
|
||||
// denial — offer the fix on the spot. Same repair as the Service menu item.
|
||||
var offer = MessageBox.Show(this,
|
||||
"RemSound was not allowed to save the service profile — your account has lost access "
|
||||
+ "to the service settings folder. This can happen after a reinstall or an update.\n\n"
|
||||
+ "Repair the folder access now? Windows will ask for administrator permission. "
|
||||
+ "Afterwards, save the profile again.",
|
||||
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
|
||||
if (offer == DialogResult.Yes) RunServiceVerbAsync(ServiceControl.RepairVerb, "access repair");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not save the service profile: {ex.Message}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
@@ -2461,14 +2515,32 @@ public sealed partial class MainForm : Form
|
||||
if (startNow == DialogResult.Yes) RunServiceVerbAsync(ServiceControl.StartVerb, "start");
|
||||
return;
|
||||
}
|
||||
MessageBox.Show(this, $"Service {label} succeeded.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
MessageBox.Show(this,
|
||||
label == "access repair"
|
||||
? "Service folder access repaired. Your account owns the service folder again."
|
||||
: $"Service {label} succeeded.",
|
||||
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
else if (rc == -1)
|
||||
MessageBox.Show(this, $"Service {label} was cancelled, or administrator rights were declined.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
else if (rc == ServiceControl.ElevatedTimedOut)
|
||||
MessageBox.Show(this, $"Service {label} is taking longer than expected and hasn't finished yet. It may still complete on its own — check the Service menu status in a moment.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
else
|
||||
MessageBox.Show(this, $"Service {label} failed (code {rc}).", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
{
|
||||
// Say what the code MEANS — "(code 1)" alone cost a support round-trip on 2026-08-06. The
|
||||
// service events log always has the underlying exception now (View service log shows it).
|
||||
var why = rc switch
|
||||
{
|
||||
ServiceControl.StartStopTimedOut => "The service did not respond within 15 seconds.",
|
||||
ServiceControl.StartStopScmRefused => "Windows refused — the service may be missing or disabled.",
|
||||
9 => "The repair commands ran but the folder still isn't writable.",
|
||||
_ => "",
|
||||
};
|
||||
var hint = label is "start" or "install"
|
||||
? " The service log usually says why — Service menu, View service log. If this keeps happening, try 'Repair service folder access' in the Service menu."
|
||||
: " The service log usually says why — Service menu, View service log.";
|
||||
MessageBox.Show(this, $"Service {label} failed (code {rc}). {why}{hint}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebuild the Recent profiles submenu from <see cref="AppConfig.RecentProfiles"/>.
|
||||
|
||||
@@ -24,7 +24,7 @@ internal static class Program
|
||||
HasArg(args, ServiceControl.RunVerb) || HasArg(args, ServiceControl.InstallVerb)
|
||||
|| HasArg(args, ServiceControl.UninstallVerb)
|
||||
|| HasArg(args, ServiceControl.StartVerb) || HasArg(args, ServiceControl.StopVerb)
|
||||
|| HasArg(args, ServiceControl.SelfUpdateVerb);
|
||||
|| HasArg(args, ServiceControl.SelfUpdateVerb) || HasArg(args, ServiceControl.RepairVerb);
|
||||
|
||||
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
|
||||
// "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>5.7</Version>
|
||||
<Version>5.8</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Forms;
|
||||
@@ -125,6 +126,8 @@ internal static class SelfTest
|
||||
RunStep(results, "Service log discovery (newest activity log)", ServiceLogDiscovery);
|
||||
RunStep(results, "Sealed remote control (auth + replay + skew) + nonce discipline", SealedRemoteControl);
|
||||
RunStep(results, "Service folder lockdown args (cross-user LPE hardening)", ServiceDirHardeningArgs);
|
||||
RunStep(results, "Service folder repair (reproduce wrong-owner lockout → detect → repair → verify)", ServiceAccessRepairLoop);
|
||||
RunStep(results, "Elevated verbs carry the real user (SID pass-through) + logs stay readable", ElevatedIdentityPassThrough);
|
||||
RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene);
|
||||
RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume);
|
||||
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
|
||||
@@ -2754,8 +2757,15 @@ internal static class SelfTest
|
||||
Check(args.Contains("*S-1-5-18:(OI)(CI)F"), "SYSTEM keeps Full (the service runs here)");
|
||||
Check(args.Contains("*S-1-5-32-544:(OI)(CI)F"), "Administrators keep Full");
|
||||
Check(args.Contains("*S-1-5-21-111-222-333-1001:(OI)(CI)(M)"), "the installing user keeps Modify (profile saves + test builds)");
|
||||
Check(args.Contains("/T"), "must re-stamp existing files (app-source.txt is the target of the attack)");
|
||||
// REGRESSION PIN (the 5.6 file-wedging bug): the folder lockdown must NOT sweep /T — its
|
||||
// (OI)(CI) grants are inherit-only on files, so a /T sweep leaves every existing file with an
|
||||
// effectively EMPTY ACL (unreadable even by SYSTEM). Existing children are rebuilt by the
|
||||
// separate /reset pass instead.
|
||||
Check(!args.Contains("/T"), "the folder lockdown must NOT recurse — a /T sweep wedges every existing file (5.6 bug)");
|
||||
Check(args.Split("/grant").Length == 4, "exactly three grants — nobody else survives the reset");
|
||||
var reset = ServiceControl.BuildResetChildrenArgs(@"C:\ProgramData\RemSound\service");
|
||||
Check(reset.Contains(@"service\*""") && reset.Contains("/reset") && reset.Contains("/T") && reset.Contains("/C"),
|
||||
$"children must be rebuilt as purely-inherited, recursively, continuing past errors (got: {reset})");
|
||||
|
||||
var savedOverride = ServiceStore.TestDirectoryOverride;
|
||||
var tmp = Path.Combine(Path.GetTempPath(), "remsound-selftest-sid-" + Guid.NewGuid().ToString("N"));
|
||||
@@ -2773,6 +2783,144 @@ internal static class SelfTest
|
||||
return "inheritance stripped; SYSTEM/Admins/installing-user only; SID recorded + garbage-proofed";
|
||||
}
|
||||
|
||||
/// <summary>The full life of the 5.6 wrong-owner bug, reproduced for real in a scratch folder and
|
||||
/// healed by the 5.8 repair — with the SAME icacls arguments the shipping code runs, just aimed at a
|
||||
/// throwaway directory. Proves: (1) a folder locked to somebody else genuinely denies this process's
|
||||
/// writes; (2) the app-side probe reports it broken; (3) re-applying the lockdown naming the REAL
|
||||
/// user (the repair core) restores access; (4) the probe then reports healthy. The lockout half needs
|
||||
/// a non-elevated run (elevated, the Administrators Full grant applies to us and nothing can lock us
|
||||
/// out) — under elevation those two assertions are skipped, the rest still prove the repair.</summary>
|
||||
private static string? ServiceAccessRepairLoop()
|
||||
{
|
||||
string? mySid = null;
|
||||
var elevated = false;
|
||||
try
|
||||
{
|
||||
using var id = WindowsIdentity.GetCurrent();
|
||||
mySid = id.User?.Value;
|
||||
elevated = new WindowsPrincipal(id).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch { }
|
||||
if (!ServiceControl.IsValidUserSid(mySid)) throw new StepSkipped("current identity has no user SID (unusual account) - cannot exercise the ACL loop");
|
||||
|
||||
var dir = Path.Combine(Path.GetTempPath(), "remsound-selftest-repair-" + Guid.NewGuid().ToString("N"));
|
||||
var profile = Path.Combine(dir, "service-profile.json");
|
||||
var logFile = Path.Combine(dir, "logs", "service.log");
|
||||
Directory.CreateDirectory(Path.Combine(dir, "logs"));
|
||||
File.WriteAllText(profile, "{}");
|
||||
File.WriteAllText(logFile, "hello");
|
||||
try
|
||||
{
|
||||
Check(ServiceControl.CanWriteDirectory(dir), "a fresh folder must probe healthy");
|
||||
|
||||
// 1a. Reproduce the 5.6 FILE-WEDGING bug exactly: apply the lockdown spec to a FILE — which
|
||||
// is what the old /T sweep did to every existing file. The (OI)(CI) grants are inherit-only
|
||||
// on a file, so its effective ACL empties: unreadable by everyone (the Jonathan case,
|
||||
// 2026-08-06 — Notepad refusing his own logs, the app unable to rewrite its profile).
|
||||
var wedge = RunIcaclsForTest(ServiceControl.BuildServiceDirAclArgs(profile, "S-1-5-32-546"));
|
||||
Check(wedge == 0, $"the file-wedge reproduction icacls must apply (exit {wedge})");
|
||||
var wedged = false;
|
||||
try { File.ReadAllText(profile); } catch (UnauthorizedAccessException) { wedged = true; }
|
||||
Check(wedged, "the wedged file must genuinely deny reads — even to administrators (this IS the shipped 5.6 bug)");
|
||||
|
||||
// 1b. Reproduce the wrong-owner lockdown on the folder (Guests stands in for the separate
|
||||
// admin account whose identity the 5.6 install recorded instead of the real user's).
|
||||
var brk = RunIcaclsForTest(ServiceControl.BuildServiceDirAclArgs(dir, "S-1-5-32-546"));
|
||||
Check(brk == 0, $"the wrong-owner lockdown icacls must apply (exit {brk})");
|
||||
|
||||
// 2. Detect — the app's startup probe. (Skipped under elevation: the Administrators grant
|
||||
// means an elevated run can't be locked out of the folder.)
|
||||
if (!elevated)
|
||||
Check(!ServiceControl.CanWriteDirectory(dir), "the probe must report the wrong-owner folder as broken");
|
||||
|
||||
// 3. Repair: the EXACT shipped sequence (DoRepairAccess minus the takeown — the test owns
|
||||
// the scratch folder, which carries the same right to rewrite its permissions).
|
||||
Check(ServiceControl.ApplyServiceDirAcl(dir, mySid!, _ => { }), "the repair sequence must apply cleanly");
|
||||
|
||||
// 4. Verify: folder writable again, wedged profile readable again, log readable again.
|
||||
Check(ServiceControl.CanWriteDirectory(dir), "after repair the probe must report healthy");
|
||||
Check(File.ReadAllText(profile) == "{}", "the repair must heal the wedged profile file (children reset → real inherited access)");
|
||||
Check(File.ReadAllText(logFile) == "hello", "the log file must be readable after repair");
|
||||
return elevated
|
||||
? "wedged file reproduced + healed; folder repair proven (elevated run: folder-lockout detection skipped)"
|
||||
: "wrong owner + wedged file reproduced → probe says broken → one repair heals both → probe says healthy";
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); }
|
||||
catch
|
||||
{
|
||||
// A failed half-way state can leave us without delete rights; owner can always reset.
|
||||
try { RunIcaclsForTest($"\"{dir}\" /reset /T /C"); Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunIcaclsForTest(string arguments)
|
||||
{
|
||||
using var p = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "icacls.exe",
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
})!;
|
||||
p.StandardOutput.ReadToEnd();
|
||||
p.StandardError.ReadToEnd();
|
||||
return p.WaitForExit(30000) ? p.ExitCode : -1;
|
||||
}
|
||||
|
||||
/// <summary>The 5.8 wrong-owner fix's pure pieces. The elevated helper must be TOLD who the user is
|
||||
/// (its own token is whoever approved the UAC prompt — under over-the-shoulder elevation NOT the
|
||||
/// person at the keyboard), so: the non-elevated side appends --as-user with its SID; the parse
|
||||
/// accepts only genuine user SIDs (service identities and built-in groups rejected — granting those
|
||||
/// would defeat the lockdown); and the logs-read grants let every account read logs (read-only)
|
||||
/// while the profile stays locked.</summary>
|
||||
private static string? ElevatedIdentityPassThrough()
|
||||
{
|
||||
const string user = "S-1-5-21-111-222-333-1001";
|
||||
|
||||
// SID validation: real users in, machine identities and groups out.
|
||||
Check(ServiceControl.IsValidUserSid(user), "a normal local/domain user SID must be accepted");
|
||||
Check(ServiceControl.IsValidUserSid("S-1-12-1-1-2-3-4"), "an Entra (Azure AD) user SID must be accepted");
|
||||
Check(!ServiceControl.IsValidUserSid("S-1-5-18") && !ServiceControl.IsValidUserSid("S-1-5-19") && !ServiceControl.IsValidUserSid("S-1-5-20"),
|
||||
"SYSTEM/LocalService/NetworkService must be rejected (granting the service's own identity defeats the lockdown)");
|
||||
Check(!ServiceControl.IsValidUserSid("S-1-5-32-544") && !ServiceControl.IsValidUserSid("S-1-5-32-545"),
|
||||
"built-in groups must be rejected (a group grant re-opens the every-account hole)");
|
||||
Check(!ServiceControl.IsValidUserSid(null) && !ServiceControl.IsValidUserSid("") && !ServiceControl.IsValidUserSid("garbage") && !ServiceControl.IsValidUserSid("S-1-"),
|
||||
"null/empty/garbage must be rejected");
|
||||
|
||||
// Argument parse: the value after --as-user, validated; missing/valueless/invalid → null.
|
||||
Check(ServiceControl.ParseAsUserSid([ServiceControl.InstallVerb, ServiceControl.AsUserArg, user]) == user,
|
||||
"--as-user <sid> must parse");
|
||||
Check(ServiceControl.ParseAsUserSid([ServiceControl.InstallVerb]) is null, "absent --as-user must parse as null");
|
||||
Check(ServiceControl.ParseAsUserSid([ServiceControl.InstallVerb, ServiceControl.AsUserArg]) is null, "valueless --as-user must parse as null");
|
||||
Check(ServiceControl.ParseAsUserSid([ServiceControl.AsUserArg, "S-1-5-18"]) is null, "an invalid --as-user value must parse as null, not be trusted");
|
||||
|
||||
// The non-elevated side builds "<verb> --as-user <own sid>" (when this process has a user SID).
|
||||
var built = ServiceControl.BuildElevatedArguments(ServiceControl.RepairVerb);
|
||||
Check(built.StartsWith(ServiceControl.RepairVerb, StringComparison.Ordinal), "the verb must come first");
|
||||
string? mySid = null;
|
||||
try { mySid = WindowsIdentity.GetCurrent().User?.Value; } catch { }
|
||||
if (ServiceControl.IsValidUserSid(mySid))
|
||||
Check(built == $"{ServiceControl.RepairVerb} {ServiceControl.AsUserArg} {mySid}", $"the built arguments must introduce this user (got: {built})");
|
||||
|
||||
// Logs stay readable: read-only grant to all Users on logs\ (inherited) + the events file; no write.
|
||||
var logs = ServiceControl.BuildLogsReadAclArgs(@"C:\ProgramData\RemSound\service");
|
||||
Check(logs.Contains(@"service\logs""", StringComparison.Ordinal), "the grant must target the logs subfolder");
|
||||
Check(logs.Contains("*S-1-5-32-545:(OI)(CI)(RX)", StringComparison.Ordinal), "all Users get inherited read-and-traverse only");
|
||||
Check(!logs.Contains("/T"), "no /T — inheritable ACEs propagate to existing files on apply; a /T sweep would stamp files with useless inherit-only ACEs (5.6 lesson)");
|
||||
Check(!logs.Contains("(M)") && !logs.Contains("(F)") && !logs.Contains("(W)"), "the logs grant must carry no write");
|
||||
var events = ServiceControl.BuildEventsLogReadAclArgs(@"C:\ProgramData\RemSound\service");
|
||||
Check(events.Contains("service-events.log", StringComparison.Ordinal) && events.Contains("*S-1-5-32-545:(RX)", StringComparison.Ordinal),
|
||||
"the events file gets Users read");
|
||||
Check(!events.Contains("(M)") && !events.Contains("(F)"), "the events grant must carry no write");
|
||||
|
||||
return "identity travels by argument and only real user SIDs are trusted; logs readable by every account, read-only";
|
||||
}
|
||||
|
||||
/// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's
|
||||
/// speakers audibly play (Windows tune, NVDA) but a capture attached in the first seconds of boot
|
||||
/// taps an engine mix the logon-session audio was never wired into — the endpoint's own METER shows
|
||||
|
||||
@@ -37,6 +37,24 @@ public static class ServiceControl
|
||||
public const string StartVerb = "--start-service";
|
||||
public const string StopVerb = "--stop-service";
|
||||
public const string RunVerb = "--run-service";
|
||||
/// <summary>Re-records the invoking user as the service folder's owner and re-applies the folder
|
||||
/// lockdown. The self-heal for a folder hardened to the WRONG account (the 5.6 bug: the elevated
|
||||
/// helper recorded ITS OWN identity, which on a standard-user PC is the separate admin account whose
|
||||
/// password was typed at the UAC prompt — locking the real user out of their own service profile and
|
||||
/// logs, with every service self-update re-applying the stale lock).</summary>
|
||||
public const string RepairVerb = "--repair-service-access";
|
||||
|
||||
/// <summary>Argument the non-elevated app appends to every elevated verb: <c>--as-user <SID></c>,
|
||||
/// naming the person actually at the keyboard. The elevated helper must NOT ask its own token who the
|
||||
/// user is — under over-the-shoulder elevation that token belongs to whoever's admin password was
|
||||
/// typed, not the user (the root cause above). The non-elevated app's identity IS the interactive
|
||||
/// user, so it introduces them by SID and the elevated side records THAT.</summary>
|
||||
public const string AsUserArg = "--as-user";
|
||||
|
||||
/// <summary>The validated <see cref="AsUserArg"/> SID for this elevated helper process, set once by
|
||||
/// ServiceEntry before dispatching a verb; null when absent/invalid (old caller, manual console run)
|
||||
/// — then <see cref="InstallingUserSid"/> falls back to the process identity as before.</summary>
|
||||
internal static string? ElevatedInvokerSid;
|
||||
|
||||
/// <summary>Current service state. Never throws — returns <see cref="ServiceState.Unknown"/> on any
|
||||
/// error. Unprivileged, so safe to poll from the UI without elevation.</summary>
|
||||
@@ -83,7 +101,7 @@ public static class ServiceControl
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
Arguments = verb,
|
||||
Arguments = BuildElevatedArguments(verb),
|
||||
UseShellExecute = true,
|
||||
Verb = "runas",
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
@@ -99,6 +117,40 @@ public static class ServiceControl
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
/// <summary>Pure, testable: the full command line for an elevated helper — the verb plus
|
||||
/// <see cref="AsUserArg"/> introducing THIS (non-elevated) process's user, when that identity is a
|
||||
/// real user account. See <see cref="AsUserArg"/> for why the identity must travel as an argument.</summary>
|
||||
internal static string BuildElevatedArguments(string verb)
|
||||
{
|
||||
string? sid = null;
|
||||
try { sid = WindowsIdentity.GetCurrent().User?.Value; } catch { }
|
||||
return IsValidUserSid(sid) ? $"{verb} {AsUserArg} {sid}" : verb;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="sid"/> parses as a SID and denotes an actual user account —
|
||||
/// not SYSTEM / LocalService / NetworkService (S-1-5-18/19/20) and not a built-in group
|
||||
/// (S-1-5-32-*). Granting the service folder to one of those would either hand it to the service's
|
||||
/// own identity (defeating the lockdown) or to every member of a group (re-opening the audit hole),
|
||||
/// so such values are rejected and the caller falls back to its old behaviour.</summary>
|
||||
internal static bool IsValidUserSid(string? sid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sid)) return false;
|
||||
try { _ = new SecurityIdentifier(sid); } catch { return false; }
|
||||
if (sid is "S-1-5-18" or "S-1-5-19" or "S-1-5-20") return false;
|
||||
if (sid.StartsWith("S-1-5-32-", StringComparison.Ordinal)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Pure, testable: extract the <see cref="AsUserArg"/> value from a verb command line, or
|
||||
/// null when absent, valueless, or not a valid user SID (see <see cref="IsValidUserSid"/>).</summary>
|
||||
internal static string? ParseAsUserSid(string[] args)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
if (string.Equals(args[i], AsUserArg, StringComparison.OrdinalIgnoreCase))
|
||||
return IsValidUserSid(args[i + 1]) ? args[i + 1] : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Elevated-side (called from Program.cs when running an --xxx-service verb) ---------------
|
||||
|
||||
/// <summary>Installs the service. Must be run elevated. Copies the program to the service's OWN folder
|
||||
@@ -109,6 +161,10 @@ public static class ServiceControl
|
||||
/// reports success.</summary>
|
||||
public static int DoInstall()
|
||||
{
|
||||
// If the non-elevated app introduced the real interactive user, record THAT identity before any
|
||||
// hardening — including the already-installed re-harden below, which would otherwise re-apply a
|
||||
// stale (possibly wrong-account) recorded owner forever.
|
||||
if (IsValidUserSid(ElevatedInvokerSid)) ServiceStore.SaveInstallingUserSid(ElevatedInvokerSid!);
|
||||
if (IsInstalled()) { HardenServiceDirectory(); return 0; } // re-harden pre-audit installs
|
||||
var exe = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(exe)) return 2;
|
||||
@@ -134,12 +190,12 @@ public static class ServiceControl
|
||||
RunSc(BuildFailureArgs());
|
||||
// Let a normal (non-admin) user start/stop it — otherwise stopping needs the app's UAC prompt.
|
||||
GrantUserStartStop();
|
||||
// Let a normal user REPLACE the binaries in the service's bin folder too (once the service is
|
||||
// stopped), so a new build can be dropped in without admin — the auto-updater does this as SYSTEM,
|
||||
// but it also makes "stop the service, copy the new files in, start it" work for a developer/tester
|
||||
// with no UAC. (Trust note: a user-writable folder whose contents run as SYSTEM is the same posture
|
||||
// as the auto-update copy; fine for this app, a hardened build would code-sign instead.)
|
||||
GrantUsersWriteToBin();
|
||||
// The installing user's write access to the bin folder (drop test builds in without admin) comes
|
||||
// from the hardening below: the folder ACL grants them Modify, inherited by bin and its files
|
||||
// after the children reset. (A separate explicit bin grant existed until 5.8; the reset wiped it
|
||||
// anyway, so it was removed. Trust note: a user-writable folder whose contents run as SYSTEM is
|
||||
// the same posture as the auto-update copy; fine for this app, a hardened build would code-sign.)
|
||||
//
|
||||
// Close the cross-user escalation the 2026-07-26 security audit found: the SYSTEM service
|
||||
// trusts app-source.txt (which folder to self-update FROM), and if ANOTHER local user had
|
||||
// pre-created ProgramData\RemSound\service (e.g. by saving the service config dialog before
|
||||
@@ -180,48 +236,142 @@ public static class ServiceControl
|
||||
var own = RunProcessCaptured("takeown.exe", $"/f \"{dir}\" /a /r /d y", 30000);
|
||||
if (!own.Started || !own.Exited || own.ExitCode != 0)
|
||||
ServiceStore.AppendServiceEvent($"harden: takeown on service dir returned {(own.Exited ? own.ExitCode : -1)}: {own.StdErr}");
|
||||
if (ApplyServiceDirAcl(dir, sid, ServiceStore.AppendServiceEvent))
|
||||
ServiceStore.AppendServiceEvent("harden: service folder ownership + ACL locked to SYSTEM/Administrators/installing user; files rebuilt as inherited; logs readable");
|
||||
}
|
||||
|
||||
/// <summary>The complete lockdown sequence minus the takeown — factored out so the self-test can
|
||||
/// run the EXACT shipped commands against a scratch folder (reproduce → repair → verify, every
|
||||
/// build). Steps: (1) harden the folder itself (<see cref="BuildServiceDirAclArgs"/>); (2) rebuild
|
||||
/// all existing children as purely-inherited (<see cref="BuildResetChildrenArgs"/> — real file
|
||||
/// access again, stale ACEs gone, and the healer for the 5.6 file-wedging bug); (3) logs + the
|
||||
/// events file readable by every local account, read-only (the audit hole was WRITE access; taking
|
||||
/// everyone's READ just meant a user in trouble couldn't open their own logs — support case
|
||||
/// 2026-08-06). The profile stays locked to SYSTEM/Administrators/owner: it holds the obfuscated
|
||||
/// password. Returns true when every command succeeded; failures are reported and keep going.</summary>
|
||||
internal static bool ApplyServiceDirAcl(string dir, string sid, Action<string> report)
|
||||
{
|
||||
var ok = true;
|
||||
var acl = RunProcessCaptured("icacls.exe", BuildServiceDirAclArgs(dir, sid), 30000);
|
||||
if (!acl.Started || !acl.Exited || acl.ExitCode != 0)
|
||||
ServiceStore.AppendServiceEvent($"harden: icacls reset on service dir returned {(acl.Exited ? acl.ExitCode : -1)}: {acl.StdErr}{acl.StdOut}");
|
||||
else
|
||||
ServiceStore.AppendServiceEvent("harden: service folder ownership + ACL locked to SYSTEM/Administrators/installing user");
|
||||
{
|
||||
ok = false;
|
||||
report($"harden: icacls on service dir returned {(acl.Exited ? acl.ExitCode : -1)}: {acl.StdErr}{acl.StdOut}");
|
||||
}
|
||||
bool hasChildren;
|
||||
try { hasChildren = Directory.EnumerateFileSystemEntries(dir).Any(); }
|
||||
catch { hasChildren = true; } // can't tell (we may lack list rights) — try; /C tolerates
|
||||
if (hasChildren)
|
||||
{
|
||||
var reset = RunProcessCaptured("icacls.exe", BuildResetChildrenArgs(dir), 30000);
|
||||
if (!reset.Started || !reset.Exited || reset.ExitCode != 0)
|
||||
{
|
||||
ok = false;
|
||||
report($"harden: children reset returned {(reset.Exited ? reset.ExitCode : -1)}: {reset.StdErr}{reset.StdOut}");
|
||||
}
|
||||
}
|
||||
try { Directory.CreateDirectory(Path.Combine(dir, "logs")); } catch { }
|
||||
var logsAcl = RunProcessCaptured("icacls.exe", BuildLogsReadAclArgs(dir), 30000);
|
||||
if (!logsAcl.Started || !logsAcl.Exited || logsAcl.ExitCode != 0)
|
||||
{
|
||||
ok = false;
|
||||
report($"harden: users-can-read-logs grant returned {(logsAcl.Exited ? logsAcl.ExitCode : -1)}: {logsAcl.StdErr}");
|
||||
}
|
||||
if (File.Exists(Path.Combine(dir, "service-events.log")))
|
||||
RunProcessCaptured("icacls.exe", BuildEventsLogReadAclArgs(dir), 30000);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>Pure, testable: the icacls arguments that lock the service folder down.
|
||||
/// /inheritance:r strips inherited ACEs (ProgramData grants CREATOR OWNER full control —
|
||||
/// the exact hole); explicit grants only: SYSTEM + Administrators Full, installing user
|
||||
/// Modify. /T re-stamps existing files (app-source.txt included), /C continues past
|
||||
/// per-file errors.</summary>
|
||||
internal static string BuildServiceDirAclArgs(string dir, string installingUserSid) =>
|
||||
$"\"{dir}\" /inheritance:r /grant \"*S-1-5-18:(OI)(CI)F\" /grant \"*S-1-5-32-544:(OI)(CI)F\" /grant \"*{installingUserSid}:(OI)(CI)(M)\" /T /C";
|
||||
/// <summary>Re-record the invoking user as the folder's owner and re-apply the lockdown — the
|
||||
/// elevated side of the one-click repair (Service menu, or offered automatically when the app finds
|
||||
/// it can no longer write the service folder). Also the recovery for installs bitten by the 5.6
|
||||
/// wrong-account recording. Returns 0 when the hardening commands all succeeded, 9 otherwise (the
|
||||
/// service events log has the detail either way).</summary>
|
||||
public static int DoRepairAccess()
|
||||
{
|
||||
if (IsValidUserSid(ElevatedInvokerSid)) ServiceStore.SaveInstallingUserSid(ElevatedInvokerSid!);
|
||||
HardenServiceDirectory();
|
||||
ServiceStore.AppendServiceEvent($"repair-access: completed (invoker sid {(ElevatedInvokerSid is null ? "not supplied - kept recorded owner" : "recorded")})");
|
||||
// HardenServiceDirectory is best-effort with loud logging; sanity-check the way the app will —
|
||||
// by writing. We're elevated (Administrators Full), so this proves the commands ran and the
|
||||
// folder isn't wedged; the app re-probes as the real user once the helper returns.
|
||||
return CanWriteDirectory(ServiceStore.Directory) ? 0 : 9;
|
||||
}
|
||||
|
||||
/// <summary>The SID to grant the no-admin service rights to: the account that installed it (the
|
||||
/// elevated install runs as the same interactive user with an elevated token, so its SID is that user).
|
||||
/// Scoping the grants to ONE account instead of all Users/Authenticated-Users keeps the effortless
|
||||
/// stop/update workflow for that user while removing the "any account on this PC could replace a
|
||||
/// SYSTEM-run binary" escalation surface. Falls back to BUILTIN\Users only if the SID can't be read.</summary>
|
||||
/// <summary>Pure, testable: the icacls arguments that let every local account READ the service's
|
||||
/// logs subfolder, without granting any write. (RX) = read + traverse. The inheritable ACE on the
|
||||
/// folder propagates to the existing log files on apply (they're unprotected after the children
|
||||
/// reset) — no /T, which would stamp files with useless inherit-only ACEs (the 5.6 lesson).</summary>
|
||||
internal static string BuildLogsReadAclArgs(string dir) =>
|
||||
$"\"{Path.Combine(dir, "logs")}\" /grant \"*S-1-5-32-545:(OI)(CI)(RX)\"";
|
||||
|
||||
/// <summary>Pure, testable: the icacls arguments that let every local account READ the always-on
|
||||
/// service events file (the first thing support asks for).</summary>
|
||||
internal static string BuildEventsLogReadAclArgs(string dir) =>
|
||||
$"\"{Path.Combine(dir, "service-events.log")}\" /grant \"*S-1-5-32-545:(RX)\"";
|
||||
|
||||
/// <summary>Can the CURRENT process create a file in <paramref name="dir"/>? The app-side health
|
||||
/// probe: a missing folder counts as healthy (nothing to repair — it'll be created with the user as
|
||||
/// owner on first save). Probes with a real create-then-delete, because that's exactly what saving
|
||||
/// the service profile does; reading the ACL and predicting would just re-implement Windows, badly.</summary>
|
||||
internal static bool CanWriteDirectory(string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir)) return true;
|
||||
var probe = Path.Combine(dir, "access-probe.tmp");
|
||||
using (new FileStream(probe, FileMode.Create, FileAccess.Write, FileShare.None)) { }
|
||||
try { File.Delete(probe); } catch { /* write proven; a stuck probe file is harmless */ }
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>App-side health check: true when the interactive user can still write the service's
|
||||
/// ProgramData folder (or it doesn't exist yet). False = they've been locked out — the 5.6
|
||||
/// wrong-owner bug, or any outside interference — and the one-click repair should be offered.</summary>
|
||||
public static bool CurrentUserCanWriteServiceDir() => CanWriteDirectory(ServiceStore.Directory);
|
||||
|
||||
/// <summary>Pure, testable: the icacls arguments that lock the service FOLDER down — the folder
|
||||
/// only, deliberately no /T. /inheritance:r strips inherited ACEs (ProgramData grants CREATOR
|
||||
/// OWNER full control — the exact hole); explicit grants only: SYSTEM + Administrators Full,
|
||||
/// installing user Modify.
|
||||
///
|
||||
/// WHY no /T (the 5.6 file-wedging bug, found 2026-08-06 via a user report): these grants carry
|
||||
/// (OI)(CI), and on a FILE an ACE with inheritance flags is INHERIT-ONLY — it grants the file
|
||||
/// itself nothing. Sweeping /T therefore stamped every EXISTING file with /inheritance:r plus
|
||||
/// only inherit-only ACEs = an effectively empty ACL: unreadable and unwritable by everyone
|
||||
/// (user, admin, even SYSTEM — which is how a service ends up unable to read its own profile,
|
||||
/// and a user finds Notepad refusing their own logs). Existing children are instead cleaned by
|
||||
/// <see cref="BuildResetChildrenArgs"/>, which rebuilds them as purely-inherited from this
|
||||
/// folder's ACL — correct file access, and it strips any stale/planted explicit ACEs too.</summary>
|
||||
internal static string BuildServiceDirAclArgs(string dir, string installingUserSid) =>
|
||||
$"\"{dir}\" /inheritance:r /grant \"*S-1-5-18:(OI)(CI)F\" /grant \"*S-1-5-32-544:(OI)(CI)F\" /grant \"*{installingUserSid}:(OI)(CI)(M)\"";
|
||||
|
||||
/// <summary>Pure, testable: the icacls arguments that rebuild every EXISTING child of the service
|
||||
/// folder as purely-inherited from the (just-hardened) folder ACL. /reset replaces each child's
|
||||
/// ACL with inherited ACEs only — files get real (not inherit-only) access again, and any explicit
|
||||
/// ACE another account picked up historically is removed. Also the healer for files wedged by the
|
||||
/// 5.6 bug (see <see cref="BuildServiceDirAclArgs"/>). /C continues past per-file errors.</summary>
|
||||
internal static string BuildResetChildrenArgs(string dir) =>
|
||||
$"\"{Path.Combine(dir, "*")}\" /reset /T /C";
|
||||
|
||||
/// <summary>The SID to grant the no-admin service rights to: the interactive user the non-elevated
|
||||
/// app introduced via <see cref="AsUserArg"/> when present, else this process's own identity.
|
||||
/// The pass-through matters: an elevated helper's own token is whoever approved the UAC prompt,
|
||||
/// which under over-the-shoulder elevation (standard user + separate admin account) is NOT the
|
||||
/// person at the keyboard — recording that locked real users out of their service folder (the 5.6
|
||||
/// bug). Scoping the grants to ONE account instead of all Users/Authenticated-Users keeps the
|
||||
/// effortless stop/update workflow for that user while removing the "any account on this PC could
|
||||
/// replace a SYSTEM-run binary" escalation surface. Falls back to BUILTIN\Users only if no identity
|
||||
/// can be read at all.</summary>
|
||||
private static string InstallingUserSid()
|
||||
{
|
||||
if (IsValidUserSid(ElevatedInvokerSid)) return ElevatedInvokerSid!;
|
||||
try { return WindowsIdentity.GetCurrent().User?.Value ?? "S-1-5-32-545"; }
|
||||
catch { return "S-1-5-32-545"; }
|
||||
}
|
||||
|
||||
/// <summary>Grant the installing user Modify rights on the service's bin folder (via icacls), so a
|
||||
/// stopped service's binaries can be refreshed without administrator rights. Best-effort.</summary>
|
||||
private static void GrantUsersWriteToBin()
|
||||
{
|
||||
// Grant the installing user (see InstallingUserSid) Modify. (OI)(CI) = inherit to files +
|
||||
// subfolders; (M) = Modify. /T applies to the existing contents too (the bin was just populated),
|
||||
// /C keeps going past any single-file error. Runs through RunProcessCaptured, which drains both
|
||||
// pipes concurrently — icacls /T over 100+ files emits far more than the pipe buffer holds, and the
|
||||
// old read-stderr-then-stdout order deadlocked here (the install hang Ed hit, 2026-07-17).
|
||||
var r = RunProcessCaptured("icacls.exe",
|
||||
$"\"{ServiceStore.BinDirectory}\" /grant \"*{InstallingUserSid()}:(OI)(CI)(M)\" /T /C", 30000);
|
||||
if (!r.Started) ServiceStore.AppendServiceEvent("install: grant-write on bin failed to launch icacls");
|
||||
else if (!r.Exited) ServiceStore.AppendServiceEvent("install: grant-write on bin timed out (icacls killed)");
|
||||
else if (r.ExitCode != 0) ServiceStore.AppendServiceEvent($"install: icacls grant-write on bin returned {r.ExitCode}: {r.StdErr}{r.StdOut}");
|
||||
}
|
||||
|
||||
/// <summary>Copies the program files from <paramref name="sourceDir"/> to <paramref name="destDir"/>,
|
||||
/// recursively, but NEVER the user-state folders (logs, profiles, config, recordings) — the service
|
||||
/// keeps its own state in ProgramData. Overwrites so a re-install refreshes the binaries.</summary>
|
||||
@@ -397,31 +547,38 @@ public static class ServiceControl
|
||||
}
|
||||
|
||||
/// <summary>Starts the service. Must be run elevated. Returns 0 on success.</summary>
|
||||
public static int DoStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sc = new ServiceController(ServiceName);
|
||||
if (sc.Status is ServiceControllerStatus.Running or ServiceControllerStatus.StartPending) return 0;
|
||||
sc.Start();
|
||||
sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(15));
|
||||
return 0;
|
||||
}
|
||||
catch { return 1; }
|
||||
}
|
||||
// Distinct failure codes for start/stop, so the dialog can say something useful instead of the
|
||||
// notorious bare "(code 1)". The full exception always goes to the service events log too.
|
||||
public const int StartStopTimedOut = 6; // service didn't reach the target state in 15 s
|
||||
public const int StartStopScmRefused = 7; // the service manager refused (missing, disabled, ...)
|
||||
|
||||
public static int DoStart() => StartStop(start: true);
|
||||
|
||||
/// <summary>Stops the service. Must be run elevated. Returns 0 on success or if already stopped.</summary>
|
||||
public static int DoStop()
|
||||
public static int DoStop() => StartStop(start: false);
|
||||
|
||||
private static int StartStop(bool start)
|
||||
{
|
||||
var label = start ? "start" : "stop";
|
||||
try
|
||||
{
|
||||
using var sc = new ServiceController(ServiceName);
|
||||
if (sc.Status is ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending) return 0;
|
||||
sc.Stop();
|
||||
sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(15));
|
||||
if (start && sc.Status is ServiceControllerStatus.Running or ServiceControllerStatus.StartPending) return 0;
|
||||
if (!start && sc.Status is ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending) return 0;
|
||||
if (start) sc.Start(); else sc.Stop();
|
||||
sc.WaitForStatus(start ? ServiceControllerStatus.Running : ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(15));
|
||||
return 0;
|
||||
}
|
||||
catch { return 1; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The WHY was swallowed here for two releases ("code 1", diagnosed blind on 2026-08-06);
|
||||
// now it's always in the events log, and the code tells the dialog which story to tell.
|
||||
ServiceStore.AppendServiceEvent($"elevated {label}: FAILED {ex.GetType().Name}: {ex.Message}"
|
||||
+ (ex.InnerException is { } inner ? $" (inner: {inner.GetType().Name}: {inner.Message})" : ""));
|
||||
return ex is System.ServiceProcess.TimeoutException ? StartStopTimedOut
|
||||
: ex is InvalidOperationException ? StartStopScmRefused
|
||||
: 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunSc(string arguments)
|
||||
|
||||
@@ -22,6 +22,11 @@ internal static class ServiceEntry
|
||||
/// <see cref="Program.IsServiceInvocation"/>.</summary>
|
||||
public static int Dispatch(string[] args)
|
||||
{
|
||||
// The non-elevated app introduces the interactive user by SID (--as-user); pick it up before any
|
||||
// verb runs so install/repair/harden grant the person at the keyboard, never whoever approved
|
||||
// the UAC prompt. Absent or invalid → null → the old own-token fallback. See ServiceControl.AsUserArg.
|
||||
ServiceControl.ElevatedInvokerSid = ServiceControl.ParseAsUserSid(args);
|
||||
|
||||
if (Has(args, ServiceControl.RunVerb))
|
||||
{
|
||||
// Point the service's data (its log) at the machine-wide ProgramData location, next to its
|
||||
@@ -39,6 +44,7 @@ internal static class ServiceEntry
|
||||
: Has(args, ServiceControl.StartVerb) ? "start"
|
||||
: Has(args, ServiceControl.StopVerb) ? "stop"
|
||||
: Has(args, ServiceControl.SelfUpdateVerb) ? "selfupdate"
|
||||
: Has(args, ServiceControl.RepairVerb) ? "repair-access"
|
||||
: null;
|
||||
if (verb is null) return 0;
|
||||
|
||||
@@ -56,6 +62,7 @@ internal static class ServiceEntry
|
||||
"start" => ServiceControl.DoStart(),
|
||||
"stop" => ServiceControl.DoStop(),
|
||||
"selfupdate" => ServiceControl.DoSelfUpdate(),
|
||||
"repair-access" => ServiceControl.DoRepairAccess(),
|
||||
_ => 0,
|
||||
};
|
||||
ServiceStore.AppendServiceEvent($"elevated {verb}: finished with code {rc}");
|
||||
|
||||
Reference in New Issue
Block a user