ENGINEERING

How we cut agentless Windows deploys from 183s to 26s (by deleting sc.exe)

When RFF onboards a new Windows machine, the first deploy is agentless: we reach the box over SMB, install our agent as a service, and let it take over from there. On day one that bootstrap took ~183 seconds per machine. Onboard a 50-machine site and you’re staring at the screen for over two hours.

Here’s how we got it to ~26 seconds, and the Win32 rabbit hole that got us there.

The smoking gun: sc.exe \\host is glacial as SYSTEM

Our agent installer drove the remote Service Control Manager the obvious way - by shelling out to sc.exe:

sc.exe \\HOST create   RffAgent binPath= "..." start= auto
sc.exe \\HOST config   RffAgent ...
sc.exe \\HOST failure  RffAgent reset= 60 actions= restart/5000/restart/10000/restart/30000
sc.exe \\HOST start    RffAgent
...

Six-ish sc.exe calls per deploy. We timed them: ~22 seconds each. Almost all of it was spent before the command did any work.

The cause: the installer runs as LocalSystem, and each sc.exe \\HOST is a fresh child process with its own logon session and no inherited SMB context. Every call re-authenticates to the target from scratch - and as SYSTEM it tries Kerberos first, waits for it to fail, then falls back to NTLM. Multiply that handshake by six and you’ve burned two minutes authenticating the same machine six times.

Fix #1: authenticate once, then ride that session

The trick is to establish one authenticated SMB session to the target and run everything over it. We open a WNetConnection to \\HOST\IPC$ with explicit credentials, which authenticates our logon session against the box exactly once. Every subsequent SCM call piggybacks on that session via the \PIPE\svcctl named pipe instead of re-authenticating.

(One marshaling landmine here: the NETRESOURCE struct must be declared CharSet = CharSet.Unicode. Get it wrong and the string fields marshal as ANSI and you get a baffling error 67 - network name not found. Cost us an afternoon.)

Fix #2: talk to the SCM directly, not through a process per command

With an authenticated session in hand, there’s no reason to spawn sc.exe at all. The SCM is just an advapi32.dll API. We open one SCM handle per host and reuse it for create / configure / start:

// One handle, reused for every service operation on this host.
_hScm = OpenSCManagerW($@"\\{host}", null,
    SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE);

IntPtr svc = CreateServiceW(_hScm, name, displayName,
    SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, SERVICE_AUTO_START,
    SERVICE_ERROR_NORMAL, binPath,
    null, IntPtr.Zero, null,
    null,   // lpServiceStartName = null → LocalSystem
    null);

No child processes, no per-call auth, no Kerberos-timeout tax. Six 22-second shellouts collapse into a handful of in-process API calls over the session we already paid for.

The gotchas nobody documents

Auto-restart needs two rights, not one. Setting SERVICE_CONFIG_FAILURE_ACTIONS requires the service handle to hold both SERVICE_CHANGE_CONFIG and SERVICE_START. Open it with only CHANGE_CONFIG and the call returns ERROR_ACCESS_DENIED (5) with no hint why:

IntPtr svc = OpenServiceW(_hScm, name, SERVICE_CHANGE_CONFIG | SERVICE_START);

Failure actions are an unmanaged array. SERVICE_FAILURE_ACTIONS points at a C array of SC_ACTION structs, so you hand-marshal it: AllocHGlobal, StructureToPtr each element, pass the pointer, and FreeHGlobal in a finally. Tedious, but it’s a one-time helper.

Trust the start request, verify out of band. StartService returns at START_PENDING, not RUNNING. We don’t block waiting for the transition - the agent’s first poll back to our API is the real “it’s alive” signal. Treat SERVICE_ALREADY_RUNNING (1056) as success so the path is idempotent on retries.

When you do need credentials: CreateProcessWithLogonW

Some steps genuinely have to launch a process as a specific user on the target (not everything is an SCM call). For those, the equivalent win is CreateProcessWithLogonW with the LOGON_NETCREDENTIALS_ONLY flag - the credentials are used for outbound network auth only, which skips the same slow interactive-logon path that was killing us.

Result

Before (sc.exe shellouts)After (direct SCM + held session)
Auth handshakes per deploy~6 (one per call)1
Per-machine bootstrap~183s~26s

~7× faster, and far fewer moving parts to fail. The whole detour came down to one principle: authenticate once and reuse the session, instead of letting the OS silently re-auth behind every convenient shellout.

Once the agent’s on the box, the reverse operation - fleet-wide uninstall - has its own crop of edge cases: interactive uninstallers hanging forever in session 0, winget refusing to load its DLLs under SYSTEM. Same lesson, different Win32 corner - the OS fights you, and the win is refusing the convenient-but-broken default.


We’re building RFF - hosted fleet management for Windows: deploy software, automate patching, and run remote actions from a browser tab. Free for your first 100 endpoints, no sales call. This kind of plumbing is what you don’t have to think about when it’s hosted.