Unverified Commit d177428f authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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".
parent cc0c9458
Loading
Loading
Loading
Loading
+15 −6
Changes for cmd/felis/api.go: 15 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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))
	}

cmd/felis/nano.go

0 → 100644
+68 −0
Changes for cmd/felis/nano.go: 68 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
}
+3 −0
Changes for cmd/felis/run.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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":
+13 −0
Changes for internal/api/handlers_hasjoined.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/config/config.go: 48 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -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 == "" {
Loading