feat(nano): give a Mojang player's name back to them, by prefixing the squatter

A premium player and a third-party player sharing a username could not both be
online. Whichever logged in second was kicked with "You are already connected to
this proxy!" -- even though the UUID rewrite had already made them two distinct
players on the backend. Velocity's player registry is keyed on the NAME (lowercased),
not the UUID, so two identities holding one name are one player as far as the proxy
is concerned, and the reclaim invariant the rewrite buys is invisible to it.

The fix needs no plugin and no state, because Velocity honours the name in the
hasJoined RESPONSE rather than pinning the one the client sent at login-start --
established by a real login, not by reading the source. So the multiplexer hands
back a different name and the collision is simply gone.

A third-party player whose name belongs to a Mojang account now joins as
PREFIX_name (LS_steve). Everyone else keeps their own name: the rename fires only
on an actual collision, decided by asking api.mojang.com whether the name is
registered. The name's owner is never the one renamed, which is 正版优先 falling out
for free -- the identity source is never rewritten, so there is no policy to encode
and no 30-day hold to track.

The premium-name answer is cached asymmetrically, because the two directions have
very different costs. "Taken" is nearly permanent (Mojang does not recycle names) and
is trusted for a day; "free" can stop being true the moment someone buys that name,
and a stale "free" leaves a squatter holding a name its real owner has just bought,
so it is trusted for ten minutes. A lookup that fails with nothing cached fails
CLOSED -- assume premium, rename the third-party player: a Mojang outage must not
become an opportunity to hold someone else's name, and being wrong that way costs a
cosmetic prefix while being wrong the other way bounces the name's owner off the
proxy. The lookup gets its own 2s client rather than sharing the 5s auth client,
since it is a SECOND Mojang round-trip on a login that already spent one.

prefix is a required, unique, 1-4 character config field rather than something
derived from the tag, because it is player-visible and no derivation can know that
"littleskin" is meant to read LS. Two sources sharing a prefix would rewrite their
same-named players onto one name, so uniqueness is enforced case-insensitively --
the proxy folds case, and LS/ls would collide there while reading as distinct here.

Also close a pre-existing hole on the path this touches: a third-party source's
profile name was relayed verbatim, so a hostile or sloppy Yggdrasil root could put
"§4admin", an empty string, or 200 characters straight into the proxy's player list.
The name is now checked against the Minecraft username charset and a bad one is a 204,
the same way a bad UUID already was.

Verified end to end on the deploy host (Velocity 3.5.1 + Paper 26.2), both branches:

  premium FLYEMOJ1     -> 195fadbd-f72e-4b9b-9f8f-f92586fe16ad, name unchanged
  LittleSkin FLYEMOJ1  -> LS_FLYEMOJ1, f1b7b6ae-f250-348a-b069-a2ec0fcae668
  both online at once, zero "already connected" rejections
  LittleSkin FelisNyaTest01 -> joins as FelisNyaTest01, no prefix, UUID still v3

The last line is the one that matters: an ordinary third-party player collides with
nobody and keeps their name, while the rewrite that keeps identities apart still ran.
Paper's "LS_FLYEMOJ1 (formerly known as li_FLYEMOJ1) joined the game" is the other
half of it -- the rename moved the player's display name and their playerdata came
along untouched, because every server-side key is the UUID and the UUID does not
depend on the name.

Known ceiling, left alone deliberately: two players of one source whose names agree
on their first 16-len(prefix)-1 characters truncate onto the same in-game name, and a
prefixed name may itself happen to be a premium name. Both cost an "already connected"
bounce, not an identity -- the UUID rewrite does not depend on the name at all.

BREAKING CHANGE: every [[auth_source]] now requires prefix = "XX" (1-4 letters or
digits, unique across sources). An existing nano felis.toml without it fails to load
with an error naming the field, rather than silently keeping the collision.
This commit is contained in:
flyemoji committed 2026-07-13 13:00:54 +09:00
1 parent b323975ddb
commit fd062882ed
6 files changed
+370 -16

No files matched your search

