Skip to content

Epic ECSA Role Usage Guide

The epic_ecsa role applies Epic Client Systems (ECSA) baseline settings on Windows servers: Microsoft Defender exclusions, IIS request/query limits, IIS version-header removal, ECF-over-TLS prerequisites, Microsoft Update registration, IIS log cleanup, the Epic Windows Server hardening baseline, all-users Edge favorites/desktop shortcuts to the Epic web apps, and suppression of Edge's first-run experience. It also carries one operational action rather than a setting: forcing a Kuiper check-in on the Satellite agent.

Design

Every section is opt-in via group_vars: the role ships inert defaults (empty lists, false flags), and each section only acts on hosts whose group has enabled it. That makes any tag safe to run against the whole Windows fleet — hosts that haven't opted in are untouched.

The one exception is satellite_checkin, which configures nothing and takes no data: it is enabled by default and stays fleet-safe by skipping hosts that don't have the Satellite agent installed.

A tag is required to indicate intent. Running without tags (or with --tags all) fails immediately.

# Everything a host has opted into
ansible-playbook playbooks/deploy-epic-ecsa.yml --limit os_windows \
  --tags defender,iis_limits,ecf_tls,ms_update,iis_logs,hardening,edge_first_run

# Just the Defender exclusions
ansible-playbook playbooks/deploy-epic-ecsa.yml --limit app_icfg --tags defender

Tags

Tag What it does Enabling variable
defender Ensures Microsoft Defender path exclusions are present (additive; never removes). epic_ecsa_defender_exclusions and/or epic_ecsa_defender_general_exclusions (lists of paths, combined at run time)
iis_limits Enforces per-site IIS request/query limits; unlocks serverRuntime at server level when needed. epic_ecsa_iis_sites (list of site dicts)
iis_headers Removes the Server and X-Powered-By response headers so the IIS and ASP.NET versions aren't advertised (see below). epic_ecsa_iis_remove_version_headers: true
ecf_tls Enables the CAPI2 Operational event log and sets SchUseStrongCrypto in both .NET registry hives. epic_ecsa_ecf_tls: true
ms_update Registers the Microsoft Update service and sets AllowAUOnServerOS for .NET updates. epic_ecsa_microsoft_update: true
iis_logs Creates a daily scheduled task (as SYSTEM) deleting aged IIS logs. epic_ecsa_iis_log_cleanup: true
hardening Applies the Epic Windows Server hardening baseline (see below). epic_ecsa_hardening: true
edge_shortcuts Places all-users links to the Epic web apps: a .url internet shortcut on the All Users desktop and a managed favorite on the Edge favorites bar (see below). epic_ecsa_edge_shortcuts (list of name/url dicts)
edge_first_run Skips Edge's first-run/welcome flow for every user via the HideFirstRunExperience policy (see below). epic_ecsa_edge_disable_first_run: true
satellite_checkin Forces an immediate Kuiper check-in with Satellite.exe /f, for use right after machines are registered with Kuiper (see below). epic_ecsa_satellite_checkin: true (the default)
su_eeds_cleanup Creates a daily scheduled task (as the Kuiper gMSA) that prunes aged RA package directories from the ECSM SU_EEDS package root (see below). epic_ecsa_su_eeds_cleanup: true plus epic_ecsa_su_eeds_cleanup_username

Full variable reference: ansible-doc -t role epic_ecsa.

Where the variables live

Per the group taxonomy, settings that apply to every Epic Windows server sit in group_vars/os_windows/vars.yml (epic_ecsa_ecf_tls: true, epic_ecsa_hardening: true, and the General Configuration Defender exclusion set in epic_ecsa_defender_general_exclusions), and per-server-type settings sit in the matching app_* group:

  • group_vars/app_icfg/vars.yml (Interconnect): the Interconnect-specific Defender exclusions, the Default Web Site request size limits for Haiku/Canto uploads and the OIDC query-string limits, and IIS log cleanup.
  • group_vars/app_hsw/vars.yml (Hyperspace Web): the Hyperspace-Web-specific Defender exclusions, Microsoft Update registration, and IIS log cleanup.
  • group_vars/app_ahsw/vars.yml (Authentication Hyperspace Web): the same settings as app_hsw, plus removal of the IIS/ASP.NET version response headers.
  • group_vars/app_kuiper/vars.yml (Kuiper): the Kuiper-specific Defender exclusions — the ECSM Tools install and ProgramData directories. The base package directory ("varies" in Galaxy) must be added once a package source is configured. The SU_EEDS package cleanup settings belong here too, since the package root only exists on the Kuiper server.
  • group_vars/app_systempulse/vars.yml (System Pulse): the System-Pulse-specific Defender exclusions — the MSMQ storage directory.

