panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
// Package steamvdf is a small, tolerant parser for Valve's text KeyValues
// ("VDF") format, sized for exactly two panel jobs:
//
// 1. Reading the installed buildid + beta branch out of a SteamCMD
// appmanifest_<appid>.acf in an instance volume.
// 2. Reading the per-branch latest buildids out of `steamcmd
// +app_info_print <appid>` output.
//
// Tolerant means: unquoted tokens are accepted, // comments are skipped,
// stray junk lines around the top-level object (SteamCMD banner output)
// are ignored, and duplicate keys take last-wins. It is NOT a general
// binary-VDF or #include-aware parser.
package steamvdf
import (
"fmt"
"strings"
)
// KV is one parsed VDF object: values are either string or KV.
type KV map[string]any
// Get walks a path of nested keys (case-insensitive, matching Steam's own
// behaviour) and returns the string value at the leaf, or "" if any hop
// is missing or is an object where a string was expected.
func (kv KV) Get(path ...string) string {
cur := kv
for i, key := range path {
v, ok := lookup(cur, key)
if !ok {
return ""
}
if i == len(path)-1 {
s, _ := v.(string)
return s
}
next, ok := v.(KV)
if !ok {
return ""
}
cur = next
}
return ""
}
// Obj walks a path and returns the nested object at the leaf, or nil.
func (kv KV) Obj(path ...string) KV {
cur := kv
for _, key := range path {
v, ok := lookup(cur, key)
if !ok {
return nil
}
next, ok := v.(KV)
if !ok {
return nil
}
cur = next
}
return cur
}
func lookup(kv KV, key string) (any, bool) {
if v, ok := kv[key]; ok {
return v, true
}
for k, v := range kv {
if strings.EqualFold(k, key) {
return v, true
}
}
return nil, false
}
// Parse parses VDF text into a KV. The top-level KV maps the root key(s)
// to their objects (an ACF parses to {"AppState": {...}}). Junk lines
// outside the first root object (SteamCMD banners, progress lines) are
// skipped as long as they don't contain an unbalanced brace-quote mix
// that swallows real content — in practice SteamCMD noise never does.
func Parse(src string) (KV, error) {
toks := tokenize(stripLeadingNoise(src))
root := KV{}
i := 0
for i < len(toks) {
// Expect: key, then either "{" (object) or value.
if toks[i].kind == tokOpen || toks[i].kind == tokClose {
// Stray brace at top level — skip it (tolerance).
i++
continue
}
key := toks[i].text
i++
if i >= len(toks) {
break // trailing bare token (noise) — ignore
}
switch toks[i].kind {
case tokOpen:
obj, n, err := parseObject(toks, i+1)
if err != nil {
return nil, err
}
root[key] = obj
i = n
case tokStr:
root[key] = toks[i].text
i++
default:
i++ // key followed by "}" — noise, skip
}
}
return root, nil
}
func parseObject(toks []token, i int) (KV, int, error) {
obj := KV{}
for i < len(toks) {
switch toks[i].kind {
case tokClose:
return obj, i + 1, nil
case tokOpen:
return nil, i, fmt.Errorf("steamvdf: unexpected '{' without a key")
}
key := toks[i].text
i++
if i >= len(toks) {
return nil, i, fmt.Errorf("steamvdf: truncated after key %q", key)
}
switch toks[i].kind {
case tokOpen:
sub, n, err := parseObject(toks, i+1)
if err != nil {
return nil, n, err
}
obj[key] = sub
i = n
case tokStr:
obj[key] = toks[i].text
i++
case tokClose:
// key with no value right before '}' — tolerate, drop key.
return obj, i + 1, nil
}
}
return nil, i, fmt.Errorf("steamvdf: unterminated object")
}
// stripLeadingNoise drops SteamCMD banner/progress lines that precede the
// actual VDF (e.g. "Loading Steam API...OK", "AppID : 294420, change
// number : …"). Those lines tokenize into stray strings that misalign
// key/value pairing. The real document starts at the first line that is
// a lone quoted key whose next non-blank line opens a brace.
func stripLeadingNoise(src string) string {
lines := strings.Split(src, "\n")
for i, line := range lines {
t := strings.TrimSpace(line)
if len(t) < 2 || t[0] != '"' || t[len(t)-1] != '"' || strings.Count(t, `"`) != 2 {
continue
}
for j := i + 1; j < len(lines); j++ {
nt := strings.TrimSpace(lines[j])
if nt == "" {
continue
}
if strings.HasPrefix(nt, "{") {
return strings.Join(lines[i:], "\n")
}
break
}
}
return src // no root-key/brace pattern found — parse as-is
}
type tokKind int
const (
tokStr tokKind = iota
tokOpen
tokClose
)
type token struct {
kind tokKind
text string
}
// tokenize splits VDF text into quoted/unquoted strings and braces.
// Handles \" escapes inside quoted strings and skips // line comments.
func tokenize(src string) []token {
var toks []token
i, n := 0, len(src)
for i < n {
c := src[i]
switch {
case c == ' ' || c == '\t' || c == '\r' || c == '\n':
i++
case c == '/' && i+1 < n && src[i+1] == '/':
for i < n && src[i] != '\n' {
i++
}
case c == '{':
toks = append(toks, token{kind: tokOpen})
i++
case c == '}':
toks = append(toks, token{kind: tokClose})
i++
case c == '"':
i++
var b strings.Builder
for i < n && src[i] != '"' {
if src[i] == '\\' && i+1 < n {
i++
}
b.WriteByte(src[i])
i++
}
i++ // closing quote
toks = append(toks, token{kind: tokStr, text: b.String()})
default:
start := i
for i < n && !strings.ContainsRune(" \t\r\n{}\"", rune(src[i])) {
i++
}
toks = append(toks, token{kind: tokStr, text: src[start:i]})
}
}
return toks
}
// AppManifest is the subset of appmanifest_<appid>.acf the panel cares
// about for update checks.
type AppManifest struct {
AppID string
BuildID string
// BetaKey is the Steam branch the install came from ("" = public).
// SteamCMD records it under UserConfig/betakey (and sometimes
// MountedConfig/betakey after a finished install).
BetaKey string
Name string
}
// ParseAppManifest extracts buildid + betakey from ACF text.
func ParseAppManifest(src string) (*AppManifest, error) {
kv, err := Parse(src)
if err != nil {
return nil, err
}
app := kv.Obj("AppState")
if app == nil {
return nil, fmt.Errorf("steamvdf: no AppState object in manifest")
}
m := &AppManifest{
AppID: app.Get("appid"),
BuildID: app.Get("buildid"),
Name: app.Get("name"),
}
if bk := app.Get("UserConfig", "betakey"); bk != "" {
m.BetaKey = bk
} else if bk := app.Get("MountedConfig", "betakey"); bk != "" {
m.BetaKey = bk
}
if m.BuildID == "" {
return m, fmt.Errorf("steamvdf: manifest has no buildid")
}
return m, nil
}
// ParseAppInfoBranches extracts the depots→branches map (branch name →
// buildid) from `steamcmd +app_info_print <appid>` output. The output
// has SteamCMD banner/progress noise around a root object keyed by the
// numeric appid; the tolerant Parse skips the noise.
func ParseAppInfoBranches(src, appID string) (map[string]string, error) {
kv, err := Parse(src)
if err != nil {
return nil, err
}
app := kv.Obj(appID)
if app == nil {
// Some SteamCMD builds print the root without us knowing the key
// (e.g. when the caller passed "" or output nests differently).
// Fall back to the first object that has depots/branches.
for _, v := range kv {
if o, ok := v.(KV); ok && o.Obj("depots", "branches") != nil {
app = o
break
}
}
}
if app == nil {
return nil, fmt.Errorf("steamvdf: app %s not found in app_info output", appID)
}
branches := app.Obj("depots", "branches")
if branches == nil {
return nil, fmt.Errorf("steamvdf: no depots.branches in app_info for %s", appID)
}
out := map[string]string{}
for name, v := range branches {
if o, ok := v.(KV); ok {
if bid := o.Get("buildid"); bid != "" {
out[name] = bid
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("steamvdf: branches map empty for %s", appID)
}
return out, nil
}
+191
View File
@@ -0,0 +1,191 @@
package steamvdf
import "testing"
// Realistic ACF as steamcmd writes it for a 7DTD dedicated server pinned
// to a frozen beta branch.
const acf7dtd = `"AppState"
{
"appid" "294420"
"Universe" "1"
"name" "7 Days to Die Dedicated Server"
"StateFlags" "4"
"installdir" "7 Days to Die Dedicated Server"
"LastUpdated" "1750912345"
"SizeOnDisk" "14680064000"
"StagingSize" "0"
"buildid" "14503775"
"LastOwner" "76561190000000000"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"InstalledDepots"
{
"294422"
{
"manifest" "1118115119020227469"
"size" "14680064000"
}
}
"UserConfig"
{
"betakey" "v2.6"
}
"MountedConfig"
{
"betakey" "v2.6"
}
}
`
const acfPublic = `"AppState"
{
"appid" "2430930"
"name" "Palworld Dedicated Server"
"buildid" "20112233"
"UserConfig"
{
}
}
`
func TestParseAppManifestBetaBranch(t *testing.T) {
m, err := ParseAppManifest(acf7dtd)
if err != nil {
t.Fatalf("ParseAppManifest: %v", err)
}
if m.AppID != "294420" || m.BuildID != "14503775" || m.BetaKey != "v2.6" {
t.Fatalf("got %+v", m)
}
if m.Name != "7 Days to Die Dedicated Server" {
t.Fatalf("name: %q", m.Name)
}
}
func TestParseAppManifestPublic(t *testing.T) {
m, err := ParseAppManifest(acfPublic)
if err != nil {
t.Fatalf("ParseAppManifest: %v", err)
}
if m.BuildID != "20112233" || m.BetaKey != "" {
t.Fatalf("got %+v", m)
}
}
func TestParseAppManifestNoBuildID(t *testing.T) {
if _, err := ParseAppManifest(`"AppState" { "appid" "1" }`); err == nil {
t.Fatal("expected error for manifest without buildid")
}
}
// app_info_print output as steamcmd emits it: banner noise, the numeric
// appid root key, escaped quotes, nested branches with pwdrequired etc.
const appInfo = `Redirecting stderr to '/root/Steam/logs/stderr.txt'
Loading Steam API...OK
Connecting anonymously to Steam Public...OK
Waiting for client config...OK
Waiting for user info...OK
AppID : 294420, change number : 27581234/0, last change : Sat Jun 28 12:00:00 2026
"294420"
{
"common"
{
"name" "7 Days to Die Dedicated Server"
"type" "Tool"
}
"config"
{
"installdir" "7 Days to Die Dedicated Server"
}
"depots"
{
"294422"
{
"name" "Linux Depot"
"config"
{
"oslist" "linux"
}
}
"branches"
{
"public"
{
"buildid" "19216811"
"timeupdated" "1751168000"
}
"latest_experimental"
{
"buildid" "19333444"
"description" "Latest \"experimental\" build"
"timeupdated" "1752168000"
}
"v2.6"
{
"buildid" "14503775"
"description" "2.6 Stable"
"timeupdated" "1750168000"
}
"private_qa"
{
"buildid" "19999999"
"pwdrequired" "1"
}
}
}
}
`
func TestParseAppInfoBranches(t *testing.T) {
b, err := ParseAppInfoBranches(appInfo, "294420")
if err != nil {
t.Fatalf("ParseAppInfoBranches: %v", err)
}
want := map[string]string{
"public": "19216811",
"latest_experimental": "19333444",
"v2.6": "14503775",
"private_qa": "19999999",
}
for k, v := range want {
if b[k] != v {
t.Fatalf("branch %s: got %q want %q (all: %v)", k, b[k], v, b)
}
}
}
func TestParseAppInfoBranchesUnknownRootKey(t *testing.T) {
// Caller passes the wrong appid; fallback finds the object anyway.
b, err := ParseAppInfoBranches(appInfo, "999999")
if err != nil {
t.Fatalf("fallback failed: %v", err)
}
if b["public"] != "19216811" {
t.Fatalf("got %v", b)
}
}
func TestParseAppInfoNoBranches(t *testing.T) {
if _, err := ParseAppInfoBranches(`"1" { "common" { "name" "x" } }`, "1"); err == nil {
t.Fatal("expected error when branches missing")
}
}
func TestParseToleratesCommentsAndUnquoted(t *testing.T) {
kv, err := Parse("// comment\nAppState { key value // trailing\n nested { a b } }")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if kv.Get("AppState", "key") != "value" || kv.Get("appstate", "nested", "a") != "b" {
t.Fatalf("got %v", kv)
}
}
func TestParseUnterminated(t *testing.T) {
if _, err := Parse(`"A" { "k" "v"`); err == nil {
t.Fatal("expected unterminated-object error")
}
}