feat(felis): add felis nano — Yggdrasil hasJoined multiplexer without a control plane

`felis nano` serves the vanilla sessionserver protocol
(GET /session/minecraft/hasJoined) as a federating multiplexer over
Mojang plus any number of third-party Yggdrasil roots, with no k3s,
Postgres, or panel — a MultiLogin-style auth front-end delivered as a
subcommand of the single felis binary rather than a separate build.

- config.LoadNano reads only [[auth_source]] blocks; it skips the
  database.url / root_domain / archive requirements the full server
  needs. Zero sources is valid (Mojang-only).
- Mojang is prepended in code (Identity:true), never from config, so it
  is always the sole identity root. Third-party profiles are rewritten
  to canonical = UUIDv3(felisAuthNS, tag+":"+nativeID).
- validateAuthSources rejects unknown keys, duplicate tags, and
  scheme-less URLs — a malformed nano config fails loud at load.
- Reuses api.HasJoinedHandler with a stub Repo (no blacklist backend);
  a rejected login is a 204, matching the vanilla sessionserver.
- nano.go binds the -listen flag and ignores [server] listen in config.

Verified on WSL (go1.26.4): go build/vet/test ./... green; a runtime
smoke against the template config returns 204 on a miss and logs
"Mojang + 0 third-party source(s)"; a duplicate-tag config exits
non-zero citing "unique".
This commit is contained in:
flyemoji committed 2026-07-13 02:39:41 +09:00
1 parent cc0c945811
commit d177428fb0
6 files changed
+188 -16

No files matched your search

+13
View File
@@ -59,6 +59,19 @@ type sessionProfile struct {
Properties []json.RawMessage `json:"properties,omitempty"`
}
// 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: authlib speaks the vanilla sessionserver protocol and sends no
// service token. A rejected login is 204 No Content — exactly what Mojang returns for an
+48 -10
View File
@@ -176,20 +176,31 @@ const (
defaultUserUploadsContext = "s3://felis-user-uploads"
)
// Load reads and validates a felis.toml from path.
func Load(path string) (*Config, error) {
// decodeConfig reads a felis.toml and rejects unknown keys (typos surface as errors
// rather than silently ignored config). Both the full Load and the nano-only LoadNano
// share it, so the unknown-key contract is owned in one place.
func decodeConfig(path string) (Config, error) {
var cfg Config
md, err := toml.DecodeFile(path, &cfg)
if err != nil {
return nil, fmt.Errorf("config: decode %s: %w", path, err)
return cfg, fmt.Errorf("config: decode %s: %w", path, err)
}
if undecoded := md.Undecoded(); len(undecoded) > 0 {
// Surface typos rather than silently ignoring unknown keys.
keys := make([]string, len(undecoded))
for i, k := range undecoded {
keys[i] = k.String()
}
return nil, fmt.Errorf("config: unknown keys in %s: %s", path, strings.Join(keys, ", "))
return cfg, fmt.Errorf("config: unknown keys in %s: %s", path, strings.Join(keys, ", "))
}
return cfg, nil
}
// Load reads and validates a full felis.toml (the control-plane binaries: api, migrate,
// reaper).
func Load(path string) (*Config, error) {
cfg, err := decodeConfig(path)
if err != nil {
return nil, err
}
cfg.applyDefaults()
if err := cfg.Validate(); err != nil {
@@ -198,6 +209,27 @@ func Load(path string) (*Config, error) {
return &cfg, nil
}
// LoadNano reads a felis.toml for a Felis-nano host — the hasJoined multiplexer only, no
// control plane. It validates just the [[auth_source]] block and deliberately skips the
// control-plane requirements (database.url, root_domain, archive store) that a nano host has
// no Postgres or FQDN for: forcing a fake database.url onto a pure hasJoined federator would
// be a lie that breaks the moment anything touches it. The auth-source rules (unique tags,
// scheme-qualified URLs) are the SAME code path Load enforces, so nano cannot reopen the
// cross-source impersonation hole a full deployment is protected from.
func LoadNano(path string) (*Config, error) {
cfg, err := decodeConfig(path)
if err != nil {
return nil, err
}
if cfg.Server.Listen == "" {
cfg.Server.Listen = defaultListen
}
if err := cfg.validateAuthSources(); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) applyDefaults() {
if c.Server.Listen == "" {
c.Server.Listen = defaultListen
@@ -251,11 +283,17 @@ func (c *Config) Validate() error {
if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") {
return fmt.Errorf("config: [registry] url %q must be a bare host[:port] with no scheme (e.g. registry.felis.svc:5000); a scheme breaks the user-modpack build lane's derived push target", c.Registry.URL)
}
// Felis-nano auth sources: 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.
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.
func (c *Config) validateAuthSources() error {
seenTags := make(map[string]struct{}, len(c.AuthSources))
for i, s := range c.AuthSources {
if s.Tag == "" {
+41
View File
@@ -293,6 +293,47 @@ url = "bare.example.net/hasJoined"
}
}
// TestLoadNanoAcceptsMinimalConfig is the linchpin of the Felis-nano fold: a nano host has no
// Postgres and no FQDN, so LoadNano must accept a felis.toml carrying ONLY [[auth_source]] —
// the control-plane requirements (database.url, root_domain) that full Load enforces are
// deliberately skipped. It still applies the listen default and hands back the sources.
func TestLoadNanoAcceptsMinimalConfig(t *testing.T) {
cfg, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "littleskin"
url = "https://littleskin.example.net/api/yggdrasil/sessionserver/session/minecraft/hasJoined"
`))
if err != nil {
t.Fatalf("LoadNano minimal: %v", err)
}
if len(cfg.AuthSources) != 1 || cfg.AuthSources[0].Tag != "littleskin" {
t.Fatalf("auth sources = %+v, want one littleskin source", cfg.AuthSources)
}
if cfg.Server.Listen != "0.0.0.0:8080" {
t.Errorf("default listen = %q, want 0.0.0.0:8080", cfg.Server.Listen)
}
}
// TestLoadNanoStillEnforcesAuthSourceRules pins that skipping the control-plane requirements
// does NOT skip the crown-jewel auth-source guard: a duplicate tag still collapses two sources
// into one UUID namespace, and LoadNano must reject it exactly as Load does (shared code path).
func TestLoadNanoStillEnforcesAuthSourceRules(t *testing.T) {
_, err := config.LoadNano(writeTOML(t, `
[[auth_source]]
tag = "dup"
url = "https://a.example.net/hasJoined"
[[auth_source]]
tag = "dup"
url = "https://b.example.net/hasJoined"
`))
if err == nil {
t.Fatal("expected LoadNano to reject a duplicate auth_source tag")
}
if !strings.Contains(err.Error(), "unique") {
t.Errorf("error should explain the tags-must-be-unique contract, got: %v", err)
}
}
func TestLoadRejectsUnknownKeys(t *testing.T) {
_, err := config.Load(writeTOML(t, `
[server]