A desktop server browser for Arma Reforger, built on the reforgermods.net v2 API. Live server list with search, sort and filters; a detail inspector with population history and mod readiness; measured ICMP latency; favorites, recents and settings; and direct joining through Steam. Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested directly; src-tauri is the window, the command surface and the wiring. Packaged for the public beta: a per-user NSIS installer, a portable .exe and an Arch package, with signature-verified background updates on Windows and a release pipeline that builds from a tag. The artifacts are not yet Authenticode-signed — that is wired and waiting on an Azure Trusted Signing identity, and docs/distribution.md says so plainly rather than letting anyone discover it at the SmartScreen dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Launcher client identification — API implementation notes
Audience: whoever maintains api.reforgermods.net (ReforgerWorkshopAPI).
Status: implemented on both sides. The launcher sends the header described below, and the API records it as a derived key. The sections headed Suggested API-side work are kept as the rationale for what was built; what actually shipped is summarised in What the API does.
The header remains additive: an API that ignores it behaves exactly as before, and the launcher does not care whether it is read.
The problem
Today the API can see "50 000 requests from the launcher" but not whether that
is 5 000 installs making 10 requests each, or 50 installs making 1 000 each.
User-Agent identifies the application; client IP is a poor proxy for a
person (CGNAT, shared households, mobile, VPNs) and is also the thing you least
want to retain.
What the launcher sends
Every request carries both of:
User-Agent: reforgermods.net-launcher/0.1.0
X-Launcher-Install-Id: 8f14e45f-ceea-467a-9c2b-1f2a3b4c5d6e
| Property | Value |
|---|---|
| Header | X-Launcher-Install-Id |
| Format | Lowercase canonical UUID, 8-4-4-4-12 hex. Always exactly 36 chars. UUID version 8 (custom derivation) where machine-derived, version 4 where random. |
| Generation | SHA-256(application salt ‖ machine value), first 16 bytes, formatted as a UUID. |
| Lifetime | Survives uninstall and reinstall, because it is derived rather than stored. Changes when the OS is reinstalled. |
| Fallback | A random v4 UUID persisted in the launcher's state file, used only where no machine value is readable. That one does not survive a reinstall. |
| Reset | None. There is no reset action, by design — see below. |
| Absent when | The stored value fails validation and no machine value is readable. The launcher sends no header at all rather than a malformed one. |
Machine value by platform
| Platform | Source |
|---|---|
| Windows | HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid |
| Linux | /etc/machine-id, else /var/lib/dbus/machine-id |
| Other / unreadable | random fallback |
These are per-OS-installation values. They are not device serials, not MAC addresses, not tied to a user account, and they change when the OS is reinstalled — which is the intended granularity.
The machine value never leaves the process. Only the salted digest is sent, so the API cannot reverse the identifier into a machine id, and the same machine running different software derives a different value. An all-zero or blank machine value is rejected rather than used, since it would collapse every affected machine onto one identifier.
Why not IP. An address changes with DHCP, VPNs, tethering and moving networks, so it would split one install into many, while merging everyone behind a shared NAT into one. It is also the one genuinely personal field in the request, and the API already sees it.
Client-side references: INSTALL_ID_HEADER in
crates/rfm-core/src/api/client.rs, stable_install_id in
crates/rfm-core/src/identity.rs.
What it is not
It is not authentication, and it must not gate anything. The launcher is a public, untrusted client. Anyone can send any value, rotate it per request, or copy someone else's. Treat it exactly as what it is: a voluntary, spoofable analytics hint.
Concretely:
- Do not rate limit on it alone. Keep the existing IP-based limiting as the enforcement mechanism. A caller that wants to evade a per-install limit only has to send a new UUID each time, so an install-keyed limit would penalise honest clients and stop nobody.
- Do not use it for entitlement, quota assignment, or moderation.
- Do use it for counting, cohorting, and retention analysis.
If per-client enforcement is ever needed, that requires real launcher authentication, which is a separate design.
What the API does
Implemented in ReforgerWorkshopAPI, matching the design below except where
noted:
| Concern | Where |
|---|---|
| Header constant | telemetry.InstallIDHeader |
| Validation | telemetry.ValidInstallID — canonical lowercase UUID, any version; the nil UUID is rejected |
| Derived key | Anonymizer.InstallKey — HMAC-SHA256(secret, id), truncated to 128 bits, domain-separated from NetworkID |
| Storage | request_events.install_key, with a partial index; added by the existing ensureColumn migration |
| Classification | launcher client kind and source; counts as activity |
| Version | split out of the User-Agent into client_version |
| Redaction | the raw header is in the middleware's redacted list, so it never reaches the stored header snapshot |
Two deliberate departures from the design below:
- The key is stored as hex
TEXT, notBLOB(16), for consistency withnetwork_idandquota_subject_hash. The value is the same 128 bits. - The key is not rotated per time window, unlike
NetworkID. A key that changes underneath the data every month cannot answer "how many distinct installs, and do they come back", which is the only reason the field exists. Correlation is bounded by rotating the secret instead, which re-partitions the whole keyspace from that point on. This is the lever referred to in Note for the API side, and it matters more given the client value is now stable and derived.
The five assertions under Testing the API side are
covered by tests: telemetry/install_id_test.go for validation and keying, and
TestLauncher* in api/telemetry_middleware_test.go end to end, including that
a caller rotating the id per request gets no more requests than one sending none.
Suggested API-side work
1. Accept and validate the header
Reject-by-ignoring, never by erroring. Suggested handling:
raw := r.Header.Get("X-Launcher-Install-Id")
if raw is not exactly a canonical lowercase v4-shaped UUID -> treat as absent
Never log the raw value unvalidated, and never echo it back in a response body or error envelope.
2. Store a salted hash, not the value
The launcher generates the id, but the API does not need to retain it verbatim to count distinct installs:
install_key = truncate(HMAC-SHA256(server_secret, install_id), 128 bits)
With a server-side secret this keeps the stored value useless if the analytics store leaks, and it prevents anyone who guesses or harvests an id from querying for that install's activity. Rotating the secret on a schedule (say quarterly) caps how far back any single key can be correlated.
3. Record it on the existing telemetry path
The API already classifies routes in telemetry/classify.go and records request
telemetry in api/telemetry_middleware.go. The natural shape is one extra
nullable column on whatever already records a request, plus a rollup:
| Field | Type | Notes |
|---|---|---|
install_key |
BLOB(16) nullable |
Hashed per above. Null for non-launcher traffic. |
client_kind |
TEXT |
launcher / web / other, parsed from User-Agent. |
client_version |
TEXT |
From the launcher's User-Agent suffix. |
Daily rollup suitable for the question that prompted this:
SELECT
date(ts) AS day,
client_version,
COUNT(*) AS requests,
COUNT(DISTINCT install_key) AS installs,
COUNT(*) * 1.0 /
NULLIF(COUNT(DISTINCT install_key), 0) AS requests_per_install
FROM request_telemetry
WHERE client_kind = 'launcher'
GROUP BY day, client_version;
That answers "50 000 requests, 5 000 installs, 10 each" directly, and splits it by launcher version so a regression in request volume is attributable to a release.
4. Retention
The install key is only needed at daily/weekly granularity. Recommend keeping
per-request rows for the existing telemetry window, and rolling up to
(day, client_version, install_key) presence rows beyond that — or to pure
counts once the cohort window has passed. There is no product reason to retain
per-install request logs indefinitely.
5. Do not add it to the public rate-limit response
GET /v2/rate-limits reports the caller's limits. Since the install id does not
affect limits (see above), it should not appear there — surfacing it would imply
it does something it does not.
Privacy position
Worth stating plainly, because this changed: the identifier is now derived from the machine rather than randomly generated, and there is no reset. That is deliberate — a resettable random value made reinstalls look like new installs, which was the exact number the metric exists to get right — but it is a real trade, and the honest framing is:
- The identifier is a salted hash of a per-OS-installation value. It carries no personal data and cannot be reversed into a machine identity.
- It is stable across reinstalls of the launcher, and changes when the OS is reinstalled.
- It is not linked to a reforgermods.net account, because the launcher has none.
- Its sole purpose is aggregate usage measurement.
- It is not shown in the UI. It appears only when the launcher is started with
RFM_DEBUG=1, which is a developer affordance, not a user setting.
Anyone wanting genuinely uncorrelatable requests has to block the header, which is a thing a user cannot do from inside the launcher. If that matters for the project's public posture, the mitigation is disclosure — a line in the launcher's description or privacy text — rather than an in-app toggle, because a toggle would reintroduce exactly the undercount the derivation fixes.
Note for the API side
Because the client value is now stable and derived, the server-side hashing in step 2 matters more, not less: it is what stops the analytics store from holding a long-lived per-machine key. Rotating the server secret on a schedule also caps how far back any single key can be correlated, which is the main lever left for limiting retention.
Testing the API side
Once implemented, these should hold:
- A request with no header is accepted and recorded with a null
install_key. - A request with a malformed header (
not-a-uuid, 10 KB of text, CRLF injection attempt) is accepted and recorded as if absent — never 4xx, never logged raw. - Two requests with the same id produce the same
install_key; with different ids, different keys. - The same id sent to two different deployments with different secrets produces different keys.
- Rate limiting behaviour is unchanged by the presence or value of the header.
Open question for the API owner
Should the launcher also send a coarse platform token (windows / linux) so
the rollup can distinguish "5 000 Windows installs" from a Proton cohort? That
would be one more low-cardinality header and would answer the Linux-support
question from real data rather than guesswork. The launcher does not send one
today; say the word and it is a two-line change.
Still open, and now worth deciding. The beta publishes an Arch package, so there is a Linux cohort to measure for the first time. Note that the question is partly answerable without a new header: the User-Agent is recorded, and the Windows and Linux builds could carry distinguishable version suffixes. A separate low-cardinality header is cleaner, and neither is built.