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 }