New server types opt in by adding the corresponding variables to their app_* group.

IIS limits reference

Each epic_ecsa_iis_sites entry names an IIS site and the limits to enforce; omitted keys are left untouched:

epic_ecsa_iis_sites:
  - name: Default Web Site
    max_request_length: 50000             # KB, system.web/httpRuntime
    upload_read_ahead_size: 50000000      # bytes, system.webServer/serverRuntime
    max_allowed_content_length: 50000000  # bytes, requestFiltering/requestLimits
    max_query_string_length: 4096         # system.web/httpRuntime
    max_query_string: 4096                # bytes, requestFiltering/requestLimits

OIDC authentication failures

max_query_string_length and max_query_string must be set per site (they cannot be set globally). Missing them causes intermittent OIDC authentication failures on Interconnect.

IIS version response headers

The iis_headers tag stops IIS from advertising which IIS and ASP.NET versions a host is running. Enable it with epic_ecsa_iis_remove_version_headers: true; both headers are written at server level (applicationHost.config), so every site on the host inherits the setting:

  • Server (e.g. Microsoft-IIS/10.0) is generated by IIS itself, so it is switched off with the system.webServer/security/requestFiltering removeServerHeader attribute. This attribute exists only in IIS 10 (Windows Server 2016 or later). Skip it with epic_ecsa_iis_remove_server_header: false.
  • X-Powered-By: ASP.NET is a plain entry in the system.webServer/httpProtocol custom headers collection, so the role deletes the entry rather than switching anything off. Skip it with epic_ecsa_iis_remove_powered_by_header: false.

Both changes take effect on the next response — no IIS restart or app-pool recycle is needed. Verify from a client with curl.exe -sI https://<host>/ and confirm neither header comes back.

Scope: server level and these two headers only

A site's own web.config can re-add X-Powered-By for that site, which would override the server-level removal — check the site config if the header persists. The ASP.NET X-AspNet-Version and X-AspNetMvc-Version headers are controlled separately (httpRuntime enableVersionHeader and an MVC application setting) and are not touched by this tag.

In this repo the tag is enabled on group_vars/app_ahsw/vars.yml (Authentication Hyperspace Web), the internet-reachable authentication endpoint. Other IIS server types opt in the same way.

Defender exclusions reference

The exclusion lists come from Epic Galaxy (the General Configuration antivirus document plus the per-server-type Server Setup documents). They are split across two variables because group_vars lists replace, not merge: epic_ecsa_defender_general_exclusions holds the General Configuration set shared by every server type (defined once on os_windows, so every Epic Windows server receives it), and epic_ecsa_defender_exclusions holds only the paths specific to that server type (defined on the app_* group). The role applies the union of the two. Exclusions are compared case-insensitively and %SystemDrive%-style environment variables are passed to Defender verbatim.

IIS log cleanup defaults

The scheduled task deletes *.log files recursively under C:\inetpub\logs\LogFiles whose last write time is older than 30 days, daily at 03:00 local time, running as SYSTEM. Override with epic_ecsa_iis_log_cleanup_path, epic_ecsa_iis_log_cleanup_retention_days, epic_ecsa_iis_log_cleanup_time, and epic_ecsa_iis_log_cleanup_task_name.

SU_EEDS package cleanup

The su_eeds_cleanup tag deploys a PowerShell script and a daily scheduled task that prune aged RA package directories from the ECSM package root, by default C:\ProgramData\Epic\ECSMTools\Packages\SU_EEDS. On a live customer server the RA directories accounted for 12.8 GB of a 26.5 GB package tree, so they are usually the bulk of what is worth reclaiming.

# group_vars/app_kuiper/vars.yml
epic_ecsa_su_eeds_cleanup: true
epic_ecsa_su_eeds_cleanup_username: 'SAPPHIRE\svcKuiper$'
epic_ecsa_su_eeds_cleanup_retention_days: 30
epic_ecsa_su_eeds_cleanup_keep_minimum: 10
epic_ecsa_su_eeds_cleanup_whatif: false   # only after reviewing the log

