The Valheim draft in #13 passed -maxplayers, an option Valheim doesn't have. The server ignores unknown options, so the draft booted and passed every boot test while its MaxPlayers setting did nothing. build now reads the pages research based the variant on, in full, and the drafter rejects any launch option in the startup command that none of them, nor the image's own scripts, mention. The model gets it back to remove or spell correctly. Options of shell tools (mkdir -p, [ -n ...]) are skipped. The check is off when there are no sources (draft run by hand, no reader). Against #13's 11 real sources it flags -maxplayers and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Checks a booting draft can still fail: things that only go wrong once an owner changes a setting, so
|
|
// neither the validator nor a boot test with the defaults sees them.
|
|
|
|
// splitSettings finds text settings that reach the server's command line unquoted, so a value with spaces
|
|
// ("GBRHosting - My Server") arrives as several arguments and the game only sees the first word. Covers
|
|
// direct use (-name ${SERVER_NAME}) and the common ARGS="-name ${SERVER_NAME}"; ./server $ARGS, where the
|
|
// quotes in the assignment don't survive the unquoted $ARGS.
|
|
func splitSettings(game map[string]any) []string {
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
if startup == "" {
|
|
return nil
|
|
}
|
|
textVars := map[string]string{} // env name -> setting key
|
|
for key, p := range schemaProperties(game) {
|
|
if prop, _ := p.(map[string]any); prop["type"] == "string" {
|
|
textVars[envName(key)] = key
|
|
}
|
|
}
|
|
if len(textVars) == 0 {
|
|
return nil
|
|
}
|
|
s := scanShell(unwrapShell(startup))
|
|
|
|
split := map[string]string{} // setting key -> how it gets split
|
|
var reach func(holder string, seen map[string]bool) []string
|
|
reach = func(holder string, seen map[string]bool) []string {
|
|
if seen[holder] {
|
|
return nil
|
|
}
|
|
seen[holder] = true
|
|
var out []string
|
|
for _, v := range s.held[holder] {
|
|
if _, ok := textVars[v]; ok {
|
|
out = append(out, v)
|
|
}
|
|
out = append(out, reach(v, seen)...)
|
|
}
|
|
return out
|
|
}
|
|
for _, e := range s.uses {
|
|
if e.quoted {
|
|
continue
|
|
}
|
|
if key, ok := textVars[e.name]; ok {
|
|
split[key] = "${" + e.name + "} is not in double quotes"
|
|
}
|
|
if len(s.held[e.name]) > 0 {
|
|
for _, v := range reach(e.name, map[string]bool{}) {
|
|
if _, done := split[textVars[v]]; !done {
|
|
split[textVars[v]] = fmt.Sprintf("${%s} is inside $%s, which is used without double quotes", v, e.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var problems []string
|
|
for _, key := range sortedKeysString(split) {
|
|
problems = append(problems, fmt.Sprintf("setting %s is split into words on the command line (%s): a value with spaces like "+
|
|
"\"GBRHosting - My Server\" reaches the game as several arguments. Quote it where it is used (-name \"${%s}\"), or build "+
|
|
"optional arguments in a bash array: args=(-name \"$%s\"); [ -n \"$X\" ] && args+=(-x \"$X\"); ./server \"${args[@]}\"",
|
|
key, split[key], envName(key), envName(key)))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`)
|
|
|
|
// unwrapShell looks inside a startup command that is one bash -c '...' (ARK's), which would otherwise be
|
|
// all single-quoted text.
|
|
func unwrapShell(cmd string) string {
|
|
if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") {
|
|
return m[1]
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
type shellUse struct {
|
|
name string
|
|
quoted bool
|
|
}
|
|
|
|
type shellScan struct {
|
|
uses []shellUse // expansions where the value is used: words, arguments
|
|
held map[string][]string // variable -> variables its assigned value contains
|
|
}
|
|
|
|
var (
|
|
assignWord = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\+?=`)
|
|
varName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
|
|
nestedVar = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`)
|
|
)
|
|
|
|
// scanShell is just enough of a shell parser for splitSettings: quotes, $VAR / ${VAR...} expansions, and
|
|
// assignments (NAME=..., NAME+=..., NAME=(...) arrays).
|
|
func scanShell(cmd string) *shellScan {
|
|
s := &shellScan{held: map[string][]string{}}
|
|
var single, double, wordStart = false, false, true
|
|
assign, inArray := "", false
|
|
record := func(name string, quoted bool) {
|
|
switch {
|
|
case assign != "" && !inArray:
|
|
// NAME="...$X..." : no splitting yet, but $NAME carries X along
|
|
s.held[assign] = append(s.held[assign], name)
|
|
case inArray && quoted:
|
|
s.held[assign] = append(s.held[assign], name) // safe unless the array is expanded unquoted
|
|
default:
|
|
s.uses = append(s.uses, shellUse{name, quoted})
|
|
}
|
|
}
|
|
for i := 0; i < len(cmd); i++ {
|
|
c := cmd[i]
|
|
if wordStart && !single && !double {
|
|
if m := assignWord.FindString(cmd[i:]); m != "" {
|
|
assign = strings.TrimRight(strings.TrimSuffix(m, "="), "+")
|
|
i += len(m)
|
|
if i < len(cmd) && cmd[i] == '(' {
|
|
inArray = true
|
|
} else {
|
|
i--
|
|
}
|
|
wordStart = false
|
|
continue
|
|
}
|
|
}
|
|
wordStart = false
|
|
switch {
|
|
case c == '\\' && !single:
|
|
i++
|
|
case c == '\'' && !double:
|
|
single = !single
|
|
case c == '"' && !single:
|
|
double = !double
|
|
case c == '$' && !single && i+1 < len(cmd):
|
|
if cmd[i+1] == '{' {
|
|
end := matchingBrace(cmd, i+1)
|
|
body := cmd[i+2 : end]
|
|
name := varName.FindString(strings.TrimLeft(body, "#!"))
|
|
if name != "" {
|
|
record(name, double)
|
|
}
|
|
// Defaults can hold more expansions: ${A:-${B}}
|
|
for _, m := range nestedVar.FindAllStringSubmatch(body[len(name):], -1) {
|
|
record(m[1], double)
|
|
}
|
|
i = end
|
|
} else if name := varName.FindString(cmd[i+1:]); name != "" {
|
|
record(name, double)
|
|
i += len(name)
|
|
}
|
|
case single || double:
|
|
case inArray && c == ')':
|
|
inArray, assign = false, ""
|
|
case inArray:
|
|
case c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
|
|
assign, wordStart = "", true
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func matchingBrace(s string, open int) int {
|
|
depth := 0
|
|
for i := open; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return len(s) - 1
|
|
}
|
|
|
|
var (
|
|
portSetting = regexp.MustCompile(`(?i)(port$|(^|_)port(_|$))`)
|
|
notAPort = regexp.MustCompile(`(?i)(sup|re|ex|im|trans)port$`)
|
|
// Reached over the Docker network, never published (AUTHORING.md)
|
|
internalPort = regexp.MustCompile(`(?i)rcon|rest|api|admin|telnet|web|http|metrics`)
|
|
)
|
|
|
|
// portSettings checks settings that set a port players connect to against defaultPorts. The node always
|
|
// publishes defaultPorts' container ports (moving only the host side when servers share a node), so a
|
|
// game listening anywhere else can't be reached: a default that isn't published is broken from the
|
|
// start, and an editable one breaks the moment an owner changes it.
|
|
func portSettings(game map[string]any) []string {
|
|
published := map[int]bool{}
|
|
ports, _ := game["defaultPorts"].([]any)
|
|
for _, p := range ports {
|
|
n, _ := strconv.Atoi(strings.Split(containerPort(fmt.Sprint(p)), "/")[0])
|
|
published[n] = true
|
|
}
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
props := schemaProperties(game)
|
|
var problems []string
|
|
keys := make([]string, 0, len(defaults))
|
|
for k := range defaults {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !portSetting.MatchString(key) || notAPort.MatchString(key) || internalPort.MatchString(key) {
|
|
continue
|
|
}
|
|
port, ok := portNumber(defaults[key])
|
|
if !ok {
|
|
continue // {PUBLIC_PORT_n} and other placeholders are filled per server
|
|
}
|
|
_, editable := props[key]
|
|
switch {
|
|
case !published[port]:
|
|
problems = append(problems, fmt.Sprintf("setting %s defaults to port %d, which defaultPorts does not publish: players can't reach it. "+
|
|
"Use one of the published ports, or add this port to defaultPorts if players connect to it", key, port))
|
|
case editable:
|
|
problems = append(problems, fmt.Sprintf("setting %s lets owners change the port, but the node always publishes container port %d "+
|
|
"(defaultPorts), so any other value makes the server unreachable. Don't make it a setting: remove %s from "+
|
|
"defaultConfig and configSchema and write %d directly in the startup command or config file. If the game must "+
|
|
"announce the port players use, give it {PUBLIC_PORT_%d} instead", key, port, key, port, port))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func portNumber(v any) (int, bool) {
|
|
var s string
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
s = n.String()
|
|
case string:
|
|
s = n
|
|
case int, int64, float64:
|
|
s = fmt.Sprint(n)
|
|
default:
|
|
return 0, false
|
|
}
|
|
p, err := strconv.Atoi(strings.TrimSpace(s))
|
|
return p, err == nil && p > 0 && p < 65536
|
|
}
|
|
|
|
var (
|
|
optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`)
|
|
segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`)
|
|
testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true}
|
|
// Options of these are the shell's and its tools', not the game's
|
|
shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true,
|
|
"wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true,
|
|
"sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true,
|
|
"cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true}
|
|
)
|
|
|
|
// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor
|
|
// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one
|
|
// (Valheim has no -maxplayers) boots fine and passes every boot test while its setting does nothing.
|
|
func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string {
|
|
if strings.TrimSpace(docs) == "" {
|
|
return nil
|
|
}
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
corpus := docs
|
|
if facts != nil {
|
|
for _, s := range facts.Scripts {
|
|
corpus += "\n" + s
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
var missing []string
|
|
for _, opt := range launchOptions(unwrapShell(startup)) {
|
|
name := strings.TrimLeft(opt, "-")
|
|
key := strings.ToLower(name)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`)
|
|
if !documented.MatchString(corpus) {
|
|
missing = append(missing, opt)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+
|
|
"Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+
|
|
"only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))}
|
|
}
|
|
|
|
// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]).
|
|
func launchOptions(cmd string) []string {
|
|
var out []string
|
|
for _, seg := range segmentSep.Split(cmd, -1) {
|
|
fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({"))
|
|
i := 0
|
|
for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" ||
|
|
(assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) {
|
|
i++
|
|
}
|
|
if i == len(fields) {
|
|
continue
|
|
}
|
|
if word := path.Base(fields[i]); shellTools[word] {
|
|
continue
|
|
}
|
|
for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) {
|
|
name := strings.TrimLeft(m[1], "-")
|
|
if len(name) < 2 || testOps[name] {
|
|
continue
|
|
}
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
return out
|
|
}
|