// 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"` Prefix string `toml:"prefix"` URL string `toml:"url"` // APIURL is optional for sources whose hasJoined URL does not use the standard path. APIURL string `toml:"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, // /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: /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 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()) }