Files
Felis/internal/config/config.go
T
Lemon-miaow 61b283ae2f feat(auth): add Owner authentication source settings
Manage Yggdrasil providers from the panel using durable platform settings, protected identity namespaces and atomic revisions. Apply changes to subsequent logins and profile lookups without restarting. Return operator-host logouts to the login method selection page.
2026-10-04 22:18:37 +08:00

777 lines
36 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"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// scanIDPattern is build.scanIDPattern: the shape of a finding id scan-gate
// takes in its comma-separated --accept flag.
var scanIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
// dnsLabel is a Kubernetes namespace or object name (RFC 1123 label).
var dnsLabel = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`)
// 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"`
Offsite OffsiteConfig `toml:"offsite"`
SMTP SMTPConfig `toml:"smtp"`
Audit AuditConfig `toml:"audit"`
// 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 = Mojang is the only source. 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).
// It is permanent: every player UUID of the source is hashed from it byte for byte, so
// changing it, even its case, gives all of them new UUIDs and orphans their playerdata,
// account links and bans. 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" json:"tag"`
Prefix string `toml:"prefix" json:"prefix"`
URL string `toml:"url" json:"url"`
// APIURL is optional for sources whose hasJoined URL does not use the standard path.
APIURL string `toml:"api_url" json:"api_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": every door that mails a code
// answers 503 mail_unavailable and sign-in is by passkey only. 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"`
// MaxPerHour caps the mail the API sends install-wide (codes and notices),
// so a flood cannot spend the relay's quota and get the account suspended.
// 0 means DefaultMailPerHour. Size it to the relay's own limit.
MaxPerHour int `toml:"max_per_hour"`
// RequireTLS refuses to send through a relay on a port other than 465 that
// does not offer STARTTLS. Unset, it is on for every relay except one on
// this host (see TLSRequired). A code sent in the clear can be read by
// anyone on the path, and a relay's STARTTLS offer can be stripped by
// anyone who can rewrite the conversation.
RequireTLS *bool `toml:"require_tls,omitempty"`
}
// TLSRequired reports whether mail may go to this relay only over TLS: the
// explicit require_tls when set, otherwise true unless the relay is this
// host (localhost or a loopback address), where the path never leaves the
// machine.
func (c SMTPConfig) TLSRequired() bool {
if c.RequireTLS != nil {
return *c.RequireTLS
}
if strings.EqualFold(c.Host, "localhost") {
return false
}
ip := net.ParseIP(c.Host)
return ip == nil || !ip.IsLoopback()
}
// DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is
// unset: far above a community's normal sign-in mail, far below the daily
// quota of common relays.
const DefaultMailPerHour = 120
// 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"`
// Deployment is the k3s Deployment the database runs as, "namespace/name"
// ("felis/felis-postgres"), written into the host copy only. The host
// carries no PostgreSQL client, so `felis db backup`/`restore` and the
// pre-migration snapshot run pg_dump, pg_restore and psql inside its
// postgres container. Empty runs the tools on PATH against url.
Deployment string `toml:"deployment"`
}
// 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"`
// GamePort is the public TCP port the proxy accepts players on (bootstrap's
// FELIS_GAME_PORT). The panel adds it to the server addresses players copy
// when it is not Minecraft's default; 0 means that default, 25565.
GamePort int `toml:"game_port"`
// GameVersion is the login/lobby protocol built by bootstrap. Empty means
// unknown for custom images; the panel must not guess a client version.
GameVersion string `toml:"game_version"`
}
// AuthConfig is the [auth] table: the two privileged faces and the Cloudflare
// Access application's audience. The API does not verify Access JWTs (Access is
// enforced at the edge); a set audience marks the install as sitting behind
// Cloudflare, which makes CF-Connecting-IP the client address.
type AuthConfig struct {
AdminHostname string `toml:"admin_hostname"`
PanelHostname string `toml:"panel_hostname"`
AccessJWTAud string `toml:"access_jwt_aud"`
// ClientIPHeader names the header the edge writes the visitor's address
// into: CF-Connecting-IP behind the Cloudflare tunnel (the edge setup
// writes it), X-Forwarded-For behind an operator's reverse proxy. The API
// keys its per-client sign-in rate limit on it. Empty means the TCP peer,
// except that an install with an Access audience (set only by the
// Cloudflare edge setup) implies CF-Connecting-IP.
ClientIPHeader string `toml:"client_ip_header"`
}
// EffectiveClientIPHeader resolves ClientIPHeader with its Cloudflare default.
func (a AuthConfig) EffectiveClientIPHeader() string {
if a.ClientIPHeader != "" {
return a.ClientIPHeader
}
if a.AccessJWTAud != "" {
return "CF-Connecting-IP"
}
return ""
}
// 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"`
// KanikoImage / TrivyImage / BuildCPULimit / BuildMemLimit override the
// build subsystem's defaults: the kaniko and trivy copies the installer keeps
// in this registry under mirror/ (build.Tools), 2 CPU / 4Gi per build
// container. Set the images only to run another build of the tools
// (docs/troubleshooting.md §8e). Empty keeps the default.
KanikoImage string `toml:"kaniko_image"`
TrivyImage string `toml:"trivy_image"`
BuildCPULimit string `toml:"build_cpu_limit"`
BuildMemLimit string `toml:"build_mem_limit"`
// BuildDiskLimit caps a build pod's ephemeral storage (context, unpacked base
// image and image tarball together). Empty keeps 12Gi.
BuildDiskLimit string `toml:"build_disk_limit"`
// BuildUserNamespaces runs build pods in a user namespace (hostUsers: false):
// "auto" (the default) turns it on when felis-api's startup probe pod ran
// with it, "on" always, "off" never.
BuildUserNamespaces string `toml:"build_user_namespaces"`
// BuildRuntimeClass runs build pods under a sandbox RuntimeClass such as
// gVisor or Kata. Empty runs them under the node's default runtime.
BuildRuntimeClass string `toml:"build_runtime_class"`
// MaxConcurrentBuilds caps how many builds run at once; later ones queue.
// Zero keeps 2; at most 6 (the build namespace's pod quota).
MaxConcurrentBuilds int `toml:"max_concurrent_builds"`
// TrivyDBRepository points Trivy at an OCI repository holding the
// vulnerability DB (--db-repository). Trivy's own default fetches from
// mirror.gcr.io/ghcr.io, which the build egress lock denies, so the default
// here is the copy felis-build-tools.timer refreshes in this registry,
// <url>/mirror/trivy-db:2 (build.Tools). The scan runs with --insecure, so
// the plain-HTTP internal registry works. Empty keeps that default.
TrivyDBRepository string `toml:"trivy_db_repository"`
// TrivyJavaDBRepository points Trivy at an OCI repository holding the Java
// DB (--java-db-repository). Trivy fetches it lazily whenever the scanned
// image contains Java artifacts — every real modpack image does — so on an
// egress-locked box it comes from this registry exactly like the
// vulnerability DB: <url>/mirror/trivy-java-db:1 by default. Empty keeps that
// default.
TrivyJavaDBRepository string `toml:"trivy_java_db_repository"`
// ScanFailOn lists the severities that block a built image (CRITICAL, HIGH,
// MEDIUM, LOW, UNKNOWN). Empty keeps CRITICAL (build.DefaultScanFailOn).
ScanFailOn []string `toml:"scan_fail_on"`
// ScanFailUnfixed blocks on vulnerabilities that have no fixed release too.
// Off by default: the submitter cannot upgrade past them, and the build's
// scan report still lists them.
ScanFailUnfixed bool `toml:"scan_fail_unfixed"`
// ScanAccept lists vulnerability ids and secret rule ids accepted as known
// risks (build.ScanPolicy.Accept): still listed in the scan, never blocking.
ScanAccept []string `toml:"scan_accept"`
// 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), and so does the read end: the build Pod's fetch initContainer
// streams the blob back over the API's internal face, so this value just names
// where the API stores it, not where Kaniko must reach. 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"`
// UserUploadsMaxBytes caps what every user's uploaded contexts may occupy
// together, as a quantity ("4Gi"). Each user also has a 2 GiB budget of their
// own; this bounds the sum, which on k3s local-path is the only bound, since
// the uploads PVC's size is not enforced there. Empty keeps 4Gi.
UserUploadsMaxBytes string `toml:"user_uploads_max_bytes"`
// ContextMaxBytes caps one uploaded build context, as a quantity ("512Mi").
// Empty keeps 1Gi. The Cloudflare edge refuses a single request body over
// 100 MB; the panel sends a context in 32 MiB parts
// (/api/v1/me/submissions/{id}/context/upload), so the cap holds behind it.
ContextMaxBytes string `toml:"context_max_bytes"`
// 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).
// The manual_* keys bound the owners' on-demand backups, which share the
// archive store with the reaper's: how long each is kept (default 30d), how
// many per server (default 5, the oldest go first), and how soon an owner may
// ask for the next one (default 10m). The scheduled_* keys shape the daily
// restore points felis-api takes of played worlds: how far apart (default 1d,
// "0s" turns them off), how many per server (default 7) and how long each is
// kept (default 90d). Empty or zero means the default.
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"`
ManualRetention string `toml:"manual_retention"`
ManualKeep int `toml:"manual_keep"`
ManualCooldown string `toml:"manual_cooldown"`
ScheduledEvery string `toml:"scheduled_every"`
ScheduledKeep int `toml:"scheduled_keep"`
ScheduledRetention string `toml:"scheduled_retention"`
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"`
}
// AuditConfig is the [audit] table. Retention is how long felis-api keeps audit
// rows before deleting them ("365d", "18mo", or "forever" to keep every row);
// empty means DefaultAuditRetention. Export what must outlive it with
// `felis db audit-export` first.
type AuditConfig struct {
Retention string `toml:"retention"`
}
// DefaultAuditRetention keeps a year of audit rows; MinAuditRetention is the
// shortest an install may set, since the manual-backup cooldown and an incident
// investigation both read recent rows.
const (
DefaultAuditRetention = 365 * 24 * time.Hour
MinAuditRetention = 30 * 24 * time.Hour
)
// RetentionPeriod resolves Retention: 0 keeps every row.
func (a AuditConfig) RetentionPeriod() (time.Duration, error) {
switch v := strings.TrimSpace(a.Retention); v {
case "":
return DefaultAuditRetention, nil
case "forever":
return 0, nil
default:
d, err := ParseSpan(v)
if err != nil {
return 0, fmt.Errorf("config: [audit] retention %q must be a span such as 365d or 18mo, or forever", a.Retention)
}
if d < MinAuditRetention {
return 0, fmt.Errorf("config: [audit] retention %q is shorter than the 30d minimum", a.Retention)
}
return d, nil
}
}
// ParseSpan parses the human spans felis.toml uses for retention periods:
// "3mo" (months of 30 days), "15d" (days), or any time.ParseDuration unit ("12h").
func ParseSpan(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
switch {
case strings.HasSuffix(s, "mo"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "mo"))
if err != nil {
return 0, err
}
return time.Duration(n) * 30 * 24 * time.Hour, nil
case strings.HasSuffix(s, "d"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(n) * 24 * time.Hour, nil
default:
return time.ParseDuration(s)
}
}
// OffsiteConfig is the [offsite] table: the S3-compatible bucket, away from
// this machine, that `felis offsite sync` (felis-offsite.timer on the host)
// copies every world archive and the newest database bundles into, encrypted
// (internal/offsite). An empty bucket means no off-site copy: the archives and
// the database then share the node's disk with the worlds.
//
// When it is set the reaper deletes an idle world only once the archive it made
// has its off-site copy, so the reaper pod reads this table too. The secrets
// follow the credential rule of [archive.s3]: the *_ref fields NAME the
// environment variables holding them (bootstrap writes /etc/felis/offsite.env),
// and they are never written into felis.toml.
type OffsiteConfig struct {
// Endpoint is https://host[:port]; http:// only for a store on a trusted
// network. A bare host means TLS.
Endpoint string `toml:"endpoint"`
Region string `toml:"region"`
Bucket string `toml:"bucket"`
// Prefix places every object under this key prefix, so one bucket can hold
// several installs.
Prefix string `toml:"prefix"`
AccessKeyRef string `toml:"access_key_ref"`
SecretKeyRef string `toml:"secret_key_ref"`
// KeyRef names the variable holding the encryption key (`felis offsite
// keygen`). The copies are unreadable without it, so it must also be kept
// somewhere other than this machine.
KeyRef string `toml:"key_ref"`
// DBKeep is how many of the newest database bundles the bucket keeps.
DBKeep int `toml:"db_keep"`
}
// Enabled reports whether an off-site bucket is configured.
func (o OffsiteConfig) Enabled() bool { return o.Bucket != "" }
// Default environment variable names for the [offsite] secrets, and the bundle
// count kept off-site.
const (
DefaultOffsiteAccessKeyEnv = "FELIS_OFFSITE_ACCESS_KEY"
DefaultOffsiteSecretKeyEnv = "FELIS_OFFSITE_SECRET_KEY"
DefaultOffsiteKeyEnv = "FELIS_OFFSITE_KEY"
DefaultOffsiteDBKeep = 30
)
// 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. It is only a parseable prefix —
// an s3:// base with no credentials leaves the upload transport unwired, and
// the endpoint answers an honest 503 (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 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
}
if c.Offsite.Enabled() {
if c.Offsite.AccessKeyRef == "" {
c.Offsite.AccessKeyRef = DefaultOffsiteAccessKeyEnv
}
if c.Offsite.SecretKeyRef == "" {
c.Offsite.SecretKeyRef = DefaultOffsiteSecretKeyEnv
}
if c.Offsite.KeyRef == "" {
c.Offsite.KeyRef = DefaultOffsiteKeyEnv
}
if c.Offsite.DBKeep == 0 {
c.Offsite.DBKeep = DefaultOffsiteDBKeep
}
}
}
// 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 d := c.Database.Deployment; d != "" {
ns, name, ok := strings.Cut(d, "/")
if !ok || !dnsLabel.MatchString(ns) || !dnsLabel.MatchString(name) {
return fmt.Errorf("config: [database] deployment %q is not namespace/name", d)
}
}
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 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)
}
switch c.Registry.BuildUserNamespaces {
case "", "auto", "on", "off":
default:
return fmt.Errorf("config: [registry] build_user_namespaces %q must be auto, on or off", c.Registry.BuildUserNamespaces)
}
if p := c.Velocity.GamePort; p < 0 || p > 65535 {
return fmt.Errorf("config: [velocity] game_port %d must be 1-65535 (0 keeps 25565)", p)
}
if n := c.Registry.MaxConcurrentBuilds; n < 0 || n > 6 {
return fmt.Errorf("config: [registry] max_concurrent_builds %d must be 1-6 (0 keeps 2)", n)
}
for i, sev := range c.Registry.ScanFailOn {
sev = strings.ToUpper(strings.TrimSpace(sev))
c.Registry.ScanFailOn[i] = sev
switch sev {
case "CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN":
default:
return fmt.Errorf("config: [registry] scan_fail_on %q must be one of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN", sev)
}
}
for i, id := range c.Registry.ScanAccept {
id = strings.TrimSpace(id)
c.Registry.ScanAccept[i] = id
if !scanIDPattern.MatchString(id) {
return fmt.Errorf("config: [registry] scan_accept %q must be a vulnerability id or secret rule id (letters, digits, and . _ : -)", id)
}
}
// [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)
}
}
if c.SMTP.MaxPerHour < 0 {
return fmt.Errorf("config: [smtp] max_per_hour %d must be positive (0 means the default %d)", c.SMTP.MaxPerHour, DefaultMailPerHour)
}
if err := c.Offsite.validate(); err != nil {
return err
}
if _, err := c.Audit.RetentionPeriod(); err != nil {
return err
}
if h := c.Auth.ClientIPHeader; strings.ContainsAny(h, " :\t\r\n") {
return fmt.Errorf("config: [auth] client_ip_header %q must be a bare header name such as CF-Connecting-IP or X-Forwarded-For", h)
}
return c.validateAuthSources()
}
// validate checks a configured [offsite] table. Nothing is required of an
// unconfigured one; a half-filled one (an endpoint and no bucket) is refused,
// since it reads as configured while nothing is copied anywhere.
func (o OffsiteConfig) validate() error {
if !o.Enabled() {
if o.Endpoint != "" || o.Prefix != "" {
return fmt.Errorf("config: [offsite] names an endpoint or prefix but no bucket; set bucket, or remove the table")
}
return nil
}
if strings.TrimSpace(o.Endpoint) == "" {
return fmt.Errorf("config: [offsite] endpoint is required with bucket %q (e.g. https://s3.eu-central-1.amazonaws.com)", o.Bucket)
}
if u, err := url.Parse(o.Endpoint); strings.Contains(o.Endpoint, "://") && (err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || strings.Trim(u.Path, "/") != "") {
return fmt.Errorf("config: [offsite] endpoint %q must be http(s)://host[:port] with no path; the bucket goes in bucket", o.Endpoint)
}
if strings.ContainsAny(o.Bucket, "/ ") {
return fmt.Errorf("config: [offsite] bucket %q must be a bare bucket name; put a key prefix in prefix", o.Bucket)
}
if strings.Contains(o.Prefix, "..") {
return fmt.Errorf("config: [offsite] prefix %q must not contain \"..\"", o.Prefix)
}
if o.DBKeep < 1 {
return fmt.Errorf("config: [offsite] db_keep %d must be at least 1", o.DBKeep)
}
return nil
}
// 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 applies the same identity and endpoint rules to panel and
// TOML configuration. Mojang remains the code-owned first source.
func ValidateAuthSources(sources []AuthSourceConfig) error {
return (&Config{AuthSources: sources}).validateAuthSources()
}
// 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, duplicate or colon-bearing 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 URL
// the resolver cannot query leaves the source 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)
}
// A player's UUID is derived from tag+":"+nativeID, and the native id is whatever the
// source says it is. With a ':' allowed in tags, "guild" answering id "eu:X" hashes
// exactly like "guild:eu" answering "X", so one source could mint another's players.
// Colon-free tags make the join unambiguous. The charset is otherwise left open, because
// renaming an existing tag would move every one of its players to a new UUID.
if strings.Contains(s.Tag, ":") {
return fmt.Errorf("config: [[auth_source]] tag %q contains ':'; the tag and a player's native id are joined with ':' to derive their UUID, so a ':' in a tag would let another source mint this source's players", s.Tag)
}
// Refused for the same permanence: a stray space is invisible in the file yet is a
// different namespace, and so a different UUID for every player of the source.
if strings.TrimSpace(s.Tag) != s.Tag {
return fmt.Errorf("config: [[auth_source]] tag %q has leading or trailing whitespace; the tag is hashed into every player UUID of the source, so an invisible edit to it would give all of them new ones", s.Tag)
}
// Mojang is the built-in first source. A listed "mojang" is never it: it is asked again,
// after Mojang, on every login that reaches it, and nano's startup list then reads as if
// Mojang had been pointed at that url.
if strings.EqualFold(s.Tag, "mojang") {
return fmt.Errorf("config: [[auth_source]] tag %q is reserved: Mojang is built in as the first source and must not be listed", s.Tag)
}
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 problem := hasJoinedURLProblem(s.URL); problem != "" {
return fmt.Errorf("config: [[auth_source]] %q url %q %s", s.Tag, s.URL, problem)
}
if s.APIURL != "" {
if problem := hasJoinedURLProblem(s.APIURL); problem != "" {
return fmt.Errorf("config: [[auth_source]] %q api_url %q %s", s.Tag, s.APIURL, problem)
}
}
}
return nil
}
// hasJoinedURLProblem says why u cannot be queried as a hasJoined endpoint, or "" if it
// can. The resolver appends "?username=…&serverId=…" to it as a string, so a query or
// fragment already in it swallows those parameters, and a URL the client cannot send only
// fails one login at a time, with the source looking like it knows nobody.
func hasJoinedURLProblem(u string) string {
if strings.TrimSpace(u) != u {
return "has leading or trailing whitespace"
}
p, err := url.Parse(u)
switch {
case err != nil:
return "does not parse: " + err.Error()
case p.Scheme != "http" && p.Scheme != "https":
return "must be a scheme-qualified http(s):// hasJoined endpoint"
case p.Host == "":
return "has no host"
case strings.ContainsAny(u, "?#"):
return "must not carry a query or fragment; the username and serverId parameters are appended to it"
case p.Scheme == "http" && !plaintextHostOK(p.Hostname()):
return "sends logins in plaintext to a public host, where anyone on the path can answer as any player of this source; use https://, or http:// only for localhost or a loopback or private IP address"
}
return ""
}
// plaintextHostOK is decided on the literal host because nothing is resolved at load time,
// so a LAN root named by hostname needs its IP address or https.
func plaintextHostOK(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}