What it selects

Only immediate children of the package root whose name matches the RA pattern are ever considered:

RA-<number>_<MM-DD-YYYY>        e.g. RA-2031_08-20-2026

An RA is deleted only when both conditions hold — it is older than epic_ecsa_su_eeds_cleanup_retention_days, and it is outside the newest epic_ecsa_su_eeds_cleanup_keep_minimum. Either gate on its own misbehaves: age alone empties a server that has been quiet for a while, and count alone drops RAs that are still current during a busy release week.

Age is taken from the date in the directory name, never from a file timestamp. Extracted client-package trees carry the original archive mtimes, so a ten-day-old RA can legitimately contain files dated years earlier — a per-file age rule would delete current packages.

Everything else in the package root (Releases, Hyperdrive, EpicWebView2, Release_Files, and any folder a given customer happens to have) is matched against that pattern, skipped, and recorded in the log. Matching is a positive allowlist rather than a list of names to avoid, so an unfamiliar folder is never a candidate for deletion.

It reports before it deletes

epic_ecsa_su_eeds_cleanup_whatif defaults to true, so a newly installed task logs the directories it would delete and removes nothing. Review C:\ProgramData\Sapphire\Logs\su_eeds_cleanup.log on a real server, then set it to false. Ansible itself never deletes anything — it only installs the task — so running this tag is safe in --check and in production alike.

Why the Kuiper service account

The task runs as the Kuiper gMSA (epic_ecsa_su_eeds_cleanup_username, in DOMAIN\account$ form) rather than SYSTEM, so it can clean up a package destination held on a file share as well as a local one — a machine account has no rights off-box. No password is stored: the task is registered with logon type password and none supplied, and Task Scheduler retrieves the managed password itself.

