panel v0.9.2 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:06:16 -07:00
commit 0f6aea796c
2164 changed files with 301480 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
module panel/tools/amp-distill
go 1.24
+500
View File
@@ -0,0 +1,500 @@
// Command amp-distill parses the CubeCoders AMPTemplates repository (MIT
// licensed, see CREDITS.md) into a single self-contained catalog.json the
// panel can serve to its Library UI. It is a build-time tool: run it by hand
// whenever the AMPTemplates clone is refreshed.
//
// Usage:
//
// go run ./tools/amp-distill \
// -templates C:/Users/dbled/sources/AMPTemplates \
// -modules ./modules \
// -out ./controller/cmd/controller/static/catalog.json
//
// For every <game>.kvp it reads the sibling <game>ports.json /
// <game>updates.json / <game>metaconfig.json and distills one CatalogEntry.
// Malformed templates are skipped with a logged reason — the tool never
// fails the whole run over one bad template.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// CatalogEntry is one distilled game template.
type CatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
OS []string `json:"os,omitempty"`
Steam *SteamInfo `json:"steam,omitempty"`
Ports []PortEntry `json:"ports,omitempty"`
RCONAdapter string `json:"rcon_adapter,omitempty"` // source_rcon, telnet, stdio, ...
AdminMethod string `json:"admin_method,omitempty"` // raw App.AdminMethod
ReadyPattern string `json:"ready_pattern,omitempty"`
Events map[string]string `json:"events,omitempty"` // join/leave/chat -> Go regex
UpdateStages []UpdateStage `json:"update_stages,omitempty"`
ConfigFiles []string `json:"config_files,omitempty"`
// SteamLoginRequired: kvp App.SteamUpdateAnonymousLogin=False —
// SteamCMD refuses `+login anonymous` for this title (DayZ/Arma class).
SteamLoginRequired bool `json:"steam_login_required,omitempty"`
ArtSteamAppID string `json:"art_steam_app_id,omitempty"`
Wine bool `json:"wine"` // needs Wine/Proton on a Linux host
EntrypointHint string `json:"entrypoint_hint,omitempty"`
Ported bool `json:"ported,omitempty"` // already exists as a panel module
ModuleID string `json:"module_id,omitempty"`
Provenance string `json:"provenance"` // source .kvp filename
Notes []string `json:"notes,omitempty"`
}
// SteamInfo carries the SteamCMD install identity.
type SteamInfo struct {
AppID string `json:"app_id"` // dedicated-server app id
GameAppID string `json:"game_app_id,omitempty"` // client/game app id (art, SteamAppId env)
Platform string `json:"platform,omitempty"` // "windows" when ForceDownloadPlatform=Windows
}
// PortEntry is one declared network port.
type PortEntry struct {
Name string `json:"name"`
Proto string `json:"proto"` // tcp / udp / both
Port uint32 `json:"port"`
}
// UpdateStage is one mapped install/update pipeline stage.
type UpdateStage struct {
Kind string `json:"kind"` // steamcmd, github, direct
Name string `json:"name,omitempty"`
AppID string `json:"app_id,omitempty"`
Platform string `json:"platform,omitempty"`
Repo string `json:"repo,omitempty"`
AssetRegex string `json:"asset_regex,omitempty"`
URL string `json:"url,omitempty"`
TargetPath string `json:"target_path,omitempty"`
// Conditional stages (AMP UpdateSourceConditionSetting) are optional
// extras like ValheimPlus/BepInEx — flagged so the UI can hide them.
Conditional bool `json:"conditional,omitempty"`
}
// ampPort mirrors the shape of <game>ports.json entries (recursive).
type ampPort struct {
Protocol string `json:"Protocol"`
Port json.RawMessage `json:"Port"`
Name string `json:"Name"`
ChildPorts []ampPort `json:"ChildPorts"`
}
// ampUpdateStage mirrors <game>updates.json entries.
type ampUpdateStage struct {
UpdateStageName string `json:"UpdateStageName"`
UpdateSourcePlatform string `json:"UpdateSourcePlatform"`
UpdateSource string `json:"UpdateSource"`
UpdateSourceData string `json:"UpdateSourceData"`
UpdateSourceArgs string `json:"UpdateSourceArgs"`
UpdateSourceTarget string `json:"UpdateSourceTarget"`
ForceDownloadPlatform string `json:"ForceDownloadPlatform"`
UpdateSourceConditionSetting string `json:"UpdateSourceConditionSetting"`
}
// ampMetaConfig mirrors <game>metaconfig.json entries (only the file path).
type ampMetaConfig struct {
ConfigFile string `json:"ConfigFile"`
}
// moduleInfo is what we scrape from an existing modules/<id>/module.yaml.
type moduleInfo struct {
ID string
Name string
AppIDs []string
}
var namedGroupRe = regexp.MustCompile(`\(\?<([A-Za-z][A-Za-z0-9]*)>`)
// toGoRegex converts .NET named groups (?<name>...) to Go (?P<name>...).
// Returns the converted pattern and whether it compiles as a Go regexp.
func toGoRegex(p string) (string, bool) {
if p == "" {
return "", true
}
conv := namedGroupRe.ReplaceAllString(p, `(?P<$1>`)
_, err := regexp.Compile(conv)
return conv, err == nil
}
func parseKVP(path string) (map[string]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
kv := make(map[string]string)
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
i := strings.IndexByte(line, '=')
if i < 0 {
continue
}
key := strings.TrimSpace(line[:i])
if _, seen := kv[key]; !seen { // first occurrence wins
kv[key] = line[i+1:]
}
}
return kv, nil
}
func flattenPorts(in []ampPort, out *[]PortEntry, notes *[]string) {
for _, p := range in {
var num uint64
var perr error
s := strings.Trim(string(p.Port), `"`)
if s != "" && s != "null" {
num, perr = strconv.ParseUint(strings.TrimSpace(s), 10, 32)
}
if perr != nil || num == 0 || num > 65535 {
*notes = append(*notes, fmt.Sprintf("skipped port %q: unparseable value %s", p.Name, string(p.Port)))
} else {
proto := strings.ToLower(p.Protocol)
if proto != "tcp" && proto != "udp" {
proto = "both"
}
name := p.Name
if name == "" {
name = fmt.Sprintf("port-%d", num)
}
*out = append(*out, PortEntry{Name: name, Proto: proto, Port: uint32(num)})
}
flattenPorts(p.ChildPorts, out, notes)
}
}
func adapterFor(adminMethod string) string {
switch adminMethod {
case "SourceRCON":
return "source_rcon"
case "TelnetRCON", "Telnet":
return "telnet"
case "STDIO":
return "stdio"
case "BattlEyeRCON":
return "battleye"
case "QuakeRCON":
return "quake_rcon"
case "GoldSrcRCON":
return "goldsrc_rcon"
default:
return ""
}
}
// norm reduces a name to lowercase alphanumerics for fuzzy matching.
func norm(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
// moduleAliases maps our module id -> extra normalized names it answers to,
// covering cases where the AMP template id differs from ours.
var moduleAliases = map[string][]string{
"7dtd": {"sevendaystodie", "sdtd"},
"ark-sa": {"arksamin", "arksurvivalascended"},
"dragonwilds": {"runescapedragonwilds"},
"empyrion": {"empyriongalacticsurvival"},
"conan-exiles": {"conanexiles"},
}
var yamlAppIDRe = regexp.MustCompile(`(?m)^\s*app_id:\s*["']?(\d+)`)
var yamlIDRe = regexp.MustCompile(`(?m)^id:\s*["']?([A-Za-z0-9._-]+)`)
var yamlNameRe = regexp.MustCompile(`(?m)^name:\s*["']?([^"'\r\n]+)`)
func loadModules(dir string) ([]moduleInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
// Helper/infra modules that are not real game ports — never claim an
// AMP template as "ported" via these (steamcmd-test uses HLDS app 90,
// which would falsely mark half-life/counter-strike as ported).
nonGame := map[string]bool{"demo": true, "steamcmd-test": true, "empyrion-bridge": true}
var out []moduleInfo
for _, e := range entries {
if !e.IsDir() || nonGame[e.Name()] {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name(), "module.yaml"))
if err != nil {
continue
}
mi := moduleInfo{ID: e.Name()}
if m := yamlIDRe.FindSubmatch(raw); m != nil {
mi.ID = string(m[1])
}
if m := yamlNameRe.FindSubmatch(raw); m != nil {
mi.Name = strings.TrimSpace(string(m[1]))
}
for _, m := range yamlAppIDRe.FindAllSubmatch(raw, -1) {
mi.AppIDs = append(mi.AppIDs, string(m[1]))
}
out = append(out, mi)
}
return out, nil
}
// matchModule returns the module id an entry corresponds to, or "".
func matchModule(e *CatalogEntry, mods []moduleInfo) string {
entryNames := map[string]bool{norm(e.ID): true, norm(e.Name): true}
for _, m := range mods {
// by steam app id (server app id or game app id)
if e.Steam != nil {
for _, id := range m.AppIDs {
if id == e.Steam.AppID || id == e.Steam.GameAppID {
return m.ID
}
}
}
// by name heuristics
names := []string{norm(m.ID), norm(m.Name)}
names = append(names, moduleAliases[m.ID]...)
for _, n := range names {
if n != "" && entryNames[n] {
return m.ID
}
}
}
return ""
}
func distillOne(dir, kvpFile string) (*CatalogEntry, error) {
id := strings.TrimSuffix(kvpFile, ".kvp")
kv, err := parseKVP(filepath.Join(dir, kvpFile))
if err != nil {
return nil, fmt.Errorf("read kvp: %w", err)
}
name := kv["Meta.DisplayName"]
if name == "" {
name = kv["App.DisplayName"]
}
if name == "" {
return nil, fmt.Errorf("no Meta.DisplayName/App.DisplayName")
}
e := &CatalogEntry{
ID: id,
Name: name,
Description: kv["Meta.Description"],
AdminMethod: kv["App.AdminMethod"],
RCONAdapter: adapterFor(kv["App.AdminMethod"]),
Provenance: kvpFile,
}
if osList := kv["Meta.OS"]; osList != "" {
for _, o := range strings.Split(osList, ",") {
if o = strings.TrimSpace(o); o != "" {
e.OS = append(e.OS, o)
}
}
}
e.SteamLoginRequired = strings.EqualFold(strings.TrimSpace(kv["App.SteamUpdateAnonymousLogin"]), "false")
if hint := strings.TrimSpace(kv["App.CommandLineArgs"]); hint != "" {
e.EntrypointHint = hint
}
if src := kv["Meta.DisplayImageSource"]; strings.HasPrefix(src, "steam:") {
e.ArtSteamAppID = strings.TrimPrefix(src, "steam:")
}
// Regexes: AppReady + join/leave/chat.
if p, ok := toGoRegex(kv["Console.AppReadyRegex"]); ok {
e.ReadyPattern = p
} else {
e.Notes = append(e.Notes, "ready_pattern dropped: does not compile as Go regexp")
}
e.Events = map[string]string{}
for kind, key := range map[string]string{
"join": "Console.UserJoinRegex",
"leave": "Console.UserLeaveRegex",
"chat": "Console.UserChatRegex",
} {
if raw := kv[key]; raw != "" {
if p, ok := toGoRegex(raw); ok {
e.Events[kind] = p
} else {
e.Notes = append(e.Notes, kind+" regex dropped: does not compile as Go regexp")
}
}
}
if len(e.Events) == 0 {
e.Events = nil
}
// Ports.
if raw, err := os.ReadFile(filepath.Join(dir, id+"ports.json")); err == nil {
var ports []ampPort
if err := json.Unmarshal(raw, &ports); err != nil {
e.Notes = append(e.Notes, "ports.json unparseable: "+err.Error())
} else {
flattenPorts(ports, &e.Ports, &e.Notes)
}
}
// Update stages.
wineSeen := false
if raw, err := os.ReadFile(filepath.Join(dir, id+"updates.json")); err == nil {
var stages []ampUpdateStage
if err := json.Unmarshal(raw, &stages); err != nil {
e.Notes = append(e.Notes, "updates.json unparseable: "+err.Error())
} else {
for _, s := range stages {
if strings.Contains(strings.ToLower(s.UpdateSourceArgs), "wine") ||
strings.Contains(strings.ToLower(s.UpdateStageName), "wine") {
wineSeen = true
}
st := UpdateStage{Name: s.UpdateStageName, Conditional: s.UpdateSourceConditionSetting != ""}
switch s.UpdateSource {
case "SteamCMD":
st.Kind = "steamcmd"
st.AppID = s.UpdateSourceData
if strings.EqualFold(s.ForceDownloadPlatform, "Windows") {
st.Platform = "windows"
}
if e.Steam == nil && !st.Conditional {
e.Steam = &SteamInfo{AppID: s.UpdateSourceData, GameAppID: s.UpdateSourceArgs, Platform: st.Platform}
}
case "GithubRelease":
st.Kind = "github"
st.Repo = s.UpdateSourceArgs
st.AssetRegex = s.UpdateSourceData
st.TargetPath = s.UpdateSourceTarget
case "FetchURL":
st.Kind = "direct"
st.URL = s.UpdateSourceData
st.TargetPath = s.UpdateSourceTarget
if st.TargetPath == "" {
st.TargetPath = s.UpdateSourceArgs
}
default:
continue // Executable/CreateDirectory/etc. — entrypoint territory
}
e.UpdateStages = append(e.UpdateStages, st)
}
}
}
// Config file paths from metaconfig.
if raw, err := os.ReadFile(filepath.Join(dir, id+"metaconfig.json")); err == nil {
var metas []ampMetaConfig
if err := json.Unmarshal(raw, &metas); err == nil {
seen := map[string]bool{}
for _, m := range metas {
if m.ConfigFile != "" && !seen[m.ConfigFile] {
seen[m.ConfigFile] = true
e.ConfigFiles = append(e.ConfigFiles, m.ConfigFile)
}
}
} else {
e.Notes = append(e.Notes, "metaconfig.json unparseable: "+err.Error())
}
}
// Wine flag: Windows-only template, forced Windows depots, or explicit
// wine bootstrap in the update pipeline.
linuxNative := false
for _, o := range e.OS {
if strings.EqualFold(o, "Linux") {
linuxNative = true
}
}
e.Wine = wineSeen || !linuxNative || (e.Steam != nil && e.Steam.Platform == "windows")
return e, nil
}
func main() {
templates := flag.String("templates", `C:\Users\dbled\sources\AMPTemplates`, "path to the AMPTemplates clone")
modules := flag.String("modules", "modules", "path to the panel modules/ directory")
out := flag.String("out", filepath.Join("controller", "cmd", "controller", "static", "catalog.json"), "output catalog path")
flag.Parse()
mods, err := loadModules(*modules)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: load modules: %v\n", err)
os.Exit(1)
}
entries, err := os.ReadDir(*templates)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: read templates dir: %v\n", err)
os.Exit(1)
}
var catalog []*CatalogEntry
var skipped []string
portedMap := map[string]string{}
for _, de := range entries {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".kvp") {
continue
}
e, err := distillOne(*templates, de.Name())
if err != nil {
skipped = append(skipped, fmt.Sprintf("%s: %v", de.Name(), err))
continue
}
if mid := matchModule(e, mods); mid != "" {
e.Ported = true
e.ModuleID = mid
portedMap[e.ID] = mid
}
catalog = append(catalog, e)
}
sort.Slice(catalog, func(i, j int) bool { return catalog[i].ID < catalog[j].ID })
buf, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: marshal: %v\n", err)
os.Exit(1)
}
buf = append(buf, '\n')
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
fmt.Fprintf(os.Stderr, "FATAL: mkdir: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(*out, buf, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "FATAL: write: %v\n", err)
os.Exit(1)
}
fmt.Printf("catalog: %d entries -> %s (%d KB)\n", len(catalog), *out, len(buf)/1024)
fmt.Printf("ported matches (%d):\n", len(portedMap))
var ids []string
for k := range portedMap {
ids = append(ids, k)
}
sort.Strings(ids)
for _, k := range ids {
fmt.Printf(" %s -> module %s\n", k, portedMap[k])
}
if len(skipped) > 0 {
fmt.Printf("skipped (%d):\n", len(skipped))
for _, s := range skipped {
fmt.Println(" " + s)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
module github.com/dbledeez/panel/tools/module-meta-dump
go 1.26
require github.com/dbledeez/panel/pkg v0.0.0
require gopkg.in/yaml.v3 v3.0.1 // indirect
replace github.com/dbledeez/panel/pkg => ../../pkg
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+45
View File
@@ -0,0 +1,45 @@
// Command module-meta-dump loads ./modules via the same loader the
// controller uses (module.LoadDir) and prints the WI-07 UI-metadata
// enrichment as JSON — used by controller/.test/wi07-diff-maps.mjs to
// prove the manifest-derived values match the dashboard's old inline
// maps. Run from the repo root: go run ./tools/module-meta-dump
package main
import (
"encoding/json"
"fmt"
"os"
module "github.com/dbledeez/panel/pkg/module"
)
type meta struct {
ID string `json:"id"`
Ports []module.Port `json:"ports,omitempty"`
BrowseableRoots []module.BrowseableRoot `json:"browseable_roots,omitempty"`
ReadyPattern string `json:"ready_pattern,omitempty"`
Appearance *module.Appearance `json:"appearance,omitempty"`
}
func main() {
dir := "./modules"
if len(os.Args) > 1 {
dir = os.Args[1]
}
ms, err := module.LoadDir(dir)
if err != nil {
fmt.Fprintln(os.Stderr, "load:", err)
os.Exit(1)
}
out := map[string]meta{}
for _, m := range ms {
e := meta{ID: m.ID, Ports: m.Ports, ReadyPattern: m.ReadyPattern, Appearance: m.Appearance}
if m.Runtime.Docker != nil {
e.BrowseableRoots = m.Runtime.Docker.BrowseableRoots
}
out[m.ID] = e
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(out)
}