Files
Felis/internal/api/handlers_player_reclaim.go
T
flyemoji 9c46632929 feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency
- Add `felis setup` TUI for initial Owner provisioning and optional Cloudflare edge
- Refactor breakGlass to share console TUI model (runConsoleTUI) with setup mode
- Session auth respects configured [auth].admin_hostname; fallback to op.console.<root>
- Protect linked Yggdrasil admins from Mojang-priority reclaim (spec §B3)
- cfsetup: idempotent Access app/policy creation, better 401/403 errors, GET + lookup
- Bootstrap: auto-install cloudflared, symlink /etc/felis/felis.toml
- Add sequence diagrams for ping-to-join, claim, and link flows
2026-06-28 16:41:37 +09:00

151 lines
6.5 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
)
// Username-collision reclaim (spec §B3, internal face). The configured
// third-party Yggdrasil and the official Mojang service can mint the SAME
// username under DIFFERENT UUIDs. Velocity detects the collision in a limbo login
// server; when the connecting player is NOT the genuine Mojang owner, Mojang
// takes priority (正版优先): the squatter is rejected and barred, and its data is
// stashed for a 30-day window so a new account can inherit it.
//
// These two endpoints are the Go-verifiable data layer of that flow, both
// internal-face (velocity holds a service token, never a web Principal):
//
// POST /api/v1/internal/player/reclaim — record a reclaim (bar + stash)
// GET /api/v1/internal/player/blacklist/{uuid} — the login gate's bar check
//
// Velocity collision-routing, the limbo prompt, the authlib dual-backend, and the
// data-inherit flow are CODE-ONLY (Java + a QR-bound device session a Postgres
// row cannot express) and are not represented here. The block is keyed by UUID,
// never by the contested name, so the genuine Mojang player — same username,
// different UUID — is never caught.
const (
// reclaimHoldTTL is the 30-day window a reclaimed account's data is stashed for
// before it may be purged (spec §B3 "您的数据将会被暂存 30 天"). The hold's
// expires_at is the API clock + this, so one authoritative clock drives expiry.
reclaimHoldTTL = 30 * 24 * time.Hour
)
// newHoldID returns an opaque random row id (128 bits, hex) for a
// player_data_holds row, mirroring newOTPID.
func newHoldID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// reclaimRequest is the velocity reclaim callback body (spec §B3): the verified
// online-mode UUID of the squatter being displaced, the contested username (for
// display/audit), and an optional opaque handle to the data already archived for
// the hold. data_ref is optional — archival may be deferred — but a squatter_uuid
// and username are always required.
type reclaimRequest struct {
SquatterUUID string `json:"squatter_uuid"`
Username string `json:"username"`
DataRef string `json:"data_ref"`
}
// handleReclaimUsername records a Mojang-priority username reclaim (spec §B3,
// internal face). In one transaction it bars the squatter UUID and stashes its
// data as a 30-day hold (Repo.ReclaimUsername). It is idempotent: a repeat
// reclaim of an already-barred UUID is a no-op that still answers 200, so a
// retried velocity callback is harmless. It returns the hold's expiry so velocity
// can tell the rejected player how long their data is kept.
func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
var req reclaimRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if req.SquatterUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "squatter_uuid is required"))
return
}
if req.Username == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username is required"))
return
}
// Admin-on-Yggdrasil exception (spec §B3). Before barring the holder, check
// whether the displaced UUID is a Linked Operator/SysAdmin authenticating through
// the third-party Yggdrasil. Such a holder is staff on the Login Server, not a
// Mojang squatter, so Mojang priority must NOT displace them: refuse the reclaim
// outright — no bar, no stash — so the protected admin never enters the blacklist
// and the login gate naturally passes them. The exception is scoped strictly to
// admins; an ordinary thirdparty player is still reclaimed (Mojang priority holds).
protected, err := a.Repo.IsProtectedAdminLink(r.Context(), req.SquatterUUID)
if err != nil {
writeError(w, r, err)
return
}
if protected {
// Distinct audit action so a refusal is never mistaken for a bar — the
// accountability record shows the reclaim was declined, and why.
payload, _ := json.Marshal(map[string]string{
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
_ = a.Repo.Audit(r.Context(), AuditEntry{
Actor: "velocity", Source: "internal", Action: "player.reclaim.refused",
RequestID: requestIDFromContext(r.Context()), Payload: payload,
})
writeError(w, r, newError(http.StatusConflict, "protected_admin",
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
return
}
id, err := newHoldID()
if err != nil {
writeError(w, r, err)
return
}
proposedExpiry := a.now().Add(reclaimHoldTTL)
// ReclaimUsername returns the EFFECTIVE persisted expiry, which differs from the
// proposed one on an idempotent retry (the hold keeps its first window). We echo
// the persisted value so a re-firing velocity callback never tells the player a
// 30-day window that the stored hold does not actually have.
heldUntil, err := a.Repo.ReclaimUsername(r.Context(), id, req.SquatterUUID, req.Username, req.DataRef, proposedExpiry)
if err != nil {
writeError(w, r, err)
return
}
// A reclaim bars a player and stashes their world — a security-significant
// accountability event. The squatter UUID and contested name go in the audit
// payload (the flat columns model a server op, not this), keyed by Source
// internal since velocity, not a human, drives it.
payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
_ = a.Repo.Audit(r.Context(), AuditEntry{
Actor: "velocity", Source: "internal", Action: "player.reclaim",
RequestID: requestIDFromContext(r.Context()), Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{
"blacklisted": true,
"username": req.Username,
"hold_expires_at": heldUntil.UTC(),
})
}
// handleCheckBlacklist reports whether an in-game UUID was barred by a prior
// reclaim (spec §B3, internal face). The velocity login gate calls it to reject a
// squatter before letting them in; the genuine Mojang UUID (same name, different
// UUID) is never on the list, so it always passes.
func (a *API) handleCheckBlacklist(w http.ResponseWriter, r *http.Request) {
mcUUID := r.PathValue("mc_uuid")
if mcUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return
}
blacklisted, err := a.Repo.IsUsernameBlacklisted(r.Context(), mcUUID)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"blacklisted": blacklisted})
}