Service owns its binaries + is stoppable without admin; installer offers the service

The service was registered to run from wherever it was installed FROM (Environment
.ProcessPath), so installing from a dev/test folder pinned it there: it locked those
files (blocking every rebuild) and, for a real user installing from the app folder,
would lock the app's own binaries and break the auto-updater. Stopping it also needed
admin, so only the app's UAC-prompting Service menu could do it.

Fixes:
- The service now installs its OWN copy of the program into ProgramData\RemSound\
  service\bin and is registered to run from there. Never touches the install/dev
  folder again. CopyProgramTo copies the exe + DLLs + runtimes + default sounds but
  excludes user-state folders; uninstall removes the bin copy.
- Install grants Authenticated Users start/stop/query on the service (sc sdset, ACE
  merged into the existing DACL) so it can be stopped with a plain `sc stop
  RemSoundService` or the Service menu -- no admin, no app. Pure SDDL-amend helper is
  unit-tested (placement + idempotency).
- The app installer now asks, after install, whether to also install the service
  (optional, its own UAC step; declining is fine -- Service menu installs it later).
- deploy-test.ps1: only a copy running FROM the publish folder locks its binaries, so
  only that forces a sounds-only deploy. The service (ProgramData) and an installed app
  no longer make the script silently skip the binary publish -- the bug that had us
  testing stale builds for rounds.

