Two problems a draft can boot fine with but break once an owner changes a
setting, both in the builder's Valheim draft:
- splitSettings: text settings used unquoted on the command line, directly
or through ARGS="-name ${SERVER_NAME}"; ./server $ARGS, so "GBRHosting -
My Server" reaches the game as several arguments. Suggests quoting or a
bash array.
- portSettings: game-port settings whose default isn't in defaultPorts, or
that owners can change although nodes always publish the same container
port. Internal ports (RCON, REST) and {PUBLIC_PORT_n} values are left out.
Both run on drafts only; AUTHORING.md gets the matching rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
7.9 KiB
Go
251 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
}
|