Two host prerequisites, or the task registers but never starts:

  • the gMSA is installed on the host (Install-ADServiceAccount, and the host is in the account's PrincipalsAllowedToRetrieveManagedPassword group);
  • the gMSA holds the log on as a batch job right. The role adds it by default (epic_ecsa_su_eeds_cleanup_grant_batch_logon), additively, so existing holders are untouched.

The account also needs Modify on the package root.

Checking a run

The scheduled task's Last Run Result distinguishes the outcomes: 0 success, 1 one or more deletions failed, 2 refused to run because the package root was missing or resolved to a volume or share root. Each run appends its decisions — kept, skipped, deleted, or would-delete — to epic_ecsa_su_eeds_cleanup_log_path.

Windows Server hardening baseline

The hardening tag implements Epic's Server2022Hardening document (ansible-epic#6). The document ships the settings as a PowerShell script meant for a scheduled task; the role enforces the same settings with native, idempotent modules instead:

  • Local password policy (win_security_policy): password history 24, maximum age 365 days, minimum age 1 day, minimum length 14, complexity required, reversible encryption disabled. Thresholds are overridable via epic_ecsa_hardening_password_history, epic_ecsa_hardening_maximum_password_age, epic_ecsa_hardening_minimum_password_age, and epic_ecsa_hardening_minimum_password_length — the defaults are the document's required values.
  • Logon rights (win_user_right, action: set — the configured list is authoritative): Allow log on locallyepic_ecsa_hardening_local_logon_principals (default BUILTIN\Administrators); Allow log on through Remote Desktop Servicesepic_ecsa_hardening_rdp_logon_principals (default BUILTIN\Administrators, BUILTIN\Remote Desktop Users).
  • Guest account disabled.
  • Registry hardening (win_regedit): NTLMv2-only with 128-bit session security (LmCompatibilityLevel=5, NTLMMinClientSec/NTLMMinServerSec), no LM hashes, anonymous SAM/share enumeration blocked, SMBv1 client and server disabled, LLMNR (multicast name resolution) off, insecure guest logons off, RDS password saving off, CredSSP Encryption Oracle Remediation forced to updated clients, Group Policy registry processing set to reapply, Continue experiences on this device off, and the DisableBkGndGroupPolicy value removed.

Ansible connectivity is unaffected

Ansible connects over WinRM/PSRP using a network logon with Kerberos or NTLMv2, so restricting interactive logon rights and refusing LM/NTLMv1 does not lock Ansible (or RDP administrators) out.

Edge shortcuts

The edge_shortcuts tag places all-users links to the Epic web apps on a host. Each entry in epic_ecsa_edge_shortcuts is a name/url pair:

epic_ecsa_edge_shortcuts:
  - name: Kuiper
    url: https://kuiper.ire-copier.internal/Kuiper
  - name: System Pulse
    url: https://systempulse.ire-copier.internal/SystemPulse

For every entry the role publishes three surfaces, all machine-wide (every user):

  • A .url internet shortcut on the All Users desktop (C:\Users\Public\Desktop\<name>.url, override the directory with epic_ecsa_edge_shortcuts_desktop). A .url opens in the user's default browser with the default icon.
  • A favorite in an Edge favorites-bar folder, via Edge's ManagedFavorites policy (HKLM\SOFTWARE\Policies\Microsoft\Edge). All entries land in one locked folder named by epic_ecsa_edge_favorites_folder (default Epic); the bar is forced visible via FavoritesBarEnabled unless epic_ecsa_edge_favorites_bar_enabled: false. Set epic_ecsa_edge_favorites_folder_enabled: false to skip this surface.
  • An Edge New Tab Page quick-link tile, via the NewTabPageManagedQuickLinks policy. As a machine policy the tiles are pinned at the front of the New Tab Page for all users and can't be removed. The policy schema accepts at most three entries — a longer list is rejected whole (edge://policy shows "Value is out of range") and renders no tiles at all — so the role publishes only the first three shortcuts as tiles; Edge fetches each site's favicon itself. Set epic_ecsa_edge_ntp_tiles: false to skip this surface.

Modern Edge always nests managed favorites in a folder

There is no all-users policy to pin loose (folder-less) favorites directly on the favorites bar in Chromium-based Edge — ManagedFavorites always uses a locked folder. The legacy ProvisionFavorites policy that allowed loose favorites applies only to the retired Edge (EdgeHTML). Use the New Tab Page tiles (above) for folder-free prominence.

In the Azure IRE environment the URLs are the load-balanced front-ends defined in group_vars/platform_azure/vars.yml. The Kuiper and System Pulse links (kuiper_lb_url, system_pulse_lb_url) each point at the app's IIS path (/Kuiper, /SystemPulse) and are placed on both the Kuiper and System Pulse servers (group_vars/ire_copier_azure_app_kuiper/vars.yml and group_vars/ire_copier_azure_app_systempulse/vars.yml). The Kuiper server additionally gets host-root links to the Hyperspace Web, Harbor Admin, and Interconnect VIPs (hsw_lb_url, harboradmin_lb_url, icfg_lb_url). Those load-balanced hostnames are also added to the matching server certificate's SAN list (certificate_authority_cert_sans): kuiper_internal_hostname/system_pulse_internal_hostname in the Kuiper/System Pulse scoped groups, and hsw_internal_hostname + harboradmin_internal_hostname and icfg_internal_hostname in group_vars/ire_copier_azure_app_hsw/vars.yml and group_vars/ire_copier_azure_app_icfg/vars.yml, so the URLs validate over HTTPS. (The New Tab Page shows only the first three tiles, so on the Kuiper server the desktop shortcuts and favorites cover all five links while the tiles cover the first three.)

Edge first run experience

The edge_first_run tag sets Edge's HideFirstRunExperience policy (HKLM\SOFTWARE\Policies\Microsoft\Edge) to 1, so the browser skips its first-run/welcome flow — the "Welcome to Microsoft Edge" pages, the import prompt, and the initial sign-in/personalization walkthrough — for every user on the machine.

It is enabled for the whole Windows fleet in group_vars/os_windows/vars.yml (epic_ecsa_edge_disable_first_run: true), alongside epic_ecsa_ecf_tls and epic_ecsa_hardening.

Why this is a separate tag from edge_shortcuts

edge_shortcuts gates on a non-empty epic_ecsa_edge_shortcuts list and is only set on the Kuiper and System Pulse groups, so it cannot carry a setting meant for every server. edge_first_run takes no data, which is what lets it live on a broad group.

Because it is written as a machine policy rather than into a user profile, it also applies to profiles created after the role runs — the common case on an Epic server, where an analyst's profile is first created on their initial RDP logon.

Satellite check-in

A Kuiper-managed machine only picks up its work when its Satellite agent checks in, which by default happens every five minutes. The satellite_checkin tag runs Satellite.exe /f on the machine so it collects that work immediately — the point being to run it right after the machines have been registered with Kuiper:

# Register the fleet with Kuiper (runs against the Kuiper server)
ansible-playbook --limit app_kuiper playbooks/deploy-kuiper.yml --tags machines

# Then check the machines in, so Kuiper hands them their work now
ansible-playbook --limit os_windows playbooks/deploy-epic-ecsa.yml --tags satellite_checkin

The two steps are separate runs because they target different tiers: registration is an API call made on the Kuiper server (kuiper role, --tags machines or fleet), while the check-in runs on each managed machine. Narrow the second run with --limit when only some machines need it.

Satellite.exe lives in a version-numbered directory (C:\Program Files (x86)\Epic\Satellite\<version>\), so the role reads the install directory from the EpicSatellite service's ImagePath rather than assuming a path. Point epic_ecsa_satellite_checkin_path at the executable to skip that discovery, and epic_ecsa_satellite_checkin_service at a different service name if the registration is named otherwise.

Confirming the check-in actually happened

Satellite ends its output with The force check-in has completed successfully. There are no outstanding actions to retrieve from Kuiper. even when it checked nothing in — so that line alone is not evidence. The role requires the line naming the connection instead:

Message: Satellite has requested a forced check-in
Message: Connected to Kuiper with URL "https://kuiper.example.org/Kuiper/"
Message: Completed posting results to Kuiper and retrieving actions from Kuiper.

There are two ways a run ends with the completion line but no check-in:

  • The invoked binary is superseded. Satellite upgrades itself into a new version directory, and the old one refuses to work: Error: System.Exception: This process does not match the active install of Epic Satellite. Run the process at C:\Program Files (x86)\Epic\Satellite\119.0.6.0\Satellite.exe. The role reads that path out of the output and re-runs from it — see Following a Satellite upgrade below.
  • Satellite's run type is Service. The invoked process only starts the EpicSatellite service and shuts itself down (Warning: System.Exception: The desired run type is a Service...); the service then checks in out of view, where no output can confirm it. Set epic_ecsa_satellite_checkin_require_connect: false on those hosts to accept the completion line on its own — the failure message says as much when it sees that warning. The Azure IRE environment runs Satellite as a service (Kuiper's Satellite settings enable it for monitoring), so the flag is set for the whole environment in group_vars/ire_copier_azure_platform/vars.yml.

