// 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"` // 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"` } // 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; the upload // transport that places the blob there is a separate, deferred integration // (INTEGRATION-ONLY). 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" ) // 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 } } // 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) } 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 }