New self-test "Service self-contained install" (runs-from-own-bin, SDDL amend, copy
exclusions). Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 14:38:24 +01:00
co-authored by Claude Opus 4.8
parent a450cae66a
commit 4e15451e1d
5 changed files with 214 additions and 6 deletions
+11 -2
View File
@@ -33,9 +33,18 @@ function Invoke-Robocopy([string[]]$rcArgs) {
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE): $($rcArgs -join ' ')" } if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE): $($rcArgs -join ' ')" }
} }
$soundOnly = @(Get-Process RemSound -ErrorAction SilentlyContinue).Count -gt 0 # Only a RemSound process running FROM the publish folder locks its binaries. The send-only Windows
# service runs from its own copy under ProgramData (...\service\bin) and an installed app runs from
# %LocalAppData%\Programs\RemSound - neither locks publish, so neither should force a sounds-only deploy.
# (This is the bug that silently skipped binary deploys while the service was running - 2026-07-17.)
$publishFull = (Resolve-Path -LiteralPath $publish -ErrorAction SilentlyContinue).Path
$locking = @(Get-Process RemSound -ErrorAction SilentlyContinue | Where-Object {
try { $_.Path -and $publishFull -and $_.Path.StartsWith($publishFull, [System.StringComparison]::OrdinalIgnoreCase) }
catch { $false } # .Path throws for the SYSTEM service (access denied) - it's not in publish, so ignore it
})
$soundOnly = $locking.Count -gt 0
if ($soundOnly) { if ($soundOnly) {
Write-Host "RemSound is running - refreshing SOUNDS only (binaries are locked; close RemSound to update them)." -ForegroundColor Yellow Write-Host "A RemSound is running FROM the publish folder - refreshing SOUNDS only (its binaries are locked; close that copy to update them)." -ForegroundColor Yellow
} }
else { else {
# Never deploy a build to test that hasn't passed the tests. The gate publishes + tests its own # Never deploy a build to test that hasn't passed the tests. The gate publishes + tests its own
+27
View File
@@ -170,6 +170,33 @@ internal static class AppInstaller
"Press OK to finish. RemSound will now close and reopen from the new install location.", "Press OK to finish. RemSound will now close and reopen from the new install location.",
"RemSound installed", MessageBoxButtons.OK, MessageBoxIcon.Information); "RemSound installed", MessageBoxButtons.OK, MessageBoxIcon.Information);
// Offer to install the send-only Windows service too (Ed, 2026-07-17). It's a separate, optional
// component that needs its own admin (UAC) step, so we ask rather than assume. Declining is fine —
// it can be installed any time from the app's Service menu. Never block the app install on it.
try
{
if (!ServiceControl.IsInstalled())
{
var wantService = MessageBox.Show(owner,
"Do you also want to install the RemSound service?" + Environment.NewLine + Environment.NewLine +
"The service streams this PC's audio to your RemSound peers even when nobody is logged in " +
"(for example at the lock screen after a reboot). It's send-only and steps aside whenever the " +
"RemSound app is open. You can install or remove it later from the app's Service menu.",
"Install the RemSound service?", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (wantService == DialogResult.Yes)
{
log?.Invoke("install: user opted to install the service too");
var rc = ServiceControl.RunElevated(ServiceControl.InstallVerb);
MessageBox.Show(owner,
rc == 0
? "The RemSound service was installed. Configure and start it from the app's Service menu when you want it running."
: "The RemSound service was not installed (the elevation prompt was declined, or it failed). You can try again later from the app's Service menu.",
"RemSound service", MessageBoxButtons.OK, rc == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
}
}
}
catch (Exception ex) { log?.Invoke($"install: optional service step skipped ({ex.GetType().Name}: {ex.Message})"); }
// Hand over cleanly. We can't just launch the installed exe and exit: the single-instance // Hand over cleanly. We can't just launch the installed exe and exit: the single-instance
// lock would still be held for the instant it takes us to shut down, and the new copy would // lock would still be held for the instant it takes us to shut down, and the new copy would
// see "already running". So a tiny batch waits for THIS process to exit (lock released), then // see "already running". So a tiny batch waits for THIS process to exit (lock released), then
+56
View File
@@ -73,6 +73,7 @@ internal static class SelfTest
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal); RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics); RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks); RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
RunStep(results, "Recording churn / soak", RecordingChurn); RunStep(results, "Recording churn / soak", RecordingChurn);
@@ -742,6 +743,61 @@ internal static class SelfTest
return "sc create + failure args well-formed; self-update comparison loop-safe"; return "sc create + failure args well-formed; self-update comparison loop-safe";
} }
/// <summary>The service installs and runs from its OWN copy of the program under ProgramData, never the
/// folder it was installed from — so it can't lock the app's install folder / a dev working copy or
/// block the auto-updater. And it grants authenticated users start/stop so it's stoppable without admin.
/// Tests the pure pieces: the run-from path, the SDDL amendment, and the program-copy exclusions.</summary>
private static string? ServiceSelfContainedInstall()
{
// 1. The service runs from ProgramData\RemSound\service\bin\RemSound.exe, and BuildCreateArgs points there.
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
Check(ServiceStore.BinExePath.StartsWith(programData, StringComparison.OrdinalIgnoreCase)
&& ServiceStore.BinExePath.EndsWith(@"\bin\RemSound.exe", StringComparison.OrdinalIgnoreCase),
$"the service must run from its own ProgramData bin copy (got {ServiceStore.BinExePath})");
var createArgs = ServiceControl.BuildCreateArgs(ServiceStore.BinExePath);
Check(createArgs.Contains("\\\"" + ServiceStore.BinExePath + "\\\" " + ServiceControl.RunVerb),
"the create command must register the ProgramData bin exe as the service binary");
// 2. AddUserStartStopAce inserts the AU start/stop ACE into the DACL, ahead of the SACL, and is idempotent.
const string sample = "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCLCSWLOCRRC;;;IU)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)";
var amended = ServiceControl.AddUserStartStopAce(sample);
Check(amended is not null && amended.Contains(ServiceControl.UserStartStopAce), "the AU start/stop ACE must be added");
Check(amended!.IndexOf(ServiceControl.UserStartStopAce, StringComparison.Ordinal) < amended.IndexOf("S:", StringComparison.Ordinal),
"the ACE must sit inside the DACL, before the SACL");
Check(amended.StartsWith("D:", StringComparison.Ordinal), "the result must still be a valid DACL-first SDDL");
Check(ServiceControl.AddUserStartStopAce(amended) == amended, "adding the ACE twice must be a no-op (idempotent)");
Check(ServiceControl.AddUserStartStopAce("garbage") is null, "a non-DACL SDDL must be rejected");
// 3. CopyProgramTo copies program files but NEVER the user-state folders.
var root = Path.Combine(Path.GetTempPath(), "remsound-svccopy-" + Guid.NewGuid().ToString("N"));
var src = Path.Combine(root, "src");
var dst = Path.Combine(root, "dst");
try
{
Directory.CreateDirectory(Path.Combine(src, "runtimes", "win-x64", "native"));
Directory.CreateDirectory(Path.Combine(src, "default sounds"));
Directory.CreateDirectory(Path.Combine(src, "user settings and logs", "logs"));
Directory.CreateDirectory(Path.Combine(src, "logs"));
File.WriteAllText(Path.Combine(src, "RemSound.exe"), "exe");
File.WriteAllText(Path.Combine(src, "RemSound.Sender.dll"), "dll");
File.WriteAllText(Path.Combine(src, "runtimes", "win-x64", "native", "opus.dll"), "opus");
File.WriteAllText(Path.Combine(src, "default sounds", "connect.wav"), "wav");
File.WriteAllText(Path.Combine(src, "user settings and logs", "logs", "secret.log"), "log");
File.WriteAllText(Path.Combine(src, "logs", "stray.log"), "log");
ServiceControl.CopyProgramTo(src, dst);
Check(File.Exists(Path.Combine(dst, "RemSound.exe")), "the exe must be copied");
Check(File.Exists(Path.Combine(dst, "RemSound.Sender.dll")), "sibling DLLs must be copied");
Check(File.Exists(Path.Combine(dst, "runtimes", "win-x64", "native", "opus.dll")), "native runtimes must be copied");
Check(File.Exists(Path.Combine(dst, "default sounds", "connect.wav")), "bundled default sounds must be copied");
Check(!Directory.Exists(Path.Combine(dst, "user settings and logs")), "user settings/logs must NOT be copied");
Check(!Directory.Exists(Path.Combine(dst, "logs")), "stray logs folder must NOT be copied");
return "runs from own ProgramData bin; AU start/stop ACE added idempotently; program copy excludes user state";
}
finally { try { Directory.Delete(root, recursive: true); } catch { /* temp */ } }
}
/// <summary>The service profile is fully isolated from the normal profile machinery: it lives in a /// <summary>The service profile is fully isolated from the normal profile machinery: it lives in a
/// MACHINE-WIDE ProgramData location (readable by the SYSTEM service, outside the user's profiles /// MACHINE-WIDE ProgramData location (readable by the SYSTEM service, outside the user's profiles
/// folder), and the reserved title never shows up in the profile listing that backs the startup /// folder), and the reserved title never shows up in the profile listing that backs the startup
+111 -4
View File
@@ -1,6 +1,8 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.ServiceProcess; using System.ServiceProcess;
using RemSound.Core;
namespace RemSound.App; namespace RemSound.App;
@@ -86,24 +88,100 @@ public static class ServiceControl
// ---- Elevated-side (called from Program.cs when running an --xxx-service verb) --------------- // ---- Elevated-side (called from Program.cs when running an --xxx-service verb) ---------------
/// <summary>Creates the service (auto-start) pointing at this exe with <see cref="RunVerb"/>. Must be /// <summary>Installs the service. Must be run elevated. Copies the program to the service's OWN folder
/// run elevated. Returns 0 on success. Idempotent-ish: if it already exists, reports success.</summary> /// (<see cref="ServiceStore.BinDirectory"/>) and registers it to run from THERE — never from the app's
/// install folder or a dev working copy — so it can't lock those files or block the app's auto-updater.
/// Also grants the machine's authenticated users start/stop rights, so the service can be stopped with
/// a plain <c>sc stop</c> (no admin, no app). Returns 0 on success. Idempotent-ish: already-installed
/// reports success.</summary>
public static int DoInstall() public static int DoInstall()
{ {
if (IsInstalled()) return 0; if (IsInstalled()) return 0;
var exe = Environment.ProcessPath; var exe = Environment.ProcessPath;
if (string.IsNullOrEmpty(exe)) return 2; if (string.IsNullOrEmpty(exe)) return 2;
var sourceDir = Path.GetDirectoryName(exe);
if (string.IsNullOrEmpty(sourceDir)) return 2;
var rc = RunSc(BuildCreateArgs(exe)); // Copy the whole program (exe + DLLs + runtimes/ + default sounds/) into the service's own bin
// folder, so the running service uses ITS copy, not the source it was installed from.
try { CopyProgramTo(sourceDir, ServiceStore.BinDirectory); }
catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: copy program failed: {ex.GetType().Name}: {ex.Message}"); return 5; }
var rc = RunSc(BuildCreateArgs(ServiceStore.BinExePath));
if (rc != 0) return rc; if (rc != 0) return rc;
// Best-effort description; failure here doesn't fail the install. // Best-effort description; failure here doesn't fail the install.
RunSc($"description {ServiceName} \"{Description}\""); RunSc($"description {ServiceName} \"{Description}\"");
// Auto-restart on crash: without this a crashed service stays dead until reboot, which defeats // Auto-restart on crash: without this a crashed service stays dead until reboot, which defeats
// an always-on streamer. Restart 5s / 10s / then every 60s; reset the failure counter daily. // an always-on streamer. Restart 5s / 10s / then every 60s; reset the failure counter daily.
RunSc(BuildFailureArgs()); RunSc(BuildFailureArgs());
// Let a normal (non-admin) user start/stop it — otherwise stopping needs the app's UAC prompt.
GrantUserStartStop();
return 0; return 0;
} }
/// <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>
internal static void CopyProgramTo(string sourceDir, string destDir)
{
// Guard against copying a folder onto itself (re-install from the service bin folder).
if (string.Equals(Path.GetFullPath(sourceDir).TrimEnd('\\'),
Path.GetFullPath(destDir).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
return;
var skipDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "user settings and logs", "logs", "recordings", "profiles", "config" };
static void CopyDir(string src, string dst, HashSet<string> skip)
{
Directory.CreateDirectory(dst);
foreach (var file in Directory.GetFiles(src))
File.Copy(file, Path.Combine(dst, Path.GetFileName(file)), overwrite: true);
foreach (var dir in Directory.GetDirectories(src))
{
var name = Path.GetFileName(dir);
if (skip.Contains(name)) continue;
CopyDir(dir, Path.Combine(dst, name), skip);
}
}
CopyDir(sourceDir, destDir, skipDirs);
}
/// <summary>Adds an ACE granting Authenticated Users start + stop + query on the service, so the
/// service can be stopped/started without administrator rights (a plain <c>sc stop RemSoundService</c>
/// or the app's Service menu without a UAC prompt). Reads the current security descriptor and inserts
/// the ACE, so nothing already granted is lost. Best-effort — a failure just leaves the default
/// (admin-only) rights in place.</summary>
private static void GrantUserStartStop()
{
try
{
var sddl = RunScCapture($"sdshow {ServiceName}").Trim();
var newSddl = AddUserStartStopAce(sddl);
if (newSddl is null || string.Equals(newSddl, sddl, StringComparison.Ordinal)) return;
var rc = RunSc($"sdset {ServiceName} {newSddl}");
if (rc != 0) ServiceStore.AppendServiceEvent($"install: sdset (user start/stop) returned {rc}");
}
catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: grant user start/stop failed: {ex.GetType().Name}: {ex.Message}"); }
}
/// <summary>The ACE granting Authenticated Users start (RP) + stop (WP) + query status (LC) + read
/// control (RC). Public-ish for the self-test.</summary>
internal const string UserStartStopAce = "(A;;RPWPLCRC;;;AU)";
/// <summary>Pure, testable: insert <see cref="UserStartStopAce"/> into a service SDDL's DACL (right
/// after "D:" and any DACL flags, ahead of the first ACE and the SACL). Returns null for an SDDL that
/// doesn't start with a DACL, and the input unchanged if the ACE is already present.</summary>
internal static string? AddUserStartStopAce(string? sddl)
{
if (string.IsNullOrEmpty(sddl) || !sddl.StartsWith("D:", StringComparison.Ordinal)) return null;
if (sddl.Contains(UserStartStopAce, StringComparison.OrdinalIgnoreCase)) return sddl; // already granted
var firstAce = sddl.IndexOf('(');
var sacl = sddl.IndexOf("S:", StringComparison.Ordinal);
var insertAt = firstAce >= 0 && (sacl < 0 || firstAce < sacl) ? firstAce : (sacl >= 0 ? sacl : sddl.Length);
return sddl.Insert(insertAt, UserStartStopAce);
}
/// <summary>The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a /// <summary>The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a
/// self-test can verify the format.</summary> /// self-test can verify the format.</summary>
internal static string BuildFailureArgs() => internal static string BuildFailureArgs() =>
@@ -115,7 +193,12 @@ public static class ServiceControl
{ {
if (!IsInstalled()) return 0; if (!IsInstalled()) return 0;
try { DoStop(); } catch { /* best-effort */ } try { DoStop(); } catch { /* best-effort */ }
return RunSc($"delete {ServiceName}"); var rc = RunSc($"delete {ServiceName}");
// Remove the service's own copy of the program (best-effort; a failure — e.g. a file still briefly
// locked as the service exits — just leaves a stale bin folder, which a re-install overwrites).
try { if (Directory.Exists(ServiceStore.BinDirectory)) Directory.Delete(ServiceStore.BinDirectory, recursive: true); }
catch (Exception ex) { ServiceStore.AppendServiceEvent($"uninstall: could not remove bin folder: {ex.GetType().Name}: {ex.Message}"); }
return rc;
} }
/// <summary>Starts the service. Must be run elevated. Returns 0 on success.</summary> /// <summary>Starts the service. Must be run elevated. Returns 0 on success.</summary>
@@ -167,6 +250,30 @@ public static class ServiceControl
catch { return 4; } catch { return 4; }
} }
/// <summary>Runs sc.exe and returns its stdout (empty on failure). Used to read the service's security
/// descriptor (<c>sdshow</c>) before amending it.</summary>
private static string RunScCapture(string arguments)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "sc.exe",
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi);
if (p is null) return "";
var stdout = p.StandardOutput.ReadToEnd();
p.WaitForExit(20000);
return stdout;
}
catch { return ""; }
}
/// <summary>Builds the exact sc.exe "create" argument string for a given exe path. Pure and /// <summary>Builds the exact sc.exe "create" argument string for a given exe path. Pure and
/// side-effect-free so a self-test can verify the fiddly quoting without touching the SCM. /// side-effect-free so a self-test can verify the fiddly quoting without touching the SCM.
/// ///
+9
View File
@@ -27,6 +27,15 @@ public static class ServiceStore
public static string Directory => TestDirectoryOverride ?? Path.Combine( public static string Directory => TestDirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RemSound", "service"); Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RemSound", "service");
/// <summary>ProgramData\RemSound\service\bin — the service's OWN copy of the program. The service is
/// registered to run from here, NOT from wherever it was installed from, so it never locks the app's
/// install folder or a developer's working copy (which used to block every rebuild and break the app's
/// auto-updater). Install copies the binaries here; uninstall removes them.</summary>
public static string BinDirectory => Path.Combine(Directory, "bin");
/// <summary>Full path to the service's own RemSound.exe under <see cref="BinDirectory"/>.</summary>
public static string BinExePath => Path.Combine(BinDirectory, "RemSound.exe");
private static string ProfilePath => Path.Combine(Directory, "service-profile.json"); private static string ProfilePath => Path.Combine(Directory, "service-profile.json");
private static string SettingsPath => Path.Combine(Directory, "service-settings.json"); private static string SettingsPath => Path.Combine(Directory, "service-settings.json");