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:
6 files changed
+188
-16
No files matched your search
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user