+33 -9
View File
@@ -6,6 +6,7 @@ package config
import (
"fmt"
"regexp"
"strings"
"github.com/BurntSushi/toml"
@@ -36,10 +37,14 @@ type Config struct {
// Felis-nano multiplexer federates over. Tag names the source's per-source UUID
// namespace (must be unique — two sources sharing a tag would collide onto one identity);
// URL is the full hasJoined endpoint (scheme-qualified) the query string is appended to.
// Prefix is what a player from this source is renamed with when their name belongs to a
// Mojang player (LS_steve) — player-visible, so it is written out rather than derived from
// the tag, which cannot know that "littleskin" is meant to read LS.
// No trusted/identity field, by design — see Config.AuthSources.
type AuthSourceConfig struct {
Tag string `toml:"tag"`
URL string `toml:"url"`
Tag string `toml:"tag"`
Prefix string `toml:"prefix"`
URL string `toml:"url"`
}
// ServerConfig is the [server] table.
@@ -286,15 +291,24 @@ func (c *Config) Validate() error {
return c.validateAuthSources()
}
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag and a
// scheme-qualified hasJoined URL, and tags must be unique. A blank or duplicate tag collapses
// two sources into one UUID namespace (cross-source impersonation — the exact invariant the
// per-source rewrite exists to hold); a scheme-less URL makes http.NewRequest fail so the
// source is silently dead (never validates any login). Both fail fast at load, not per-login.
// Split out from Validate so the nano-only LoadNano (no control-plane fields) enforces the
// identical rules — the impersonation guard has one owner, shared by full-api and nano.
// authSourcePrefixRe is the shape of a prefix. It is prepended to a real Minecraft
// username (LS_steve), so it is confined to the username charset and kept short enough to
// leave a legible name behind after truncation.
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix must be unique. A
// blank or duplicate tag collapses two sources into one UUID namespace (cross-source
// impersonation — the exact invariant the per-source rewrite exists to hold); a duplicate
// prefix collapses two same-named players from different sources onto one in-game name
// (they stay distinct identities, but neither can be online while the other is); a
// scheme-less URL makes http.NewRequest fail so the source is silently dead (never validates
// any login). All fail fast at load, not per-login. Split out from Validate so the nano-only
// LoadNano (no control-plane fields) enforces the identical rules — the impersonation guard
// has one owner, shared by full-api and nano.
func (c *Config) validateAuthSources() error {
seenTags := make(map[string]struct{}, len(c.AuthSources))
seenPrefixes := make(map[string]struct{}, len(c.AuthSources))
for i, s := range c.AuthSources {
if s.Tag == "" {
return fmt.Errorf("config: [[auth_source]] #%d has an empty tag; each source's tag is its per-source UUID namespace", i+1)
@@ -303,6 +317,16 @@ func (c *Config) validateAuthSources() error {
return fmt.Errorf("config: [[auth_source]] tag %q is used twice — tags are per-source UUID namespaces and must be unique", s.Tag)
}
seenTags[s.Tag] = struct{}{}
if !authSourcePrefixRe.MatchString(s.Prefix) {
return fmt.Errorf(`config: [[auth_source]] %q needs prefix = "XX" (1-4 letters or digits, e.g. "LS" for LittleSkin), got %q; a player of this source whose name belongs to a Mojang account is renamed XX_name so the two can be online at once`, s.Tag, s.Prefix)
}
// Case-insensitively — the proxy's player registry folds case, so LS and ls would
// collide there even though they read as two different prefixes here.
lower := strings.ToLower(s.Prefix)
if _, dup := seenPrefixes[lower]; dup {
return fmt.Errorf("config: [[auth_source]] prefix %q is used twice — two sources sharing a prefix rewrite their same-named players onto the same in-game name", s.Prefix)
}
seenPrefixes[lower] = struct{}{}
if !strings.HasPrefix(s.URL, "http://") && !strings.HasPrefix(s.URL, "https://") {
return fmt.Errorf("config: [[auth_source]] %q url %q must be a scheme-qualified http(s):// hasJoined endpoint", s.Tag, s.URL)
}
+59
View File
@@ -211,9 +211,11 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
[[auth_source]]
tag = "guild"
prefix = "GD"
url = "https://guild.example.net/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
@@ -258,9 +260,11 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
@@ -283,6 +287,7 @@ root_domain = "mc.example.net"
url = "postgres://felis@db/felis"
[[auth_source]]
tag = "bare"
prefix = "BR"
url = "bare.example.net/hasJoined"
`))
if err == nil {
@@ -301,6 +306,7 @@ func TestLoadNanoAcceptsMinimalConfig(t *testing.T) {
cfg, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
@@ -321,9 +327,11 @@ func TestLoadNanoStillEnforcesAuthSourceRules(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "dup"
prefix = "AA"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
prefix = "BB"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
@@ -334,6 +342,57 @@ url = "https://b.example.net/hasJoined"
}
}
// TestLoadRejectsBadAuthSourcePrefix pins the rename-prefix contract. The prefix is prepended
// to a real Minecraft username (LS_steve) when a third-party player is holding a Mojang
// player's name, so it must exist and must be legal there — a missing or illegal prefix would
// otherwise only surface as a login the proxy silently refuses, months later, the first time
// two players collide.
func TestLoadRejectsBadAuthSourcePrefix(t *testing.T) {
for name, prefix := range map[string]string{
"missing": "",
"too long": "TOOLONG",
"underscore": "L_",
"non-ascii": "皮肤",
} {
t.Run(name, func(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "`+prefix+`"
url = "https://littleskin.example.net/hasJoined"
`))
if err == nil {
t.Fatalf("expected error for prefix %q", prefix)
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
})
}
}
// TestLoadRejectsDuplicateAuthSourcePrefix: two sources sharing a prefix rewrite their
// same-named players onto the SAME in-game name, which is the collision the prefix exists to
// break. Case-insensitively, because the proxy's player registry folds case.
func TestLoadRejectsDuplicateAuthSourcePrefix(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
prefix = "LS"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "otherskin"
prefix = "ls"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected LoadNano to reject two sources sharing a prefix (case-insensitively)")
}
if !strings.Contains(err.Error(), "prefix") {
t.Errorf("error should name the prefix contract, got: %v", err)
}
}
func TestLoadRejectsUnknownKeys(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]