Skip to content

Replace the MAC-derived routing key with an opaque generated ID, and cull three generations of dead routing code #4825

Description

@allister-beamable

The thesis

A microservice routing key is an opaque token. Its entire job is to let the gateway tell "this developer's locally running instance" apart from "that developer's" and from "the deployed one". Nothing consumes its contents. Nothing should.

We do not treat it that way. We derive it, from the machine, at every call site, by inference:

public static string GetDefaultRoutingKeyForMachine()
{
    var devices = NetworkInterface.GetAllNetworkInterfaces().ToList();
    // if we don't sort the list, then different conditions may change the routingKey order.
    devices.Sort((a, b) => String.Compare(a.Id, b.Id, StringComparison.Ordinal));
    for (var i = 0; i < devices.Count; i++)
    {
        var addrBytes = devices[i].GetPhysicalAddress().GetAddressBytes();
        if (addrBytes.Length == 0) continue;
        macBytes = devices[i].GetPhysicalAddress().GetAddressBytes();
        break;
    }
    if (macBytes == null)
        throw new InvalidOperationException(
            "cannot get routingKey for a machine with no active network addresses");
    // hash the macAddr to avoid leaking pii
    using var md5 = System.Security.Cryptography.MD5.Create();
    // ...
    return (Environment.MachineName + "_" + macAddr).Replace(":", "_").Replace(",", "_").ToLowerInvariant();
}

Every defect below is downstream of that one decision. This issue proposes replacing the derivation with a vacuous generated ID, generated once, stored, and never interpreted - and then aggressively deleting the accreted alternatives, because there are more of them than anyone would guess.

What the derivation actually does

Measured on an Apple Silicon Mac, .NET 10, 2026-09-03, with the interfaces sorted exactly as the code sorts them:

anpi0     len=6 type=Ethernet       status=Down   mac=[4263987DE9F7]   <- CHOSEN
anpi1     len=6 type=Ethernet       status=Down
anpi2     len=6 type=Ethernet       status=Down
ap1       len=6 type=Wireless80211  status=Down
awdl0     len=6 type=Wireless80211  status=Up
bridge0   len=6 type=Ethernet       status=Down
en0       len=6 type=Wireless80211  status=Up     mac=[7E5F77D2DAC9]   <- the actual Wi-Fi, 7th in line
en1..en6  len=6 type=Ethernet       status=Down
en7       len=6 type=Ethernet       status=Up                          <- the actual dock, 14th
gif0      len=0 type=Tunnel         status=Down
llw0      len=6 type=Wireless80211  status=Down
lo0       len=0 type=Loopback       status=Up
stf0      len=0 type=Tunnel         status=Down
utun0-5   len=0 type=Unknown        status=Up

Read that carefully, because it is worse than "we pick a MAC address":

  • The chosen interface is an internal Apple private interface, and it is Down. Six of the first seven candidates are down. Nothing consults OperationalStatus.
  • Nothing rejects loopback. lo0 is excluded only because .NET happens to report it with no physical address. On any platform where loopback reports six zero bytes instead, it is a candidate like any other - and on a Wi-Fi-only Linux box, lo sorts ahead of veth*, virbr*, wlan* and tun*, so it could plausibly win. Whether it does is unverified and nobody has checked.
  • The exception message is a lie. It says "no active network addresses". The code does not require active; it requires any interface with a MAC, up or down.
  • The result cannot be computed offline at all. A machine with no MAC-bearing interface cannot start a local microservice. It throws.
  • On Windows, NetworkInterface.Id is an adapter GUID, so the sort order has no relationship to interface naming, which adapter wins is arbitrary, and installing or removing any virtual adapter - Docker, a VPN, Hyper-V, VMware - can silently change the machine's routing key.

And the part that should bother us most:

  • The PII hygiene is half-applied, and the half that leaks is the one that matters. The comment says "hash the macAddr to avoid leaking pii" - and then concatenates Environment.MachineName in cleartext. Machine names are routinely people's names. This is not hypothetical: adrians-macbook-pro_933f8a0ec0c402d0b902e756f317e3ce appears in a gateway error message captured from a customer's machine in March 2026. Routing keys are written to wizard_master.service_topology, echoed verbatim in BindingNotFoundException text, and pasted into tickets, CI logs, terminal scrollback, and AI agent transcripts. We hashed the MAC, which nobody would have recognized, and shipped the human's name, which everybody does.

