feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay #29
No files matched your search
@@ -153,6 +153,13 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The panel fills {PUBLIC_IP}, {PUBLIC_HOST} and {PUBLIC_PORT_n} in the startup command too
|
||||||
|
startup := g["defaultStartupCommand"]
|
||||||
|
if cmd, ok := startup.(string); ok {
|
||||||
|
cmd = strings.NewReplacer("{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(cmd)
|
||||||
|
startup = publicPort.ReplaceAllString(cmd, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"command": "DEPLOY_SERVER",
|
"command": "DEPLOY_SERVER",
|
||||||
"serverId": "boottest-" + id,
|
"serverId": "boottest-" + id,
|
||||||
@@ -166,7 +173,7 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
|
|||||||
"configFilePath": g["configFilePath"],
|
"configFilePath": g["configFilePath"],
|
||||||
"configFileFormat": g["configFileFormat"],
|
"configFileFormat": g["configFileFormat"],
|
||||||
"configFormatOptions": g["configFormatOptions"],
|
"configFormatOptions": g["configFormatOptions"],
|
||||||
"startupCommand": g["defaultStartupCommand"],
|
"startupCommand": startup,
|
||||||
"memoryLimitMb": g["requiredMemoryMb"],
|
"memoryLimitMb": g["requiredMemoryMb"],
|
||||||
"containerUser": g["containerUser"],
|
"containerUser": g["containerUser"],
|
||||||
"dockerNetwork": bootTestNetwork,
|
"dockerNetwork": bootTestNetwork,
|
||||||
|
|||||||
@@ -132,3 +132,18 @@ func TestSteamCacheStampAge(t *testing.T) {
|
|||||||
t.Error("old, missing or garbled stamps are not fresh")
|
t.Error("old, missing or garbled stamps are not fresh")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBootTestPayloadFillsPublicPlaceholdersInStartupCommand(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
game := filepath.Join(dir, "example")
|
||||||
|
os.MkdirAll(game, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(game, "game.yaml"), []byte("id: example\nname: Example\ndefaultDockerImage: x\n"+
|
||||||
|
"defaultStartupCommand: ./server -Port={PUBLIC_PORT_7777} -ip={PUBLIC_IP}\ndefaultPorts:\n - 7777/udp\n"), 0o644)
|
||||||
|
p, err := bootTestPayload(dir, "example", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p["startupCommand"] != "./server -Port=7777 -ip=127.0.0.1" {
|
||||||
|
t.Errorf("startupCommand %v", p["startupCommand"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
-204
@@ -1,204 +1,204 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Game fields in the order they are written to game.yaml. Panel-owned business settings
|
// Game fields in the order they are written to game.yaml. Panel-owned business settings
|
||||||
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
|
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
|
||||||
var gameFields = []string{
|
var gameFields = []string{
|
||||||
"id", "name",
|
"id", "name",
|
||||||
"defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
|
"defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
|
||||||
"installMethod", "installConfig",
|
"installMethod", "installConfig",
|
||||||
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
|
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
|
||||||
"agnosticCommandConfig", "clusterConfig",
|
"agnosticCommandConfig", "clusterConfig",
|
||||||
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
|
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
|
||||||
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
|
"publicIpConfigKey", "portMode", "maxPlayersConfigKey", "mapConfigKey",
|
||||||
"aiCoverPrompt",
|
"aiCoverPrompt",
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
|
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
|
||||||
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
|
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
|
||||||
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
|
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
|
||||||
|
|
||||||
func runImport(args []string) error {
|
func runImport(args []string) error {
|
||||||
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
||||||
gamesDir := fs.String("games", "games", "directory to write one folder per game into")
|
gamesDir := fs.String("games", "games", "directory to write one folder per game into")
|
||||||
all := fs.Bool("all", false, "also import games that are disabled in the panel")
|
all := fs.Bool("all", false, "also import games that are disabled in the panel")
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
return errors.New("import needs the path to a GET /api/v1/admin/games export")
|
return errors.New("import needs the path to a GET /api/v1/admin/games export")
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := os.ReadFile(fs.Arg(0))
|
raw, err := os.ReadFile(fs.Arg(0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var games []map[string]any
|
var games []map[string]any
|
||||||
if err := json.Unmarshal(raw, &games); err != nil {
|
if err := json.Unmarshal(raw, &games); err != nil {
|
||||||
return fmt.Errorf("export is not a JSON array of games: %w", err)
|
return fmt.Errorf("export is not a JSON array of games: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
id, _ := g["id"].(string)
|
id, _ := g["id"].(string)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return errors.New("export contains a game without an id")
|
return errors.New("export contains a game without an id")
|
||||||
}
|
}
|
||||||
if g["enabled"] == false && !*all {
|
if g["enabled"] == false && !*all {
|
||||||
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
|
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dir := filepath.Join(*gamesDir, id)
|
dir := filepath.Join(*gamesDir, id)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
|
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
|
||||||
return fmt.Errorf("%s: %w", id, err)
|
return fmt.Errorf("%s: %w", id, err)
|
||||||
}
|
}
|
||||||
testPath := filepath.Join(dir, "test.yaml")
|
testPath := filepath.Join(dir, "test.yaml")
|
||||||
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
|
||||||
if err := writeTest(testPath, g); err != nil {
|
if err := writeTest(testPath, g); err != nil {
|
||||||
return fmt.Errorf("%s: %w", id, err)
|
return fmt.Errorf("%s: %w", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("wrote %s\n", dir)
|
fmt.Printf("wrote %s\n", dir)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeGame(path string, g map[string]any) error {
|
func writeGame(path string, g map[string]any) error {
|
||||||
doc := &yaml.Node{Kind: yaml.MappingNode}
|
doc := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
for _, f := range gameFields {
|
for _, f := range gameFields {
|
||||||
v, ok := g[f]
|
v, ok := g[f]
|
||||||
if !ok || isEmpty(v) {
|
if !ok || isEmpty(v) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val, err := toNode(normalize(v))
|
val, err := toNode(normalize(v))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
|
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
|
||||||
}
|
}
|
||||||
return writeYAML(path, gameHeader, doc)
|
return writeYAML(path, gameHeader, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
|
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
|
||||||
func writeTest(path string, g map[string]any) error {
|
func writeTest(path string, g map[string]any) error {
|
||||||
cfg := map[string]any{}
|
cfg := map[string]any{}
|
||||||
for key, p := range schemaProperties(g) {
|
for key, p := range schemaProperties(g) {
|
||||||
if prop, ok := p.(map[string]any); ok {
|
if prop, ok := p.(map[string]any); ok {
|
||||||
if c, ok := prop["x-consent"]; ok && c != false {
|
if c, ok := prop["x-consent"]; ok && c != false {
|
||||||
cfg[key] = true
|
cfg[key] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timeout := 900
|
timeout := 900
|
||||||
if g["installMethod"] == "STEAMCMD" {
|
if g["installMethod"] == "STEAMCMD" {
|
||||||
timeout = 2400
|
timeout = 2400
|
||||||
}
|
}
|
||||||
test := map[string]any{"timeoutSec": timeout}
|
test := map[string]any{"timeoutSec": timeout}
|
||||||
if len(cfg) > 0 {
|
if len(cfg) > 0 {
|
||||||
test["config"] = cfg
|
test["config"] = cfg
|
||||||
}
|
}
|
||||||
var doc yaml.Node
|
var doc yaml.Node
|
||||||
if err := doc.Encode(test); err != nil {
|
if err := doc.Encode(test); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeYAML(path, testHeader, &doc)
|
return writeYAML(path, testHeader, &doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeYAML(path, header string, doc *yaml.Node) error {
|
func writeYAML(path, header string, doc *yaml.Node) error {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(header)
|
b.WriteString(header)
|
||||||
enc := yaml.NewEncoder(&b)
|
enc := yaml.NewEncoder(&b)
|
||||||
enc.SetIndent(2)
|
enc.SetIndent(2)
|
||||||
if err := enc.Encode(doc); err != nil {
|
if err := enc.Encode(doc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
enc.Close()
|
enc.Close()
|
||||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
|
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
|
||||||
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
|
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
|
||||||
func toNode(v any) (*yaml.Node, error) {
|
func toNode(v any) (*yaml.Node, error) {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
n := &yaml.Node{Kind: yaml.MappingNode}
|
n := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
keys := make([]string, 0, len(x))
|
keys := make([]string, 0, len(x))
|
||||||
for k := range x {
|
for k := range x {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
val, err := toNode(x[k])
|
val, err := toNode(x[k])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
|
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
case []any:
|
case []any:
|
||||||
n := &yaml.Node{Kind: yaml.SequenceNode}
|
n := &yaml.Node{Kind: yaml.SequenceNode}
|
||||||
for _, e := range x {
|
for _, e := range x {
|
||||||
val, err := toNode(e)
|
val, err := toNode(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.Content = append(n.Content, val)
|
n.Content = append(n.Content, val)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
case string:
|
case string:
|
||||||
if x != "" && strings.TrimSpace(x) == "" {
|
if x != "" && strings.TrimSpace(x) == "" {
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n := &yaml.Node{}
|
n := &yaml.Node{}
|
||||||
if err := n.Encode(v); err != nil {
|
if err := n.Encode(v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
|
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
|
||||||
func normalize(v any) any {
|
func normalize(v any) any {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
|
if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
|
||||||
return int64(x)
|
return int64(x)
|
||||||
}
|
}
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
for k, e := range x {
|
for k, e := range x {
|
||||||
x[k] = normalize(e)
|
x[k] = normalize(e)
|
||||||
}
|
}
|
||||||
case []any:
|
case []any:
|
||||||
for i, e := range x {
|
for i, e := range x {
|
||||||
x[i] = normalize(e)
|
x[i] = normalize(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmpty(v any) bool {
|
func isEmpty(v any) bool {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
return true
|
return true
|
||||||
case string:
|
case string:
|
||||||
return x == ""
|
return x == ""
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
return len(x) == 0
|
return len(x) == 0
|
||||||
case []any:
|
case []any:
|
||||||
return len(x) == 0
|
return len(x) == 0
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
id: runescape-dragonwilds-native-linux
|
id: runescape-dragonwilds-native-linux
|
||||||
name: 'RuneScape: Dragonwilds (Native Linux Server)'
|
name: 'RuneScape: Dragonwilds (Native Linux Server)'
|
||||||
defaultDockerImage: cm2network/steamcmd:root
|
defaultDockerImage: cm2network/steamcmd:root
|
||||||
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port=7777
|
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port={PUBLIC_PORT_7777} -BeaconPort={PUBLIC_PORT_8888}
|
||||||
requiredMemoryMb: 10240
|
requiredMemoryMb: 10240
|
||||||
requiredDiskMb: 20480
|
requiredDiskMb: 20480
|
||||||
defaultPorts:
|
defaultPorts:
|
||||||
@@ -113,5 +113,6 @@ backupPaths:
|
|||||||
- RSDragonwilds/Saved
|
- RSDragonwilds/Saved
|
||||||
maxBackupSizeMb: 5000
|
maxBackupSizeMb: 5000
|
||||||
publicIpConfigKey: MULTIHOME
|
publicIpConfigKey: MULTIHOME
|
||||||
|
portMode: MATCH_PUBLIC
|
||||||
maxPlayersConfigKey: MaxPlayers
|
maxPlayersConfigKey: MaxPlayers
|
||||||
aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.'
|
aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.'
|
||||||
@@ -69,6 +69,10 @@
|
|||||||
"maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 },
|
"maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 },
|
||||||
|
|
||||||
"publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] },
|
"publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] },
|
||||||
|
"portMode": {
|
||||||
|
"description": "MATCH_PUBLIC: for games that advertise the address their own traffic comes from. The container binds the port players use (the relay port behind Public Bypass) and, behind a relay, all its outgoing traffic leaves through the relay. Tell the game its port with {PUBLIC_PORT_<defaultPort>} in defaultStartupCommand.",
|
||||||
|
"enum": ["DEFAULT", "MATCH_PUBLIC", null]
|
||||||
|
},
|
||||||
"maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] },
|
"maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] },
|
||||||
"mapConfigKey": { "type": ["string", "null"] },
|
"mapConfigKey": { "type": ["string", "null"] },
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user