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
+15
-6
@@ -36,6 +36,20 @@ import (
|
|||||||
// the code marks Identity (UUIDs trusted verbatim); config can never add another.
|
// the code marks Identity (UUIDs trusted verbatim); config can never add another.
|
||||||
const mojangSessionServer = "https://sessionserver.mojang.com/session/minecraft/hasJoined"
|
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
|
// 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
|
// 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
|
// 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
|
// so a misconfig cannot reopen the impersonation hole. No sources = a.AuthSources stays
|
||||||
// nil = the endpoint 204s every login (ships off).
|
// nil = the endpoint 204s every login (ships off).
|
||||||
if len(cfg.AuthSources) > 0 {
|
if len(cfg.AuthSources) > 0 {
|
||||||
sources := make([]api.AuthSource, 0, len(cfg.AuthSources)+1)
|
a.AuthSources = authSourcesFromConfig(cfg.AuthSources)
|
||||||
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
|
|
||||||
fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources))
|
fmt.Fprintf(stderr, "felis api: hasJoined multiplexer active — Mojang + %d third-party source(s)\n", len(cfg.AuthSources))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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://<this-host>: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
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ Commands:
|
|||||||
migrate up Apply embedded database migrations under an advisory lock
|
migrate up Apply embedded database migrations under an advisory lock
|
||||||
operator Run the MinecraftServer controller-manager
|
operator Run the MinecraftServer controller-manager
|
||||||
api Run the felis-api HTTP server
|
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
|
reaper Run the world reaper / backup batch
|
||||||
restore Extract a world archive into a world volume (internal Job entrypoint)
|
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)
|
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)
|
return cmdOperator(rest, stdout, stderr)
|
||||||
case "api":
|
case "api":
|
||||||
return cmdAPI(rest, stdout, stderr)
|
return cmdAPI(rest, stdout, stderr)
|
||||||
|
case "nano":
|
||||||
|
return cmdNano(rest, stdout, stderr)
|
||||||
case "reaper":
|
case "reaper":
|
||||||
return cmdReaper(rest, stdout, stderr)
|
return cmdReaper(rest, stdout, stderr)
|
||||||
case "restore":
|
case "restore":
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ type sessionProfile struct {
|
|||||||
Properties []json.RawMessage `json:"properties,omitempty"`
|
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
|
// 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
|
// 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
|
// 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"
|
defaultUserUploadsContext = "s3://felis-user-uploads"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads and validates a felis.toml from path.
|
// decodeConfig reads a felis.toml and rejects unknown keys (typos surface as errors
|
||||||
func Load(path string) (*Config, error) {
|
// 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
|
var cfg Config
|
||||||
md, err := toml.DecodeFile(path, &cfg)
|
md, err := toml.DecodeFile(path, &cfg)
|
||||||
if err != nil {
|
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 {
|
if undecoded := md.Undecoded(); len(undecoded) > 0 {
|
||||||
// Surface typos rather than silently ignoring unknown keys.
|
|
||||||
keys := make([]string, len(undecoded))
|
keys := make([]string, len(undecoded))
|
||||||
for i, k := range undecoded {
|
for i, k := range undecoded {
|
||||||
keys[i] = k.String()
|
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()
|
cfg.applyDefaults()
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
@@ -198,6 +209,27 @@ func Load(path string) (*Config, error) {
|
|||||||
return &cfg, nil
|
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() {
|
func (c *Config) applyDefaults() {
|
||||||
if c.Server.Listen == "" {
|
if c.Server.Listen == "" {
|
||||||
c.Server.Listen = defaultListen
|
c.Server.Listen = defaultListen
|
||||||
@@ -251,11 +283,17 @@ func (c *Config) Validate() error {
|
|||||||
if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") {
|
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)
|
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
|
return c.validateAuthSources()
|
||||||
// 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
|
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag and a
|
||||||
// silently dead (never validates any login). Both fail fast at load, not per-login.
|
// 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))
|
seenTags := make(map[string]struct{}, len(c.AuthSources))
|
||||||
for i, s := range c.AuthSources {
|
for i, s := range c.AuthSources {
|
||||||
if s.Tag == "" {
|
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) {
|
func TestLoadRejectsUnknownKeys(t *testing.T) {
|
||||||
_, err := config.Load(writeTOML(t, `
|
_, err := config.Load(writeTOML(t, `
|
||||||
[server]
|
[server]
|
||||||
|
|||||||
Reference in new issue
Block a user