Files
Felis/internal/api/handlers_hasjoined.go
T
Lemon-miaow 9dad61f508 fix(nano): screen unusable profiles per source instead of stopping the ladder (#55)
A configured Yggdrasil root answering 200 with a name outside the Minecraft charset (or an identity UUID that does not parse) was rejected one layer up in the handler: a silent 204 with no log line, and because the rejection returned instead of continuing, every source behind the broken one was unreachable for that login. The resolver already treats the same class (200 without a usable profile, non-200, unreachable) as skip + log + failed; the name/UUID screens lived above it and silently stopped the ladder instead.

Live on the audit box, a single sloppy root produced 204s with no trace anywhere, and [bad root, valid root] answered 204 where the valid root would have admitted the login; nothing else in the nano matrix (60 checks across input validation, canonical rewrite, premium rename, failure modes, failover, log discipline, properties relay) was red.

Screen both shapes inside resolveHasJoined, before a 200 can win: identity ids must parse, third-party names must match the charset. A bad answer is logged ('unusable profile name' / 'unparseable profile id'), skipped, and counted as failed — 503 when nothing else validates, and later sources get their turn. The handler's guards stay as the last line before anything leaves (comments updated).

Gates: gofmt, go vet, go test ./..., deploy/bootstrap_test.sh all clean. Green live (v0.0.0+fix55): the five bad-name cases and the two failover cases all pass; matrix rerun 60/60.
2026-09-23 21:21:21 +08:00

389 lines
17 KiB
Go

package api
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// Felis-nano: the multi-source hasJoined multiplexer behind player game-login.
//
// Velocity is pointed here with -Dmojang.sessionserver and issues the request itself, not
// through authlib. On login it computes the serverId hash and GETs hasJoined; this
// endpoint fans that query out to the configured Yggdrasil roots in priority order
// (Mojang first, 正版优先) and returns the first source that validates. Each upstream
// Yggdrasil runs its own serverId-hash check — the multiplexer only relays, it computes
// no hashes.
//
// The one non-negotiable transform: a non-identity (third-party) source's UUID is
// self-asserted, so its profile is rewritten into a per-source namespace
// (canonical = UUIDv3(felisAuthNS, tag+":"+nativeID)) BEFORE it leaves the resolver.
// Mojang stays identity. This makes the reclaim invariant — "the genuine Mojang player
// has a DIFFERENT UUID from any squatter" — true by construction, not assumed: MD5
// preimage resistance means no third-party source can mint a Mojang-space UUID, and the
// per-tag namespace means two sources cannot collide onto one identity. Every downstream
// key (account_links, username_blacklist, owner checks) then sees one canonical UUID.
//
// One consequence a backend operator meets: a chat-session key a third-party source signed
// over its native UUID cannot verify against the canonical one, even on a backend that
// trusts that source's key. Such players' chat can only be accepted unsigned.
// felisAuthNS is the fixed UUIDv3 namespace every third-party profile is rewritten
// under (see the rewrite rationale above). Derived from the project name, not a magic
// literal, so its origin is self-documenting; the exact value only has to be stable.
var felisAuthNS = uuid.NewSHA1(uuid.NameSpaceURL, []byte("nano.felis.lolicon.best/auth-source"))
// authHTTPClient calls the upstream Yggdrasil roots. The timeout bounds one login
// against a hung source; the resolver moves on to the next source on any failure.
// One shared client, sequential priority scan — a third-party login costs one
// wasted Mojang round-trip; add parallel fan-out only if login latency bites.
var authHTTPClient = &http.Client{
Timeout: 5 * time.Second,
Transport: upstreamTransport,
// A redirect is not a hasJoined answer. Following one would let a configured root point
// this host at any URL it can reach — this listener included, where each hop re-runs the
// whole source scan inside the same login's timeout. The 3xx is returned as-is and the
// resolver skips that source like any other non-200.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// upstreamTransport caps response headers, which the 64 KiB body limit does not cover. The
// default allows 1 MiB, so a root that sends that much and then stalls the body pins a few
// MiB per in-flight login for the whole timeout, and enough parallel logins OOM the host
// for every source. Real roots answer in well under 1 KiB of headers.
var upstreamTransport = func() *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxResponseHeaderBytes = 16 << 10
return t
}()
// AuthSource is one upstream Yggdrasil root in the multiplexer's priority list (config
// order = priority). URL is the full hasJoined endpoint the query string is appended to.
// Identity marks the authoritative source (Mojang) whose UUIDs are trusted as-is; every
// other source is rewritten into felisAuthNS. Prefix is the in-game rename applied to a
// player of this source who is holding a Mojang player's name (see prefixedName); it is
// unused on the identity source, whose players are never renamed.
type AuthSource struct {
Tag string
Prefix string
URL string
Identity bool
}
// sessionProfile is the Mojang hasJoined contract. properties is relayed verbatim
// (json.RawMessage) so a source's signed textures survive the multiplexer untouched, and
// it is always emitted as an array: Velocity's GameProfile parser throws on a missing or
// null properties key, while a Yggdrasil root may legitimately send [] or omit it for a
// player with no skin.
type sessionProfile struct {
ID string `json:"id"`
Name string `json:"name"`
Properties []json.RawMessage `json:"properties"`
}
// HasJoinedHandler returns an http.Handler serving only the Felis-nano hasJoined
// multiplexer route (GET /session/minecraft/hasJoined), for a standalone host that
// federates logins without standing up the full felis-api. sources is the priority list
// (put the Mojang identity source first for 正版优先); repo backs the reclaim blacklist
// gate — a stub that never bars is fine for a host without the reclaim DB. The full
// felis-api mounts the same handler through its internal-face route table instead.
func HasJoinedHandler(sources []AuthSource, repo Repo) http.Handler {
a := &API{AuthSources: sources, Repo: repo}
mux := http.NewServeMux()
mux.HandleFunc("GET /session/minecraft/hasJoined", a.handleHasJoined)
return mux
}
// handleHasJoined is the multi-source session verifier (Felis-nano). It is a Public
// internal-face route: Velocity speaks the vanilla sessionserver protocol and sends no
// service token. A rejected login is 204 No Content — exactly what Mojang returns for an
// invalid session, which Velocity answers with its online-mode-only kick.
func (a *API) handleHasJoined(w http.ResponseWriter, r *http.Request) {
// Velocity sends no body. When a request declares one anyway, net/http tries to drain it
// before writing any answer, so one that never arrives holds the connection with no
// timeout: ReadHeaderTimeout stopped at the headers. Marking the reply as the last one on
// this connection skips the drain.
if r.ContentLength != 0 {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusBadRequest)
return
}
q := r.URL.Query()
username, serverID, ip := q.Get("username"), q.Get("serverId"), q.Get("ip")
if username == "" || serverID == "" ||
len(username) > maxHasJoinedParam || len(serverID) > maxHasJoinedParam || len(ip) > maxHasJoinedParam {
w.WriteHeader(http.StatusNoContent)
return
}
prof, src, failed := a.resolveHasJoined(r.Context(), username, serverID, ip)
if prof == nil {
// With a source down, "nobody knows this player" is not established: its player may
// be the one logging in. 503 makes Velocity report the auth servers as down and log
// the status, where a 204 would tell that player their account is offline-mode.
if failed {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
// Canonicalize identity. A trusted (Mojang) source keeps its UUID; a self-asserted
// source is rewritten into felisAuthNS so it can never land in Mojang's UUID space
// nor onto another source's. resolveHasJoined has already screened both shapes and
// skipped unusable ones as failed; the two guards below are the last line before
// anything leaves, kept even though nothing reaches them.
var canonical uuid.UUID
if src.Identity {
id, err := uuid.Parse(prof.ID)
if err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = id
} else {
// A third-party source is untrusted input, its name included: nothing stops a
// hostile or sloppy root from answering with "§4admin", an empty string, or 200
// characters, all of which must not be relayed straight into the proxy's player
// list. Screened in resolveHasJoined; this is the last-line guard.
if !mcUsernameRe.MatchString(prof.Name) {
w.WriteHeader(http.StatusNoContent)
return
}
canonical = uuid.NewMD5(felisAuthNS, []byte(src.Tag+":"+prof.ID))
// Give a Mojang player's name back to the Mojang player. The UUID rewrite above
// already keeps the two apart as identities, but the proxy's player registry is
// keyed on the NAME (Velocity: "You are already connected to this proxy!"), so
// without this they cannot even be online at the same time. Renaming only on an
// actual collision leaves the ordinary third-party player's name untouched.
if isPremiumName(r.Context(), prof.Name) {
prof.Name = prefixedName(src.Prefix, prof.Name)
}
}
// Bar gate at the single chokepoint every login crosses, so a reclaimed squatter
// stays out even on a consumer with no limbo plugin. Keyed on the dashed canonical
// UUID — the same form Repo.ReclaimUsername stores.
barred, err := a.Repo.IsUsernameBlacklisted(r.Context(), canonical.String())
if err != nil {
writeError(w, r, err)
return
}
if barred {
w.WriteHeader(http.StatusNoContent)
return
}
// Emit the canonical UUID undashed — the 32-hex form Velocity's GameProfile expects.
prof.ID = hex.EncodeToString(canonical[:])
if prof.Properties == nil {
prof.Properties = []json.RawMessage{} // a nil slice would marshal as null
}
writeJSON(w, http.StatusOK, prof)
}
// maxHasJoinedParam bounds each query value before it is forwarded to every source. What
// Velocity sends fits with room to spare: a login name of at most 16 characters, a signed
// SHA-1 hex serverId of at most 41, a textual IP address. Only a direct caller sends more.
const maxHasJoinedParam = 64
// mcUsernameRe is Minecraft's username charset — the trust boundary on a third-party
// source's self-asserted profile name.
var mcUsernameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
// mcUsernameMax is the protocol's username length ceiling, which prefixedName must respect.
const mcUsernameMax = 16
// prefixedName is the squatter rename: ("LS", "steve") → "LS_steve". The base name is
// TRUNCATED to fit rather than the rename being skipped when it would not fit — skipping is
// what would silently hand a 14-character premium name back to the squatter.
//
// Two players of one source whose names agree on their first mcUsernameMax-len(prefix)-1
// characters truncate onto the same in-game name, as does a prefixed name that happens to be
// a premium name itself. Both cost an "already connected" bounce, not an identity: the UUID
// rewrite is what keeps players apart, and it does not depend on the name at all. Add a
// disambiguating suffix only if real players actually collide.
func prefixedName(prefix, name string) string {
p := prefix + "_"
if keep := mcUsernameMax - len(p); len(name) > keep {
name = name[:keep]
}
return p + name
}
// mojangProfileAPI answers the one question that decides a rename: is this username
// registered to a Mojang account? A var, not a const, so a test can point it at a stub
// instead of the real Mojang.
var mojangProfileAPI = "https://api.mojang.com/users/profiles/minecraft/"
// profileHTTPClient is deliberately more impatient than authHTTPClient: the name lookup is a
// SECOND Mojang round-trip on a third-party login (the identity leg already spent one), and
// api.mojang.com is exactly what is unreliable from the networks these servers sit on. A
// slow answer counts as taken instead of holding the login open.
var profileHTTPClient = &http.Client{Timeout: 2 * time.Second, Transport: upstreamTransport}
// A name's premium status changes on human timescales, not per login, so it is cached — but
// asymmetrically, because the two directions have very different costs. "Taken" changes
// only when its owner renames away, and a stale "taken" costs a third-party player nothing
// but a prefix. "Free" can stop being true the moment someone buys that name, and a stale
// "free" is the dangerous one: it leaves a squatter holding a name its real owner has just
// bought. So a "free" answer is trusted for minutes and a "taken" answer for a day.
const (
premiumTakenTTL = 24 * time.Hour
premiumFreeTTL = 10 * time.Minute
premiumCacheMax = 4096
)
type premiumEntry struct {
taken bool
at time.Time
}
var premiumNames = struct {
sync.Mutex
m map[string]premiumEntry
}{m: make(map[string]premiumEntry)}
// isPremiumName reports whether username belongs to a real Mojang account — which is what
// makes a third-party player holding it a squatter. A lookup failure fails CLOSED (assume
// premium → rename the third-party player), even over an expired "free": the name may have
// been bought since, and a Mojang outage must not let a squatter keep it. Being wrong that
// way costs a cosmetic prefix; being wrong the other way bounces the name's actual owner off
// the proxy.
func isPremiumName(ctx context.Context, username string) bool {
key := strings.ToLower(username)
premiumNames.Lock()
cached, hit := premiumNames.m[key]
premiumNames.Unlock()
if hit && time.Since(cached.at) < premiumTTL(cached.taken) {
return cached.taken
}
taken, err := lookupPremiumName(ctx, username)
if err != nil {
return true
}
premiumNames.Lock()
// Bounded by dropping the whole map rather than evicting LRU. Any third-party source that
// validates a login mints an entry, so a hostile one can force clears; that costs repeat
// lookups, or a fail-closed prefix while Mojang is unreachable, never an identity. This
// is a backstop against an unbounded map, not a cache policy worth tuning.
if len(premiumNames.m) >= premiumCacheMax {
clear(premiumNames.m)
}
premiumNames.m[key] = premiumEntry{taken: taken, at: time.Now()}
premiumNames.Unlock()
return taken
}
func premiumTTL(taken bool) time.Duration {
if taken {
return premiumTakenTTL
}
return premiumFreeTTL
}
// lookupPremiumName asks Mojang whether a name is registered: 200 = it is, 404 (204 on the
// legacy endpoint) = it is free. Anything else is an ERROR, never a "no" — a 429 or a 503
// must not read as "this name is unowned"; see isPremiumName's fail-closed rule.
func lookupPremiumName(ctx context.Context, username string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mojangProfileAPI+url.PathEscape(username), nil)
if err != nil {
return false, err
}
resp, err := profileHTTPClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound, http.StatusNoContent:
return false, nil
}
return false, fmt.Errorf("mojang profile api: %s", resp.Status)
}
// resolveHasJoined queries each configured source in priority order and returns the
// first that validates the session (200 with a profile). 204 is "not my player". Any other
// outcome (unreachable, another status, a body that is not a profile, or a profile this
// multiplexer will not emit) skips the source too, but is logged with its tag and reported
// as failed: otherwise a dead or mistyped source looks exactly like a player it does not
// know, and nobody finds out.
func (a *API) resolveHasJoined(ctx context.Context, username, serverID, ip string) (prof *sessionProfile, src AuthSource, failed bool) {
for _, src := range a.AuthSources {
u := src.URL + "?username=" + url.QueryEscape(username) + "&serverId=" + url.QueryEscape(serverID)
if ip != "" {
u += "&ip=" + url.QueryEscape(ip)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
log.Printf("hasJoined: source %q: %v", src.Tag, err)
failed = true
continue
}
resp, err := authHTTPClient.Do(req)
if err != nil {
log.Printf("hasJoined: source %q: %v", src.Tag, err)
failed = true
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNoContent:
case resp.StatusCode >= 300 && resp.StatusCode < 400:
log.Printf("hasJoined: source %q answered %s with Location %q; redirects are not followed, so set its url to the final endpoint", src.Tag, resp.Status, resp.Header.Get("Location"))
failed = true
default:
log.Printf("hasJoined: source %q answered %s", src.Tag, resp.Status)
failed = true
}
continue
}
var p sessionProfile
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&p)
resp.Body.Close()
if err != nil || p.ID == "" {
log.Printf("hasJoined: source %q answered 200 without a usable profile (err=%v)", src.Tag, err)
failed = true
continue
}
// Screen what a 200 is allowed to mean BEFORE it can win. A profile this
// multiplexer will not emit — an identity UUID that does not parse, a third-party
// name outside the Minecraft charset — is the same class as a body that is not a
// profile: skip, log, count as failed. Letting it win would stop the ladder on one
// sloppy root (every source behind it silently unreachable) and read as "nobody
// knows this player" to Velocity while a source had actually answered.
if src.Identity {
if _, perr := uuid.Parse(p.ID); perr != nil {
log.Printf("hasJoined: identity source %q answered 200 with an unparseable profile id %q", src.Tag, p.ID)
failed = true
continue
}
} else if !mcUsernameRe.MatchString(p.Name) {
log.Printf("hasJoined: source %q answered 200 with an unusable profile name %q", src.Tag, p.Name)
failed = true
continue
}
return &p, src, failed
}
return nil, AuthSource{}, failed
}