Files
Felis/internal/config/config.go
T
flyemoji afdbfac7a8 docs: index the deferred integration seams and correct two stale markers
INTEGRATION-ONLY and KNOWN-LIMITATION are grep-able, but the grep answers the
wrong question. Thirty-four Go sites share the two markers and they carry four
different meanings: "declared, nothing implements it" reads exactly like
"implemented, only its I/O is unreachable from here", and neither reads
differently from a limitation that was accepted on purpose and is not coming
back. docs/deferred-seams.md sorts them, following the bucketed shape
internal/updater/doc.go already uses for its own package rather than starting a
second convention.

Sorting them turned up two markers that had outlived the condition they describe.

config.go called the modpack upload transport a deferred integration after both
backends had shipped -- LocalContextStore and S3ContextStore, selected in
cmd/felis by the shape of user_uploads_context, with the uploads PVC mounted and
the felis-uploads-s3 Secret rendered. What is still deferred is the far end:
Kaniko reading that context from inside the build Pod.

updater/doc.go listed the `felis update` CLI and the off-cluster Velocity jar read
under REMAINING INTEGRATION. Both exist -- cmd/felis/update.go, and
gatherer_host.go, which answers Velocity from the installed jar's manifest and
felis-api from the running binary's build stamp. The two nil seams that bullet
also names are real, but they belong to the in-cluster gatherer only, so the
bullet now says which caller has what and which is still empty.

The index also records the collision that makes a naive grep misleading:
docs/troubleshooting.md uses [INTEGRATION-ONLY] for something else, defined in its
own opening at :19 -- the symptom is produced by the kubelet, kaniko or a live
handshake, so it cannot be reproduced from the repository. Those twelve marks say
where a failure comes from, not that something is unbuilt, and are excluded.

Both code changes are comments. Every file:line the index cites was checked
against the line it points at.
2026-07-28 17:27:01 +09:00

377 lines
17 KiB
Go