The task also reports changed only when the check-in retrieved outstanding actions; a check-in that found nothing pending leaves the machine alone. epic_ecsa_satellite_checkin_verify: false drops the verification entirely, for a Satellite release that changes the wording.

Following a Satellite upgrade

A check-in can move the install out from under the binary that started it — either the run is refused as superseded, or the check-in applies an upgrade and the next check-in has to come from the new version directory. So the tag doesn't run the executable once: it retries up to epic_ecsa_satellite_checkin_attempts times (default 3), each attempt resolving the active install again — preferring the path a refused run named, otherwise re-reading the service registration — and never running the same executable twice.

An attempt that finds nothing new to run ends the loop, so the ordinary case (no upgrade) costs one registry read beyond the single check-in, and a machine that upgrades itself gets its follow-up check-in in the same play instead of waiting for the next scheduled one.

An upgrade that lands after the run exits is not waited for

The follow-up happens when the new install is already visible as the run returns — a refusal naming the new path, or a moved service registration. If Satellite installs the upgrade in the background after the process exits, that machine picks it up at its next check-in (or on the next run of this tag).

Hosts without the agent are skipped, not failed

The check-in is gated on finding Satellite.exe, so the tag is safe to run against os_windows as a whole — a Kuiper server or any other machine that isn't Satellite-managed simply skips. --check mode skips the check-in itself (forcing one is an action, not a pending change) and the verification with it, while still reporting whether the executable was found.

Two things that look like problems but aren't

The "the Scheduled Task will shut down and be disabled" wording in the run-type-Service case is not something the run did — the \Epic Satellite scheduled task is already disabled on machines running the service. And CheckInStats.json's LastCheckIn does not move on a forced check-in, so it can't be used to confirm one happened either.

Idempotency

All configuration sections read current state before writing and only change what differs, so re-runs report changed=0 and --check mode shows real pending changes. The satellite_checkin action follows the same reporting rule from the other direction: repeated check-ins with nothing pending stay ok, and only a check-in that pulled work down reports changed.