diff --git a/cmd/felis/api.go b/cmd/felis/api.go index f0b4ef0..0c6c322 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -36,6 +36,20 @@ import ( // the code marks Identity (UUIDs trusted verbatim); config can never add another. const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined" +// authSourcesFromConfig builds the multiplexer's priority list from the configured +// [[auth_source]] entries: Mojang leads as the code-owned identity anchor (正版优先, the ONLY +// Identity source — config can only append namespace-rewritten third-party sources, never a +// trusted one), then each configured source in file order. Both `felis api` and `felis nano` +// call it, so the "Mojang is prepended in code" invariant lives in exactly one place. +func authSourcesFromConfig(configured []config.AuthSourceConfig) []api.AuthSource { + sources := make([]api.AuthSource, 0, len(configured)+1) + sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true}) + for _, s := range configured { + sources = append(sources, api.AuthSource{Tag: s.Tag, URL: s.URL}) + } + return sources +} + // cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The // internal face (service token) is fully wired. The external face is wired but // fails closed until an Access JWKS key function is configured — the verifier's @@ -228,12 +242,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // so a misconfig cannot reopen the impersonation hole. No sources = a.AuthSources stays // nil = the endpoint 204s every login (ships off). if len(cfg.AuthSources) > 0 { - sources := make([]api.AuthSource, 0, len(cfg.AuthSources)+1) - sources = append(sources, api.AuthSource{Tag: "mojang", URL: mojangSessionServer, Identity: true}) - for _, s := range cfg.AuthSources { - sources = append(sources, api.AuthSource{Tag: s.Tag, URL: s.URL}) - } - a.AuthSources = sources + a.AuthSources = authSourcesFromConfig(cfg.AuthSources) fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources)) } diff --git a/cmd/felis/nano.go b/cmd/felis/nano.go new file mode 100644 index 0000000..62254f7 --- /dev/null +++ b/cmd/felis/nano.go @@ -0,0 +1,68 @@ +package main + +// felis nano: the Felis-nano hasJoined multiplexer as a felis subcommand — the lightweight +// serve path for a third-party server operator who wants multi-Yggdrasil federation without a +// full Felis control plane (no k3s, no Postgres, no DB). It reads [[auth_source]] from +// felis.toml, leads with Mojang as the code-owned identity anchor (正版优先), and serves the +// vanilla sessionserver hasJoined endpoint. Point Velocity at it with +// -Dmojang.sessionserver=http://:8081/session/minecraft/hasJoined +// and authlib verifies logins against Mojang plus every configured third-party source. +// +// This is the no-database delivery of the identical brain `felis api` mounts through its +// route table (internal/api.HasJoinedHandler). `felis setup --nano` / the bootstrap nano +// choice install this as the runtime service; here it just serves. + +import ( + "context" + "flag" + "fmt" + "io" + "net/http" + + "felis.lolicon.best/internal/api" + "felis.lolicon.best/internal/config" +) + +// nanoStubRepo satisfies api.Repo but implements only the one method handleHasJoined calls. +// The reclaim username blacklist is a felis-api/DB concern; a nano host has no Postgres, so +// nothing is barred here. ponytail: a real blacklist would need the very DB nano exists to +// avoid — YAGNI until a nano host grows a reclaim store. +type nanoStubRepo struct{ api.Repo } + +func (nanoStubRepo) IsUsernameBlacklisted(context.Context, string) (bool, error) { return false, nil } + +func cmdNano(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("nano", flag.ContinueOnError) + fs.SetOutput(stderr) + cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (reads [[auth_source]])") + listen := fs.String("listen", ":8081", "listen address for the hasJoined endpoint") + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.LoadNano(*cfgPath) + if err != nil { + fmt.Fprintln(stderr, "felis nano:", err) + return 1 + } + + handler := api.HasJoinedHandler(authSourcesFromConfig(cfg.AuthSources), nanoStubRepo{}) + fmt.Fprintf(stderr, "felis nano: hasJoined multiplexer on %s — Mojang + %d third-party source(s)\n", *listen, len(cfg.AuthSources)) + for i, s := range cfg.AuthSources { + fmt.Fprintf(stderr, " [%d] %s -> %s\n", i+1, s.Tag, s.URL) + } + + // Log each request so a live login attempt is visible while testing against a real + // Velocity — "is authlib even reaching me?" is the first question during verification. + logged := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(stderr, "felis nano: %s %s\n", r.Method, r.RequestURI) + handler.ServeHTTP(w, r) + }) + + srv := newAPIServer(*listen, logged) + if err := srv.ListenAndServe(); err != nil { + fmt.Fprintln(stderr, "felis nano:", err) + return 1 + } + return 0 +} diff --git a/cmd/felis/run.go b/cmd/felis/run.go index f1c4453..209f2fa 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -14,6 +14,7 @@ Commands: migrate up Apply embedded database migrations under an advisory lock operator Run the MinecraftServer controller-manager api Run the felis-api HTTP server + nano Run the Felis-nano hasJoined multiplexer (multi-Yggdrasil, no control plane) reaper Run the world reaper / backup batch restore Extract a world archive into a world volume (internal Job entrypoint) backup Archive a world into the backup store and record it (internal Job entrypoint) @@ -40,6 +41,8 @@ func run(args []string, stdout, stderr io.Writer) int { return cmdOperator(rest, stdout, stderr) case "api": return cmdAPI(rest, stdout, stderr) + case "nano": + return cmdNano(rest, stdout, stderr) case "reaper": return cmdReaper(rest, stdout, stderr) case "restore": diff --git a/internal/api/handlers_hasjoined.go b/internal/api/handlers_hasjoined.go index 430c4d6..5129ae9 100644 --- a/internal/api/handlers_hasjoined.go +++ b/internal/api/handlers_hasjoined.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 58df864..ebfb2ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 19e75f8..bbca9dd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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]