// Package config loads and validates felis.toml (spec §24). root_domain lives
// here and nowhere else in code: every FQDN is composed at runtime as
// subdomain + "." + root_domain, so changing the deployment domain is a
// one-line config edit and the source tree stays domain-agnostic.
package config
import (
"fmt"
"regexp"
"strings"
"github.com/BurntSushi/toml"
)
// Config is the parsed felis.toml.
type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Velocity VelocityConfig `toml:"velocity"`
Auth AuthConfig `toml:"auth"`
K8s K8sConfig `toml:"k8s"`
Registry RegistryConfig `toml:"registry"`
Archive ArchiveConfig `toml:"archive"`
SMTP SMTPConfig `toml:"smtp"`
// AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil
// roots the Felis-nano hasJoined multiplexer federates over, in priority order
// (config order = priority, so array-of-tables not a map — a map would lose order
// and silently break Mojang-first). Empty = the multiplexer ships off. There is
// deliberately NO identity/trusted field here: Mojang is the single code-owned
// identity anchor (cmd/felis prepends it) and every configured source is
// namespace-rewritten, so no config can mint a source whose self-asserted UUIDs are
// trusted verbatim — the impersonation hole that rewrite closes cannot be reopened by
// misconfiguration. (An `identity =` key here is an unknown key → Load rejects it.)
AuthSources []AuthSourceConfig `toml:"auth_source"`
}
// AuthSourceConfig is one [[auth_source]] entry: a third-party Yggdrasil root the
// Felis-nano multiplexer federates over. Tag names the source's per-source UUID
// namespace (must be unique — two sources sharing a tag would collide onto one identity);
// URL is the full hasJoined endpoint (scheme-qualified) the query string is appended to.
// Prefix is what a player from this source is renamed with when their name belongs to a
// Mojang player (LS_steve) — player-visible, so it is written out rather than derived from
// the tag, which cannot know that "littleskin" is meant to read LS.
// No trusted/identity field, by design — see Config.AuthSources.
type AuthSourceConfig struct {
Tag string `toml:"tag"`
Prefix string `toml:"prefix"`
URL string `toml:"url"`
}
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
// email one-time codes through (onboarding, email login, op-login). It is
// OPTIONAL — an empty host means "no mailer", and felis-api falls back to
// logging each code server-side (the pre-SMTP bootstrap posture). Only the
// coordinates live here; the password follows the tree's credential rule
// (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment
// variable felis-api reads it from — the secret itself is never written into
// felis.toml. The setup wizard's "configure email" step creates the felis-smtp
// Secret the deployment injects that variable from.
type SMTPConfig struct {
Host string `toml:"host"`
// Port defaults to 587 (STARTTLS submission). 465 selects implicit TLS.
Port int `toml:"port"`
// From is the envelope/header sender address the codes are mailed as.
From string `toml:"from"`
// Username is the AUTH identity; empty means the relay needs no AUTH.
Username string `toml:"username"`
PasswordRef string `toml:"password_ref"`
}
// ServerConfig is the [server] table.
type ServerConfig struct {
Listen string `toml:"listen"`
RootDomain string `toml:"root_domain"`
}
// DatabaseConfig is the [database] table.
type DatabaseConfig struct {
URL string `toml:"url"`
}
// VelocityConfig is the [velocity] table.
type VelocityConfig struct {
PublicIP string `toml:"public_ip"`
ServiceTokenRef string `toml:"service_token_ref"`
// LoginImage is the container image for the always-on "login" limbo (the
// LOOHP/Limbo auth gate). setup provisions the login system service only when
// this is set; empty means "don't guess" — setup skips the login server and
// says so, the same fail-loud stance manifests takes for images it cannot
// safely default. No official LOOHP/Limbo image exists, so a deployment builds
// one (see deploy/limbo) and points this at the pushed ref.
LoginImage string `toml:"login_image"`
// LobbyImage is the container image for the always-on "lobby" hub (Paper plus
// the felis-paper /menu plugin). Same skip-when-empty contract as LoginImage.
LobbyImage string `toml:"lobby_image"`
}
// AuthConfig is the [auth] table: the two privileged faces and the access-JWT
// audience the API enforces.
type AuthConfig struct {
AdminHostname string `toml:"admin_hostname"`
PanelHostname string `toml:"panel_hostname"`
AccessJWTAud string `toml:"access_jwt_aud"`
}
// K8sConfig is the [k8s] table.
type K8sConfig struct {
Namespace string `toml:"namespace"`
EgressMode string `toml:"egress_mode"`
MetalLBPool string `toml:"metallb_pool"`
}
// RegistryConfig is the [registry] table.
type RegistryConfig struct {
URL string `toml:"url"`
BuildNamespace string `toml:"build_namespace"`
// UserUploadsContext is the object-store base under which a user-submitted
// modpack's Kaniko build context is pinned. It belongs to the §16 build
// subsystem's input domain (the build-context store), introduced by the
// user-directed modpack approval lane (see internal/submit package doc). The
// lane derives {UserUploadsContext}/{submissionID}/context.tar.gz; both transports
// that place the blob there now ship (submit.LocalContextStore for a local path,
// submit.S3ContextStore for an s3:// base, selected in cmd/felis by the shape of
// this value). What stays deferred is the far end — Kaniko reading that context
// from inside the build Pod (INTEGRATION-ONLY, see submit/blobstore.go). It is
// kept distinct from [archive] on purpose — a world
// archive (§19 WorldArchiver) and a build context (§16) are different artifacts
// with different lifecycles, so the two must not share a store binding.
UserUploadsContext string `toml:"user_uploads_context"`
// S3 configures the object-store backend for user_uploads_context when it is an
// s3:// base (the alternative to a local uploads path). It mirrors
// ArchiveS3Config: Endpoint + Region locate the store and the *Ref fields NAME
// the environment variables felis-api reads the credentials from — never the
// secrets themselves, so no S3 key is ever written into felis.toml. The setup
// wizard injects those env vars into felis-api from a separate Secret
// (felis-uploads-s3). The bucket (and any key prefix) is taken from
// user_uploads_context itself, so it is not duplicated here. Empty for a
// local-storage install.
S3 RegistryS3Config `toml:"s3"`
}
// RegistryS3Config is the [registry.s3] subtable: the object-store coordinates for
// a user_uploads_context that is an s3:// base. It deliberately reads like
// ArchiveS3Config (endpoint + credential refs) so the two S3 bindings are
// consistent, but omits Bucket because the s3:// base already carries it.
type RegistryS3Config struct {
Endpoint string `toml:"endpoint"`
Region string `toml:"region"`
AccessKeyRef string `toml:"access_key_ref"`
SecretKeyRef string `toml:"secret_key_ref"`
}
// ArchiveConfig is the [archive] table plus its [archive.s3] subtable (spec §19).
type ArchiveConfig struct {
Store string `toml:"store"`
LocalPath string `toml:"local_path"`
Retention string `toml:"retention"`
WarnBefore []string `toml:"warn_before"`
MaxLocalBytes string `toml:"max_local_bytes"`
S3 ArchiveS3Config `toml:"s3"`
}
// ArchiveS3Config is the [archive.s3] subtable.
type ArchiveS3Config struct {
Endpoint string `toml:"endpoint"`
Bucket string `toml:"bucket"`
AccessKeyRef string `toml:"access_key_ref"`
SecretKeyRef string `toml:"secret_key_ref"`
}
// archive store backends recognized by §19.
var archiveStores = map[string]struct{}{
"tarLocal": {},
"tarS3": {},
"volumeSnapshot": {},
"longhorn": {},
}
// archive store backends this build can actually honor. §19 names four, but only
// tarLocal is implemented: the reaper's buildArchiver, the `felis restore`
// command, and the felis-api restore executor all construct tarLocal and nothing
// else. A config naming a recognized-but-unimplemented store is a footgun — it
// clears the "is this a real store name" check yet silently breaks retention (the
// reaper CronJob fails every run) and restore (503), while felis-api otherwise
// looks healthy. Validate rejects it so every binary that loads config (migrate,
// api, reaper) fails fast at startup with a clear remediation instead. (`felis
// restore` enforces the same invariant on its own --store flag: it runs inside
// the sandboxed weak-SA restore Job and by design never loads felis.toml or holds
// DB credentials, so it cannot lean on this load-time check.)
var implementedArchiveStores = map[string]struct{}{
"tarLocal": {},
}
// Defaults that callers get when the field is omitted.
const (
defaultListen = "0.0.0.0:8080"
defaultNamespace = "minecraft"
defaultEgressMode = "loadbalancer"
defaultStore = "tarLocal"
// defaultUserUploadsContext is a non-empty, platform-namespaced placeholder so
// the modpack approval lane's derived context ref is well-formed even before a
// deployment points it at a real object store. The blob transport is deferred,
// so this base only has to be a sensible, parseable prefix (see the §16 build
// subsystem and the internal/submit package doc for the lane's provenance).
defaultUserUploadsContext = "s3://felis-user-uploads"
// defaultSMTPPort is the STARTTLS submission port; applied only when [smtp]
// host is set (a portless [smtp] block with no host stays fully zero).
defaultSMTPPort = 587
)
// 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 cfg, fmt.Errorf("config: decode %s: %w", path, err)
}
if undecoded := md.Undecoded(); len(undecoded) > 0 {
keys := make([]string, len(undecoded))
for i, k := range undecoded {
keys[i] = k.String()
}
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 {
return nil, err
}
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
}
if c.K8s.Namespace == "" {
c.K8s.Namespace = defaultNamespace
}
if c.K8s.EgressMode == "" {
c.K8s.EgressMode = defaultEgressMode
}
if c.Archive.Store == "" {
c.Archive.Store = defaultStore
}
if c.Registry.UserUploadsContext == "" {
c.Registry.UserUploadsContext = defaultUserUploadsContext
}
if c.SMTP.Host != "" && c.SMTP.Port == 0 {
c.SMTP.Port = defaultSMTPPort
}
}
// Validate enforces the mandatory fields (spec §24: database.url is 强制) and
// the closed value sets.
func (c *Config) Validate() error {
if c.Database.URL == "" {
return fmt.Errorf("config: [database] url is required")
}
if c.Server.RootDomain == "" {
return fmt.Errorf("config: [server] root_domain is required")
}
if !strings.Contains(c.Server.RootDomain, ".") {
return fmt.Errorf("config: [server] root_domain %q is not a domain", c.Server.RootDomain)
}
if _, ok := archiveStores[c.Archive.Store]; !ok {
return fmt.Errorf("config: [archive] store %q is not one of tarLocal|tarS3|volumeSnapshot|longhorn", c.Archive.Store)
}
if _, ok := implementedArchiveStores[c.Archive.Store]; !ok {
return fmt.Errorf("config: [archive] store %q is recognized by §19 but not implemented in this build — only tarLocal is supported; set store = \"tarLocal\"", c.Archive.Store)
}
switch c.K8s.EgressMode {
case "loadbalancer", "nodeport":
default:
return fmt.Errorf("config: [k8s] egress_mode %q must be loadbalancer or nodeport", c.K8s.EgressMode)
}
// The registry URL is a bare host[:port] (spec §24: url="registry.felis.svc:5000"),
// never a scheme-qualified URL. This is not cosmetic: two consumers read it with
// different robustness. The admin build path normalizes via registryHost() (which
// strips a scheme), but the user-modpack approval lane derives its push target by
// string concatenation (submit.Manager.deriveImageRef → "{url}/user-uploads/{id}:latest")
// with no stripping. A "http://" prefix would make the lane's pre-CAS build.Validate
// reject every derived ref (imageNameRE forbids the leading "http:/…") and collapse
// EVERY approve to 500 while admin builds keep working — a silent split-brain. Fail
// fast at load instead, with the contract spelled out.
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)
}
// [smtp] is optional as a whole, but once a host is named the block must be
// deliverable: a From address (relays reject MAIL FROM:<>) and a sane port.
// Fail at load, not at the first OTP a player is waiting on.
if c.SMTP.Host != "" {
if !strings.Contains(c.SMTP.From, "@") {
return fmt.Errorf("config: [smtp] from %q must be the sender email address codes are mailed as", c.SMTP.From)
}
if c.SMTP.Port < 1 || c.SMTP.Port > 65535 {
return fmt.Errorf("config: [smtp] port %d must be 1-65535 (587 STARTTLS, 465 implicit TLS)", c.SMTP.Port)
}
}
return c.validateAuthSources()
}
// authSourcePrefixRe is the shape of a prefix. It is prepended to a real Minecraft
// username (LS_steve), so it is confined to the username charset and kept short enough to
// leave a legible name behind after truncation.
var authSourcePrefixRe = regexp.MustCompile(`^[A-Za-z0-9]{1,4}$`)
// validateAuthSources checks the [[auth_source]] block: each needs a namespace tag, a rename
// prefix, and a scheme-qualified hasJoined URL, and both tag and prefix 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 duplicate
// prefix collapses two same-named players from different sources onto one in-game name
// (they stay distinct identities, but neither can be online while the other is); a
// scheme-less URL makes http.NewRequest fail so the source is silently dead (never validates
// any login). All 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))
seenPrefixes := make(map[string]struct{}, len(c.AuthSources))
for i, s := range c.AuthSources {
if s.Tag == "" {
return fmt.Errorf("config: [[auth_source]] #%d has an empty tag; each source's tag is its per-source UUID namespace", i+1)
}
if _, dup := seenTags[s.Tag]; dup {
return fmt.Errorf("config: [[auth_source]] tag %q is used twice — tags are per-source UUID namespaces and must be unique", s.Tag)
}
seenTags[s.Tag] = struct{}{}
if !authSourcePrefixRe.MatchString(s.Prefix) {
return fmt.Errorf(`config: [[auth_source]] %q needs prefix = "XX" (1-4 letters or digits, e.g. "LS" for LittleSkin), got %q; a player of this source whose name belongs to a Mojang account is renamed XX_name so the two can be online at once`, s.Tag, s.Prefix)
}
// Case-insensitively — the proxy's player registry folds case, so LS and ls would
// collide there even though they read as two different prefixes here.
lower := strings.ToLower(s.Prefix)
if _, dup := seenPrefixes[lower]; dup {
return fmt.Errorf("config: [[auth_source]] prefix %q is used twice — two sources sharing a prefix rewrite their same-named players onto the same in-game name", s.Prefix)
}
seenPrefixes[lower] = struct{}{}
if !strings.HasPrefix(s.URL, "http://") && !strings.HasPrefix(s.URL, "https://") {
return fmt.Errorf("config: [[auth_source]] %q url %q must be a scheme-qualified http(s):// hasJoined endpoint", s.Tag, s.URL)
}
}
return nil
}