We have gone to real trouble - sorting for stability, hashing for privacy, sanitizing delimiters - to construct a value whose contents we then never read. That is the whole problem in one sentence.

The proposal

  1. Generate an opaque ID once - UUIDv4 is fine and boring, which is the point - at beam init or on first need.
  2. Store it in .beamable/local/config.beam.json. See the next section; this is the one thing that must not be got wrong.
  3. Read it everywhere. Fourteen call sites currently call the derivation; they all just want "my key".
  4. Do not fall back to the old derivation when the file is missing. Generate and write. A fallback preserves the exact offline failure mode we are removing.
  5. Delete the derivation and everything that grew around it. See the cull list.

The key becomes what it always was in effect: a meaningless string that identifies one developer's working copy. Nothing infers anything from it, nothing can leak through it, and it works on a plane.

Where it must live, and the trap

The obvious home is .beamable/config.beam.json. That file is committed. A project's .beamable/.gitignore contains exactly:

temp/**/*
local/**/*

So an ID written to the shared config would be checked in and shared by the entire team. Every developer's local service would claim the same routing key against the same realm, the traffic filter could no longer tell them apart, and two teammates running locally would silently fight over one identity. That is strictly worse than what we have today, which at least gives each machine a distinct key.

It has to be .beamable/local/config.beam.json, which is gitignored and already exists for per-developer state.

Note the deliberate semantic change this brings: the key stops being per-machine and becomes per-checkout. That is an improvement - two checkouts of one project on one machine currently collide on a single key - but it is a change, and it wants saying out loud rather than discovering later. A fresh clone gets a new ID; deleting .beamable/local/ regenerates one.

Why this is cheap: there is exactly one live implementation

This is the part that makes the change tractable, and it took tracing to establish rather than assuming.

  • Unity never computes the key in the live path. The editor reads latestManifest.localRoutingKey, which the CLI supplies via beam unity show-manifest (ShowManifestCommand.cs:131). UsamService and UsamRoutingStrategy only ever compare against that value.
  • The microservice never computes it. The CLI injects it as the NAME_PREFIX environment variable when starting a local service (BeamoLocalSystem_HttpMicroservice.cs:168).
  • Runtime clients send no routing header at all. Every consumer guards on CanBuildService<IServiceRoutingResolution>(), and the only registration is in Editor/Server/RegisterDependencies.cs. A shipped build registers nothing, which is correct - deployed services route with an empty routing key.

So all fourteen live call sites are in the CLI, in one process, funnelling through one function. Change the body, everything follows. There is no Unity-side implementation to version-skew against - and that is a stronger guarantee than "the mismatch window is short", because there is no second implementation to disagree in the first place.

The cull list

Every item below was grep-verified across the whole repo on 2026-09-03, excluding obj/. This is not "looks unused"; it is "has no callers".

Symbol Location Status
DefaultServiceRoutingStrategy beamable.common + Unity copy of IServiceRoutingStrategy.cs registered nowhere; only a test registers the resolution wrapper
ServiceRoutingStrategyExtensions.GetRoutingKeyMap same files zero callers
ServiceRoutingStrategyExtensions.BeamoIdsToServiceNames same files referenced only by GetRoutingKeyMap; dies with it
MicroserviceIndividualization client/.../Runtime/Server/MicroserviceIndividualization.cs [Obsolete], zero callers
IMicroservicePrefixService client/.../Runtime/Server/MicroservicePrefixService.cs zero references of any kind
UsamRoutingStrategy.GetServiceMap() Editor/Server/Usam/ServiceRoutingStrategy.cs throws NotImplementedException; the interface method, while the editor calls GetMap()

Two of these deserve individual comment, because they are not merely dead - they are actively misleading, which is the real cost of leaving dead code in a tree people debug in.

DefaultServiceRoutingStrategy is a complete, plausible, entirely unused alternative implementation. It queries Beamo for registrations and filters them against the local key:

/// <summary>
/// The routing key should be the machine name of the host.
/// ...
/// </summary>
public static string DefaultRoutingKey => ServiceRoutingStrategyExtensions.GetDefaultRoutingKeyForMachine();

public async Promise<Dictionary<string, string>> GetServiceMap()
{
    var res = await _beamo.PostMicroserviceRegistrations(new MicroserviceRegistrationsQuery(), includeAuthHeader: true);
    // ... match reg.routingKey against DefaultRoutingKey ...
}

