Files
Felis/cmd/felis/nano.go
T
flyemoji fa3eda5228 fix(nano): quote and cap the request log line
felis nano logged every request with the raw RequestURI and %s. That
text is the caller's: a right-to-left override reordered the line as
displayed, an invalid UTF-8 byte made journald store the entry as a
binary blob that journalctl -f shows as "[N blob data]", and a query
near net/http's one-megabyte limit became a one-megabyte log line.

The URI is now capped at 256 bytes, several times a real hasJoined
query, and printed with %q, so control, bidi and invalid bytes appear
escaped. The handler assembly moved into nanoHandler so the logged
handler can be tested on its own; cmdNano serves it unchanged.

The new test sends a query carrying U+202E, a 0x9b byte and 4 KiB of
padding, and expects a valid UTF-8 line with the override escaped and
no more than twice the cap. Restoring the old unquoted line fails it.
2026-09-22 13:27:28 +09:00

100 lines
4.7 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/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. 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)
}
srv := newAPIServer(*listen, nanoHandler(cfg.AuthSources, stderr))
if err := srv.ListenAndServe(); err != nil {
fmt.Fprintln(stderr, "felis nano:", 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)
})
}