// Package module defines the on-disk manifest format (module.yaml) that // declares a game plugin: how to install it, how to run it, what ports it // exposes, what RCON dialect it speaks, and what patterns identify state // and player events in its logs. // // Both Controller and Target load manifests from a local modules/ directory. // The Controller owns authoring/UI form generation; the Target uses the // manifest at runtime to shape container launch and state-tracking pipelines. package module import ( "fmt" "time" "gopkg.in/yaml.v3" ) // Manifest is the root structure parsed from a module.yaml file. type Manifest struct { ID string `yaml:"id"` Name string `yaml:"name"` Version string `yaml:"version"` Authors []string `yaml:"authors,omitempty"` SupportedModes []string `yaml:"supported_modes"` Runtime Runtime `yaml:"runtime"` Ports []Port `yaml:"ports"` Resources Resources `yaml:"resources,omitempty"` ConfigFiles []ConfigFile `yaml:"config_files,omitempty"` RCON *RCONConfig `yaml:"rcon,omitempty"` StateSources []StateSource `yaml:"state_sources,omitempty"` Events map[string]Event `yaml:"events,omitempty"` UpdateProviders []UpdateProvider `yaml:"update_providers"` Secrets []Secret `yaml:"secrets,omitempty"` Lifecycle *Lifecycle `yaml:"lifecycle,omitempty"` // AutoUpdateOnCreate — run the first update_provider automatically // after a successful create, so the operator doesn't have to click // Update as a separate step. Default is `nil` which the loader // interprets as true for modules that declare a real provider. Set // explicitly to `false` for modules whose image handles its own // downloading at runtime (e.g. the ark-sa community image). AutoUpdateOnCreate *bool `yaml:"auto_update_on_create,omitempty"` // AutoStartOnCreate — after a successful create (and first-time update, // if one ran), boot the server automatically. Default nil = true for any // module. Opt out with `auto_start_on_create: false` for modules whose // first boot needs operator attention (e.g. filling in secrets first). // Independent of AutoUpdateOnCreate: images like ark-sa/ASA skip the // external updater but still need to auto-start so their in-container // entrypoint can run the bundled SteamCMD + launch the game in one go. AutoStartOnCreate *bool `yaml:"auto_start_on_create,omitempty"` // Backup declares which paths the panel should archive when an // operator clicks Back-up-now. Without this the agent tars the // whole BrowseableRoot — fine for tiny games, catastrophic for ARK // SA where that's a 18 GB SteamCMD install plus 200 MB of saves. // Paths are container-relative (resolved against BrowseableRoot) // and use shell-style globs. Saves-only is the goal. Backup *BackupSpec `yaml:"backup,omitempty"` // ConfigValues is the on-disk *declaration* of operator-tunable knobs. // The dashboard hardcodes its config-form schemas separately (see // static/index.html `configSchemas`), so this field is not consumed by // the loader yet — it's accepted only to keep the strict YAML decoder // happy when modules document their tunables in the manifest. Future // work: drive the dashboard form generator off this field directly so // the schema lives in one place. ConfigValues []map[string]any `yaml:"config_values,omitempty"` // ReadyPattern is the log regex that marks the server as actually // joinable (world loaded, accepting connections) — CONTROLLER/UI // metadata only; the agent never compiles it. The string is a // JavaScript regex: either a bare source ("Done \\(...\\)! For help") // or slash-delimited with flags ("/StartGame done/i"). It may use JS // constructs Go's regexp lacks (lookahead) — do not compile in Go. // Source of truth moved here from the dashboard's READY_PATTERNS map. ReadyPattern string `yaml:"ready_pattern,omitempty"` // Appearance carries the dashboard's visual identity for this module // (emoji, tile gradient, packaged art, Steam appid, optional display // name/tagline/accent). CONTROLLER/UI metadata only. Source of truth // moved here from the dashboard's moduleAppearance map. Appearance *Appearance `yaml:"appearance,omitempty"` // Path is the absolute path to module.yaml, set by the loader. Path string `yaml:"-"` // Dir is the directory containing the manifest; template/schema paths // in this manifest are resolved relative to Dir. Dir string `yaml:"-"` } // Runtime declares how the module launches under each supported mode. type Runtime struct { Docker *DockerSpec `yaml:"docker,omitempty"` Host *HostSpec `yaml:"host,omitempty"` } // DockerSpec is the Docker-mode launch recipe. type DockerSpec struct { Image string `yaml:"image"` Volumes []Volume `yaml:"volumes,omitempty"` Entrypoint []string `yaml:"entrypoint,omitempty"` Command []string `yaml:"command,omitempty"` Env map[string]string `yaml:"env,omitempty"` User string `yaml:"user,omitempty"` // BrowseableRoot is the container-absolute path the file manager // treats as "root" when operating on this container (e.g. "/data"). // If empty, the resolver defaults to the first volume's container // path. If neither is set, the file manager falls back to the // host-side data_path bind-mount root. BrowseableRoot string `yaml:"browseable_root,omitempty"` // BrowseableRoots lets a module expose multiple independent "views" // to the file manager — e.g. 7DTD's /game-saves (worlds, configs) // AND /game (mods, plugins, binaries). Each entry becomes a picker // option in the Files tab. First entry is the default view. // Empty → fall back to BrowseableRoot / first volume. BrowseableRoots []BrowseableRoot `yaml:"browseable_roots,omitempty"` // NetworkMode is the Docker network mode. Empty → default bridge with // published ports. Use "host" for modules that rely on long-lived UDP // sessions to external services (e.g. Windrose's P2P coop gateway, where // Docker Desktop's UDP NAT rebinds break TURN consent checks). In host // mode the Ports block is informational only — the container listens on // the host's interfaces directly; the default bind-mount NAT is skipped. NetworkMode string `yaml:"network_mode,omitempty"` } // BrowseableRoot names one container-absolute directory for the file // manager to surface in its roots picker. JSON tags match what the // dashboard's Files-tab picker consumes via GET /api/modules. type BrowseableRoot struct { Name string `yaml:"name" json:"name"` // human label — shown in the UI Path string `yaml:"path" json:"path"` // container-absolute path Hint string `yaml:"hint,omitempty" json:"hint,omitempty"` // optional sub-line shown in the picker } // Appearance is UI-only visual metadata for a module. Every field is // optional; the dashboard falls back to its built-in defaults for any // missing field. JSON keys mirror the dashboard's moduleAppearance map // entries (emoji/grad/art/steam) so the payload drops in unchanged. type Appearance struct { // DisplayName overrides Manifest.Name in UI card headers when set. DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` // Tagline is an optional one-line blurb shown under the name. Tagline string `yaml:"tagline,omitempty" json:"tagline,omitempty"` // Accent is an optional CSS color for module-tinted UI chrome. Accent string `yaml:"accent,omitempty" json:"accent,omitempty"` // Emoji shown when no art is available. Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"` // Grad is the CSS gradient behind the tile / while art loads. Grad string `yaml:"grad,omitempty" json:"grad,omitempty"` // Art is the panel-hosted art path, e.g. "/game-art/7dtd.jpg". Art string `yaml:"art,omitempty" json:"art,omitempty"` // Steam is the Steam appid used for CDN header-art fallback. Steam string `yaml:"steam,omitempty" json:"steam,omitempty"` } // HostSpec is the host-mode launch recipe. type HostSpec struct { ExecutableLinux string `yaml:"executable_linux,omitempty"` ExecutableWindows string `yaml:"executable_windows,omitempty"` Args []string `yaml:"args,omitempty"` Env map[string]string `yaml:"env,omitempty"` WorkingDir string `yaml:"working_dir,omitempty"` } // Volume declares a mount point on the instance's container. // // Two kinds are supported: // // type: bind — Target is the host path (may contain $DATA_PATH). // Useful for configs + logs operators want to read/edit. // type: volume — Name is a Docker named volume (may contain $INSTANCE_ID). // Lives inside the WSL2/container-storage filesystem; // dramatically faster than Windows bind-mounts for // high-I/O data like game installs. // // The agent's docker runtime auto-creates named volumes on first use. type Volume struct { Type string `yaml:"type,omitempty"` // "bind" (default if Target set) or "volume" Target string `yaml:"target,omitempty"` // host path for bind (may contain $DATA_PATH) Name string `yaml:"name,omitempty"` // volume name for named volumes (may contain $INSTANCE_ID) Container string `yaml:"container"` ReadOnly bool `yaml:"read_only,omitempty"` } // Port is a declared network port. type Port struct { Name string `yaml:"name" json:"name"` Proto string `yaml:"proto" json:"proto"` // "tcp" or "udp" Default uint32 `yaml:"default" json:"default"` // default port number Required bool `yaml:"required,omitempty" json:"required,omitempty"` Internal bool `yaml:"internal,omitempty" json:"internal,omitempty"` // if true, not exposed outside the Target // Env, when set, names the container env var the agent should // populate with this port's allocated HOST port at create time. // Lets the image's entrypoint translate panel-allocated ports // into game launch args (e.g. ARK's ASA_PORT/QUERY_PORT/RCON_PORT // → ?Port=N ?QueryPort=N ?RCONPort=N). Without this, two ARK // instances all advertise the manifest defaults to Steam and only // one of them is reachable via the server browser. Env string `yaml:"env,omitempty" json:"env,omitempty"` } // Resources declares RAM / CPU guidance for the UI and default limits. type Resources struct { MinRAMMB uint32 `yaml:"min_ram_mb,omitempty"` RecommendedRAMMB uint32 `yaml:"recommended_ram_mb,omitempty"` MinCPUShares uint32 `yaml:"min_cpu_shares,omitempty"` } // BackupSpec narrows what gets included when a backup runs. // // Paths are interpreted relative to BrowseableRoot (the same path the // File Manager treats as "the instance root"). Both Include and Exclude // support tar-style patterns (e.g. "ShooterGame/Saved/SavedArks"). // // Without a BackupSpec, the agent falls back to "tar the entire // BrowseableRoot" — preserves backwards compat for modules that // haven't been profiled yet but is wasteful for any game with bulky // install dirs (ARK SA, Empyrion, Conan). Each module's manifest // should ship one of these as soon as someone has time to verify // which paths actually need saving. // // Notes label is shown in the UI alongside backups, e.g. "Saves + // configs (~250 MB typical)". type BackupSpec struct { // Root is the absolute in-container path to cd into before tarring. // Defaults to the runtime's BrowseableRoot when empty. Set this when // the module's save data lives in a different volume than the file // browser default (7DTD: file browser shows /game by default, but // saves live in /game-saves). Root string `yaml:"root,omitempty"` Include []string `yaml:"include,omitempty"` Exclude []string `yaml:"exclude,omitempty"` Notes string `yaml:"notes,omitempty"` } // ConfigFile declares a config file the panel manages for the instance. type ConfigFile struct { Path string `yaml:"path"` // relative to $DATA_PATH Format string `yaml:"format"` // xml, properties, yaml, ini, json, toml, kv Schema string `yaml:"schema,omitempty"` // relative path to JSON-schema file in the module dir Template string `yaml:"template,omitempty"` // TemplateByBranch overrides Template for instances installed from a // specific Steam branch (the normalized branch from resolveCreateBranch, // e.g. "" for public/2.6 default, "latest_experimental" / "v3.0" for // 3.0). Keyed by the normalized branch string; an exact match wins, // otherwise Template (the default) is used. This is how 7DTD selects // serverconfig.v3.xml.tmpl for a 3.0 server while ≤2.6 servers keep the // untouched serverconfig.xml.tmpl. Kept data-driven so adding a future // version is a manifest edit, not a code change. See // panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D). TemplateByBranch map[string]string `yaml:"template_by_branch,omitempty"` } // TemplateForBranch returns the template path to render for this config file // on the given normalized branch — a branch-specific override if one is // declared, else the default Template. An empty result means "no template" // (the file is operator-managed and Render skips it). func (cf ConfigFile) TemplateForBranch(branch string) string { if cf.TemplateByBranch != nil { if t, ok := cf.TemplateByBranch[branch]; ok { return t } } return cf.Template } // RCONConfig declares the instance's RCON adapter. type RCONConfig struct { Adapter string `yaml:"adapter"` // source_rcon, telnet, http, stdin_pipe, custom_wasm HostPort string `yaml:"host_port"` // port name from Ports Auth string `yaml:"auth,omitempty"` // password_prompt, source_rcon_login, bearer, none PasswordSecret string `yaml:"password_secret,omitempty"` // name from Secrets to use as RCON password PasswordLiteral string `yaml:"password_literal,omitempty"` // literal password (useful when the module's env pins one — lower priority than PasswordSecret / config_values) // PasswordFromFile reads the password at dial time from a path *inside // the container*. Lowest priority — used as a fallback when the module's // entrypoint generates its own RCON secret on first boot (e.g. 7DTD's // TelnetPassword, which we auto-populate to force 0.0.0.0 binding and // stash in /game-saves/.panel-telnet-password). PasswordFromFile string `yaml:"password_from_file,omitempty"` // PasswordFromINI extracts the password from a specific key in a // specific section of an INI file inside the container. Useful for // modules whose config file holds the RCON password alongside other // settings (ARK: SA stores it as // [ServerSettings]ServerAdminPassword=... in GameUserSettings.ini). // Read fresh on each dial so changes the operator makes via the Config // tab take effect as soon as the server picks them up on restart. PasswordFromINI *PasswordFromINI `yaml:"password_from_ini,omitempty"` Commands map[string]string `yaml:"commands,omitempty"` // named command templates } // PasswordFromINI locates a single key/value in an INI file inside the // container. Section is optional — when empty we match the key anywhere. type PasswordFromINI struct { File string `yaml:"file"` Section string `yaml:"section,omitempty"` Key string `yaml:"key"` } // StateSource is a state-feed declaration — either an RCON poll or a log tail. type StateSource struct { Type string `yaml:"type"` // rcon, log_tail, http_probe Command string `yaml:"command,omitempty"` Every Duration `yaml:"every,omitempty"` Parse *ParseConfig `yaml:"parse,omitempty"` Files []string `yaml:"files,omitempty"` URL string `yaml:"url,omitempty"` } // ParseConfig describes how to turn a raw response into named fields. type ParseConfig struct { Kind string `yaml:"kind"` // regex, json, kv Pattern string `yaml:"pattern,omitempty"` Fields map[string]string `yaml:"fields,omitempty"` // output_name -> named_group } // Event is a log-derived event mapping: regex with named groups, mapped to a kind. type Event struct { Pattern string `yaml:"pattern"` Kind string `yaml:"kind"` // join, leave, chat, death, custom } // UpdateProvider declares a source the panel can fetch new versions from. type UpdateProvider struct { ID string `yaml:"id"` Kind string `yaml:"kind"` // steamcmd, github, direct AppID string `yaml:"app_id,omitempty"` Beta string `yaml:"beta,omitempty"` // Platform, when non-empty, forces SteamCMD to download a specific // platform's depots via +@sSteamCmdForcePlatformType. Used for Windows- // only server builds installed on Linux hosts (Empyrion, a few others). // Values: "windows", "linux", "macos". Platform string `yaml:"platform,omitempty"` Repo string `yaml:"repo,omitempty"` AssetRegex string `yaml:"asset_regex,omitempty"` URL string `yaml:"url,omitempty"` // TargetPath is where downloaded bytes land, relative to the // instance's BrowseableRoot (if the container exists) or its // DataPath bind mount. Required for `direct` + `github` kinds. TargetPath string `yaml:"target_path,omitempty"` // InstallPath is the container-absolute install dir for SteamCMD's // +force_install_dir. Defaults to BrowseableRoot if empty. InstallPath string `yaml:"install_path,omitempty"` // Extract tells the `direct` / `github` provider to treat the // downloaded bytes as an archive (zip / tar / tar.gz / tar.bz2 / // tar.xz) and extract its contents INTO TargetPath (a directory) // instead of writing the raw bytes as a single file. Factorio's // headless .tar.xz uses this. When false, the provider still // auto-extracts if the content is a recognized archive AND // TargetPath has no file extension (looks like a directory) — // but a TargetPath like /server.jar or /terraria-server.zip is // always written verbatim (minecraft jars ARE zips; terraria's // entrypoint expects the zip file on disk). Extract bool `yaml:"extract,omitempty"` // SkipValidate omits the `validate` keyword from the `app_update` // command. Conan Exiles (app 443030) and a handful of other apps // fail with "Missing configuration" when validate runs against an // empty install dir; plain `app_update` succeeds. Operators who // need periodic integrity checks can run Update manually later. SkipValidate bool `yaml:"skip_validate,omitempty"` // RequiresSteamLogin means `+login anonymous` won't work — the app // is paid content (DayZ 223350, Arma 3 229470, etc.) and SteamCMD // needs a real Steam account that owns it. When true, the controller // halts the update flow with {steam_login_required: true} if no // cached credential is available, letting the UI pop a login modal. // Once creds are stored, they're injected as `+login `. RequiresSteamLogin bool `yaml:"requires_steam_login,omitempty"` } // Secret declares an operator-supplied or panel-generated credential. type Secret struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` Generated bool `yaml:"generated,omitempty"` } // Lifecycle declares shell commands (or WASM hooks — future) that run at // specific lifecycle phases. Commands run inside the instance sandbox. type Lifecycle struct { PreInstall []string `yaml:"pre_install,omitempty"` PostInstall []string `yaml:"post_install,omitempty"` PreStart []string `yaml:"pre_start,omitempty"` PostStart []string `yaml:"post_start,omitempty"` PreStop []string `yaml:"pre_stop,omitempty"` PostStop []string `yaml:"post_stop,omitempty"` } // Duration is a time.Duration that parses from YAML strings like "30s". type Duration time.Duration // UnmarshalYAML parses "30s" / "5m" / "1h" style values into a Duration. func (d *Duration) UnmarshalYAML(node *yaml.Node) error { var s string if err := node.Decode(&s); err != nil { return err } if s == "" { *d = 0 return nil } v, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("invalid duration %q: %w", s, err) } *d = Duration(v) return nil } // Std returns the underlying time.Duration. func (d Duration) Std() time.Duration { return time.Duration(d) } // HasMode reports whether the module supports the given runtime mode. func (m *Manifest) HasMode(mode string) bool { for _, x := range m.SupportedModes { if x == mode { return true } } return false } // Port returns the named port declaration, or nil if not declared. func (m *Manifest) Port(name string) *Port { for i := range m.Ports { if m.Ports[i].Name == name { return &m.Ports[i] } } return nil } // UpdateProvider returns the named update provider, or nil. func (m *Manifest) UpdateProvider(id string) *UpdateProvider { for i := range m.UpdateProviders { if m.UpdateProviders[i].ID == id { return &m.UpdateProviders[i] } } return nil }