Anyone reading the codebase to understand how routing works can land here, find a coherent implementation with a doc comment, and reason from it - and be entirely wrong, because the live path is UsamRoutingStrategy in the editor. Note also that the doc comment is inaccurate even about the code it documents: the routing key is not "the machine name of the host", it is the machine name plus an md5 of a MAC.

MicroserviceIndividualization is the fossil of the previous manual scheme - UseServicePrefix / GetServicePrefix, PlayerPrefs-backed under BeamableMicroservicePrefixes.{cid}.{pid}, last live around #2768, marked [Obsolete] at the package-combine in #4093. IMicroservicePrefixService survives beside it as a stub whose own comment reads "psuedo obsolete ... this type exists as a backwards compat signal". A backwards-compat signal with zero references is not a signal; it is litter. If we are keeping it for customer source compatibility, that intent should be an [Obsolete] attribute with a message pointing at the replacement, not a comment. If we are not, delete it.

While in the area: the Unity copy of IServiceRoutingStrategy.cs is byte-identical to the CLI's beamable.common original apart from the generated-file header, so deleting from the source deletes from both. There is no separate Unity-side cleanup to do, and no risk of the two drifting during the change.

On aggressive culling generally

This is the opinionated part, and it is the reason this issue is worth filing separately from the narrow fixes.

The routing-key area has accumulated three generations of mechanism - the PlayerPrefs prefix, the Beamo-querying strategy, the USAM discovery dropdown - and all three are still in the tree, with only the third connected to anything. That is not a tidiness problem. It is a debugging tax, and we have paid it in cash: the recent investigation that produced this issue spent real time reading DefaultServiceRoutingStrategy and chasing MicroservicePrefixService before establishing that neither runs. A customer was blocked while that happened.

The same pattern shows up immediately next door: ServiceUploadUtil.UploadStreamMulti is dead code whose doc comment sits above a different method and describes chunked resumable uploads the live path does not perform (see #4823). Two independent instances in two adjacent files suggests a habit rather than an accident.

My position: when replacing a mechanism, deleting the old one is part of the change, not a follow-up. A dead implementation that still compiles is worse than no implementation, because it answers questions incorrectly and it does so with the authority of being in the repository. Git remembers it; the tree does not need to.

What must not break

  • The header contract. Beamo matches micro_<ServiceName>:<key> via keysForService against ServiceIdentity(name, BASIC, cid, pid).fullName. This change replaces the derivation, not the format. A UUID with hyphens is fine - the existing sanitizer only replaces : and ,, and hyphens were never special.
  • The _healthCheck suffix. DeploymentService.cs:1925 builds GetDefaultRoutingKeyForMachine() + "_healthCheck" for the pre-publish health check. It must keep working. While the area is open, consider making that a structured field rather than string concatenation onto an identity.

Migration is self-healing, not a migration

The key changes exactly once, on first run after upgrade. Bindings registered under the old key are orphaned - and then reaped, because wizard_master.service_topology carries a TTL index (ttl_idx, expireAfter(0)). The window is bounded by that TTL and affects only locally-running services. Deployed services register with an empty routing key and are untouched.

No data migration, no dual-read period, no compatibility shim. The old keys expire on their own.

Release timing

This is safe in a minor. The instinct to hold it for a major is aimed at client/service mismatch, and that risk is absent for the reasons traced above: Unity has no independent implementation, the microservice is injected, runtime clients send nothing. The only conceivable skew is CLI-versus-CLI within a single checkout, and each developer's editor and CLI are pinned together.

The argument for a major is different, and it is about communication rather than compatibility: this changes an observable identifier and shifts its meaning from per-machine to per-checkout. That earns a prominent changelog entry wherever it lands - local routing keys change once, stale bindings expire on their own, and anyone who scripted against the old hostname_hash shape should stop.

Relationship to the neighbouring issues

This should sit beside #4824, not subsume it. They are closely related and genuinely separate:

Neither blocks the other, and they touch at exactly one point: #4824 proposes a free-text routing-key field, which becomes more coherent once keys are opaque generated IDs. A UUID reads as a token to paste; today's hostname_md5hash invites the belief that you can construct one by hand, or that its contents mean something. So this issue strengthens #4824's proposal rather than absorbing it.

Practical argument for keeping them apart, too: #4824 is a small Unity editor fix that could ship in a patch. This is a CLI change plus a set of deletions that wants a deliberate release slot and a changelog entry. Folding the former into the latter would bury a cheap user-visible fix inside a refactor.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions