Files
Felis/cmd/felis/nano.go
T
flyemoji 1d6c73007e fix(nano): drain in-flight logins on shutdown
The installer and the config template tell the operator to run
systemctl restart felis-nano after editing the source list. nano had no
signal handling, so SIGTERM killed it mid-request: a login waiting on an
upstream had its connection reset, and Velocity disconnected that
player with "authentication servers are down". felis api already drains
on shutdown; nano did not.

nano now listens itself, serves until SIGINT or SIGTERM, then shuts the
server down gracefully with a 30-second limit. That outlasts the source
scan of any realistic list, at five seconds per source, and stays well
inside systemd's default 90-second stop timeout.

The new test holds a request inside the handler, cancels the serve
context, and checks that serveNano is still running 200 ms later, that
the held request then gets its answer, and that serveNano returns 0.
Replacing the graceful shutdown with Close fails it.
2026-09-22 13:28:59 +09:00

132 lines
5.8 KiB
Go

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://127.0.0.1:8081/session/minecraft/hasJoined
// — Velocity's property takes the FULL endpoint URL, path included (its default is the
// full https://sessionserver.mojang.com/session/minecraft/hasJoined), and Velocity issues
// that request itself rather than through authlib. Then it verifies logins against Mojang
// plus every configured third-party source. Serving a proxy on another host means binding
// off-loopback with -listen; see the flag below for why that is an explicit opt-in.
//
// This is the no-database delivery of the identical brain `felis api` mounts through its
// route table (internal/api.HasJoinedHandler). The bootstrap installer's nano choice — its
// `[2] Felis-nano` prompt, or FELIS_INSTALL_MODE=nano — installs this as the
// felis-nano.service unit; here it just serves.
//
// There is deliberately no `felis setup --nano`. setup re-images the host through the full
// bootstrap TUI and carries no install-mode parameter anywhere (nothing in Go reads or sets
// FELIS_INSTALL_MODE), so such a flag would either re-run the installer — which is what
// pointing at the installer already does — or tear a full install down into a nano one,
// which is an uninstall, not a flag. This is the same reasoning that makes setup's --dev
// refuse and name the installer rather than pretend to choose a channel.
import (
"context"
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"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. 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 }
// nanoLogURIMax is room for a real hasJoined query (a 16-character name, a 41-character
// serverId, an address) several times over.
const nanoLogURIMax = 256
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]])")
// Loopback default: hasJoined carries no auth token (authlib speaks the vanilla
// sessionserver protocol), so a public bind is an open auth relay — anyone can point
// their proxy at it and spend this host's egress IP on Mojang. A same-host Velocity
// reaches 127.0.0.1; serving an off-host proxy is an explicit -listen opt-in.
listen := fs.String("listen", "127.0.0.1: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
}
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)
}
ln, err := net.Listen("tcp", *listen)
if err != nil {
fmt.Fprintln(stderr, "felis nano:", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return serveNano(ctx, newAPIServer(*listen, nanoHandler(cfg.AuthSources, stderr)), ln, stderr)
}
// nanoDrainTimeout outlasts the source scan of any realistic list (each source is given
// five seconds) and stays well inside systemd's default 90-second stop timeout.
const nanoDrainTimeout = 30 * time.Second
// serveNano serves until ctx ends, then drains. A restart, the documented way to pick up a
// config edit, sends SIGTERM; without the drain a login already waiting on an upstream has
// its connection reset, and Velocity tells that player the auth servers are down.
func serveNano(ctx context.Context, srv *http.Server, ln net.Listener, stderr io.Writer) int {
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
select {
case err := <-errc:
fmt.Fprintln(stderr, "felis nano:", err)
return 1
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), nanoDrainTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
fmt.Fprintln(stderr, "felis nano: shutdown:", err)
return 1
}
return 0
}
}
// nanoHandler is what felis nano serves: the shared hasJoined handler, Mojang first, behind
// a request log.
func nanoHandler(sources []config.AuthSourceConfig, stderr io.Writer) http.Handler {
handler := api.HasJoinedHandler(authSourcesFromConfig(sources), nanoStubRepo{})
// Log each request so a live login attempt is visible while testing against a real
// Velocity — "is Velocity even reaching me?" is the first question during verification.
// The URI is the caller's text: quoted so a control or bidi character cannot rewrite the
// line and invalid UTF-8 cannot turn the journal entry into a blob, and capped so one
// request cannot write a megabyte of log.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uri := r.RequestURI
if len(uri) > nanoLogURIMax {
uri = uri[:nanoLogURIMax] + "..."
}
fmt.Fprintf(stderr, "felis nano: %s %q\n", r.Method, uri)
handler.ServeHTTP(w, r)
})
}