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.
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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":
|
||||
|
||||
Reference in new issue
Block a user