package main import ( "bufio" "context" "fmt" "os" "path/filepath" "runtime" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // exitSetup prints a pause prompt on Windows, then exits with the given code. func exitSetup(code int) { if runtime.GOOS == "windows" { fmt.Println() fmt.Println("Press Enter to close...") _, _ = bufio.NewReader(os.Stdin).ReadString('\n') } os.Exit(code) } func runSetup() { r := bufio.NewReader(os.Stdin) ask := func(label, def string) string { if def != "" { fmt.Printf(" %s [%s]: ", label, def) } else { fmt.Printf(" %s: ", label) } line, _ := r.ReadString('\n') line = strings.TrimSpace(line) if line == "" { return def } return line } ok := func(msg string) { fmt.Printf(" ✓ %s\n", msg) } fail := func(msg string) { fmt.Printf(" ✗ %s\n", msg) } fmt.Println() fmt.Println("=== dune-admin setup ===") fmt.Println() // ── 1. Control plane ─────────────────────────────────────────────────────── fmt.Println("Control plane:") fmt.Println(" kubectl — Kubernetes (k3s) via SSH (current default)") fmt.Println(" docker — Docker containers (docker CLI with named containers)") fmt.Println(" local — Generic local (configurable shell commands)") fmt.Println(" amp — CubeCoders AMP with podman game-server container") fmt.Println() ctrl := ask("Control plane", "kubectl") if ctrl != "kubectl" && ctrl != "docker" && ctrl != "local" && ctrl != "amp" { ctrl = "kubectl" } fmt.Println() var cfg appConfig cfg.Control = ctrl switch ctrl { case "kubectl": runKubectlSetup(ask, ok, fail, &cfg) case "docker": runDockerSetup(ask, ok, fail, &cfg) case "local": runLocalSetup(ask, ok, fail, &cfg) case "amp": runAmpSetup(ask, ok, fail, &cfg) } // ── Embedded market bot ──────────────────────────────────────────────────── runMarketBotSetup(ask, ok, &cfg) // ── Common: listen address ───────────────────────────────────────────────── fmt.Println("Server config:") defaultListenAddr := listenAddr if ctrl == "amp" && (defaultListenAddr == "" || defaultListenAddr == ":8080") { // AMP web panel commonly binds :8080 on host installs. defaultListenAddr = ":18080" } cfg.ListenAddr = ask("HTTP listen address", defaultListenAddr) fmt.Println() // ── Write config ─────────────────────────────────────────────────────────── writeSetupConfig(ok, fail, cfg) fmt.Println("Setup complete.") fmt.Println() fmt.Println(" Run: dune-admin") fmt.Println() } // ── kubectl setup flow ──────────────────────────────────────────────────────── func setupKubectlSSHKey(ask func(string, string) string, ok, fail func(string)) string { // SSH key fmt.Println("Checking for SSH key...") keyPath := resolveKeyPath() if _, err := os.Stat(keyPath); err != nil { fail("SSH key not found (checked ~/.dune-admin/sshKey, next to binary, ./sshKey)") fmt.Println() sshKeyPath = ask("Path to SSH private key", "") if sshKeyPath == "" { fmt.Fprintln(os.Stderr, "SSH key is required. Aborting.") exitSetup(1) } if _, err := os.Stat(sshKeyPath); err != nil { fmt.Fprintf(os.Stderr, "Key not found at %s. Aborting.\n", sshKeyPath) exitSetup(1) } keyPath = sshKeyPath } else { ok("SSH key: " + keyPath) sshKeyPath = keyPath } fmt.Println() return keyPath } func setupKubectlSSHConnection( ask func(string, string) string, ok, fail func(string), keyPath string, ) *sshExecutor { // SSH connection details fmt.Println("SSH connection:") sshHost = ask("VM host:port", envOr("SSH_HOST", "192.168.0.72:22")) sshUser = ask("SSH user", envOr("SSH_USER", "dune")) fmt.Println() // Dial fmt.Printf("Connecting via SSH to %s...\n", sshHost) client, err := dialSSH(sshHost, sshUser, keyPath) if err != nil { fail("SSH failed: " + err.Error()) fmt.Println() fmt.Printf(" Attempted: user=%s host=%s key=%s\n", sshUser, sshHost, keyPath) fmt.Println() fmt.Println(" Make sure:") fmt.Println(" - The VM is reachable at the given host:port") fmt.Println(" - The correct SSH private key is specified") fmt.Println(" - That key's public key is in ~/.ssh/authorized_keys on the VM") fmt.Println(" - The SSH user matches the account on the VM (default: dune)") fmt.Println(" - The SSH user has passwordless sudo for kubectl") exitSetup(1) } ok("SSH connected") fmt.Println() globalSSH = client return &sshExecutor{client: client} } func setupKubectlDiscoverDBPod(exec *sshExecutor, ok, fail func(string), cfg *appConfig) { // Discover DB pod fmt.Println("Discovering database pod...") ns, pod, podIP, err := discoverDBPod(exec) if err != nil { fail("Pod discovery failed: " + err.Error()) fmt.Println() fmt.Println(" Make sure the SSH user can run: sudo kubectl get pods -A") exitSetup(1) } globalPodNS = ns globalPod = pod globalPodIP = podIP cfg.ControlNamespace = ns controlNS = ns ok("Database pod: " + pod) fmt.Println() } func selectBattlegroup(battlegroups []string, ask func(string, string) string) string { if len(battlegroups) == 0 { return "" } chosen := battlegroups[0] if len(battlegroups) == 1 { return chosen } fmt.Println(" Available battlegroups:") for i, bg := range battlegroups { fmt.Printf(" [%d] %s\n", i+1, bg) } fmt.Println() idxStr := ask(fmt.Sprintf("Which battlegroup? [1-%d]", len(battlegroups)), "1") idx := 1 _, _ = fmt.Sscanf(idxStr, "%d", &idx) if idx >= 1 && idx <= len(battlegroups) { chosen = battlegroups[idx-1] } return chosen } func setupKubectlDiscoverDBCredentials( exec *sshExecutor, ask func(string, string) string, ok, fail func(string), cfg *appConfig, ) (string, string) { // Discover DB password fmt.Println("Discovering database password...") discoveredUser := "postgres" discoveredPass := "" var battlegroups []string if bg := battlegroupFromPod(globalPod); bg != "" { battlegroups = []string{bg} } else { battlegroups = listBattlegroups(exec) } if len(battlegroups) == 0 { fmt.Println(" Could not determine battlegroup name") } else { chosen := selectBattlegroup(battlegroups, ask) yamlPath := fmt.Sprintf("~/.dune/%s.yaml", chosen) if u, pass := extractPasswordFromYAML(exec, yamlPath); pass != "" { discoveredUser = u discoveredPass = pass ok(fmt.Sprintf("Password found in %s (user: %s)", yamlPath, u)) } else { fail("No password found in " + yamlPath) } } if discoveredPass == "" { fmt.Println() fmt.Println(" Could not auto-discover the database password.") discoveredUser = ask("Database user", "postgres") discoveredPass = ask("Database password", "") if discoveredPass == "" { fmt.Fprintln(os.Stderr, "Database password is required. Aborting.") exitSetup(1) } } fmt.Println() return discoveredUser, discoveredPass } func setupKubectlConnectDB( discoveredUser, discoveredPass string, exec *sshExecutor, cfg *appConfig, ok, fail func(string), ) { // Connect to DB fmt.Println("Connecting to database...") dbUser = discoveredUser dbPass = discoveredPass pool, err := connectDB(context.Background(), discoveredUser, discoveredPass) if err != nil { fail("DB connect failed: " + err.Error()) fmt.Println() fmt.Printf(" The password may be wrong. Delete %s and re-run to try again.\n", configPath()) exitSetup(1) } globalDB = pool globalExecutor = exec globalControl = newControlPlane("kubectl", *cfg) ok("Database connected as: " + dbUser) fmt.Println() } func runKubectlSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) { keyPath := setupKubectlSSHKey(ask, ok, fail) exec := setupKubectlSSHConnection(ask, ok, fail, keyPath) setupKubectlDiscoverDBPod(exec, ok, fail, cfg) discoveredUser, discoveredPass := setupKubectlDiscoverDBCredentials(exec, ask, ok, fail, cfg) setupKubectlConnectDB(discoveredUser, discoveredPass, exec, cfg, ok, fail) cfg.ControlNamespace = globalPodNS controlNS = globalPodNS if abs, err := filepath.Abs(keyPath); err == nil { keyPath = abs } cfg.SSHHost = sshHost cfg.SSHUser = sshUser cfg.SSHKey = keyPath cfg.DBHost = "127.0.0.1" cfg.DBPort = dbPort cfg.DBUser = dbUser cfg.DBPass = dbPass cfg.DBName = dbName cfg.DBSchema = dbSchema cfg.ScripCurrency = scripCurrencyID } // ── docker setup flow ───────────────────────────────────────────────────────── func runDockerSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) { fmt.Println("Docker container names:") cfg.DockerGameserver = ask("Game server container name", "dune-gameserver") cfg.DockerBrokerGame = ask("mq-game broker container name (optional)", "") cfg.DockerBrokerAdmin = ask("mq-admin broker container name (optional)", "") fmt.Println() // Test docker access exec := &localExecutor{} out, err := exec.Exec(fmt.Sprintf("docker inspect --format '{{.State.Status}}' %s 2>&1", cfg.DockerGameserver)) if err != nil { fail(fmt.Sprintf("docker inspect failed: %s", out)) fmt.Println(" Make sure Docker is running and the container name is correct.") fmt.Println(" Continuing anyway...") } else { ok(fmt.Sprintf("Container %s is %s", cfg.DockerGameserver, strings.TrimSpace(out))) } fmt.Println() fmt.Println("Database connection:") cfg.DBHost = ask("DB host (Docker DNS or IP)", "database") cfg.DBPort = dbPort portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort)) _, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort) cfg.DBUser = ask("DB user", envOr("DB_USER", "dune")) cfg.DBPass = ask("DB password", "") if cfg.DBPass == "" { fmt.Fprintln(os.Stderr, "Database password is required. Aborting.") exitSetup(1) } cfg.DBName = ask("DB name", envOr("DB_NAME", "dune")) cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune")) fmt.Println() fmt.Println("Connecting to database...") dbHost = cfg.DBHost dbPort = cfg.DBPort dbUser = cfg.DBUser dbPass = cfg.DBPass dbName = cfg.DBName dbSchema = cfg.DBSchema globalExecutor = exec pool, err := connectDBDirect(context.Background(), *cfg) if err != nil { fail("DB connect failed: " + err.Error()) exitSetup(1) } globalDB = pool globalControl = newControlPlane("docker", *cfg) ok("Database connected as: " + cfg.DBUser) fmt.Println() cfg.ScripCurrency = scripCurrencyID } // ── local setup flow ────────────────────────────────────────────────────────── func runLocalSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) { fmt.Println("Database connection:") cfg.DBHost = ask("DB host", "127.0.0.1") cfg.DBPort = dbPort portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort)) _, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort) cfg.DBUser = ask("DB user", envOr("DB_USER", "dune")) cfg.DBPass = ask("DB password", "") if cfg.DBPass == "" { fmt.Fprintln(os.Stderr, "Database password is required. Aborting.") exitSetup(1) } cfg.DBName = ask("DB name", envOr("DB_NAME", "dune")) cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune")) fmt.Println() fmt.Println("Server control commands (optional — leave blank to skip):") cfg.CmdStart = ask("Start command (e.g. 'amp start dune')", "") cfg.CmdStop = ask("Stop command", "") cfg.CmdRestart = ask("Restart command", "") cfg.CmdStatus = ask("Status command", "") fmt.Println() fmt.Println("Connecting to database...") dbHost = cfg.DBHost dbPort = cfg.DBPort dbUser = cfg.DBUser dbPass = cfg.DBPass dbName = cfg.DBName dbSchema = cfg.DBSchema exec := &localExecutor{} globalExecutor = exec pool, err := connectDBDirect(context.Background(), *cfg) if err != nil { fail("DB connect failed: " + err.Error()) exitSetup(1) } globalDB = pool globalControl = newControlPlane("local", *cfg) ok("Database connected as: " + cfg.DBUser) fmt.Println() cfg.ScripCurrency = scripCurrencyID } // ── amp setup flow ──────────────────────────────────────────────────────────── type ampSetupDefaults struct { instance string user string topology string selected *ampInstance } func detectAmpSetupDefaults(ask func(string, string) string) ampSetupDefaults { d := ampSetupDefaults{instance: "DuneAwakening01", user: "amp", topology: "container"} instances, detectedUser, _ := detectAmpInstances() if len(instances) == 0 { return d } fmt.Printf("Detected %d AMP instance(s):\n", len(instances)) for i, inst := range instances { fmt.Printf(" %d) %s\n", i+1, summarizeInstance(inst)) } choice := 1 if len(instances) > 1 { pickStr := ask("Pick instance [1]", "1") if n, err := strconv.Atoi(strings.TrimSpace(pickStr)); err == nil && n >= 1 && n <= len(instances) { choice = n } } d.selected = &instances[choice-1] d.instance = d.selected.Name if d.selected.InContainer { d.topology = "container" } else { d.topology = "native" } if detectedUser != "" { d.user = detectedUser } fmt.Println() return d } // runAmpSetup configures the AMP control plane (CubeCoders AMP). AMP supports // two deployment topologies — game server in a podman container, or running // natively on the host as the AMP user. All paths/names are configurable. // // The wizard auto-detects AMP instances via `ampinstmgr -l` when available // and pre-fills prompts with discovered values. Falls back to historical // hardcoded defaults (DuneAwakening01, /AMP/duneawakening/...) when // detection isn't possible — every prompt is still overridable. func runAmpSetup(ask func(string, string) string, ok, fail func(string), cfg *appConfig) { d := detectAmpSetupDefaults(ask) defaultInstance := d.instance defaultUser := d.user defaultTopology := d.topology selected := d.selected fmt.Println("AMP instance:") cfg.AmpInstance = ask("Instance name (ampinstmgr instance)", defaultInstance) cfg.AmpUser = ask("OS user that runs AMP", defaultUser) fmt.Println() fmt.Println("AMP topology:") fmt.Println(" container — game server runs inside the AMP container (podman or docker)") fmt.Println(" native — game server runs directly on the host as the AMP user") topology := ask("Topology [container/native]", defaultTopology) useContainer := topology != "native" cfg.AmpUseContainer = &useContainer // ── Game install root: try to probe the container instead of hardcoding // `/AMP/duneawakening`. Falls back to the historical default if the // probe can't answer (container not running, non-standard layout, etc.) gameRoot := "/AMP/duneawakening" if useContainer && selected != nil && selected.InContainer { probeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) container := "AMP_" + cfg.AmpInstance if root, _ := probeGameRoot(probeCtx, cfg.AmpUser, container); root != "" { gameRoot = root fmt.Printf("Detected game install root: %s\n", gameRoot) } cancel() } gameFolder := filepath.Base(gameRoot) // e.g. "duneawakening" defaultLogPath := gameRoot + "/logs" defaultIniDir := fmt.Sprintf("/home/%s/.ampdata/instances/%s/%s/server/state", cfg.AmpUser, cfg.AmpInstance, gameFolder) // The game's stock Default*.ini ship under the extracted game-server tree, // the same in container and native topologies. defaultDefaultIniDir := gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Config" if !useContainer { // Native topology: game files live under /extracted/... defaultLogPath = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Logs" defaultIniDir = gameRoot + "/extracted/game-server/home/dune/server/DuneSandbox/Saved/Config/LinuxServer" } if useContainer { defaultContainer := "AMP_" + cfg.AmpInstance cfg.AmpContainer = ask("Container name", defaultContainer) cfg.AmpContainerRuntime = ask("Container runtime [podman/docker]", "podman") } cfg.AmpLogPath = ask("Log directory", defaultLogPath) cfg.DirectorURL = ask("Battlegroup Director URL (optional)", "http://127.0.0.1:11717") fmt.Println() // INI paths are fully determined by the AMP instance layout, so we derive // them instead of asking. The instance state dir holds UserOverrides.ini // (where dune-admin writes game settings) and ue5-saved/UserSettings (where // runtime auto-discovery finds UserGame.ini / UserEngine.ini). cfg.ServerIniDir = defaultIniDir cfg.DefaultIniDir = defaultDefaultIniDir fmt.Println("INI directories (auto-derived from the AMP instance):") fmt.Printf(" instance state: %s\n", cfg.ServerIniDir) fmt.Printf(" game defaults: %s\n", cfg.DefaultIniDir) fmt.Println() fmt.Println("RabbitMQ broker (used by capture mode AND live RMQ commands):") var defaultBrokerPrefix string if useContainer { runtime := cfg.AmpContainerRuntime if runtime == "" { runtime = "podman" } defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s %s exec %s", cfg.AmpUser, runtime, cfg.AmpContainer) } else { defaultBrokerPrefix = fmt.Sprintf("sudo -i -u %s", cfg.AmpUser) } cfg.BrokerExecPrefix = ask("Broker exec prefix", defaultBrokerPrefix) fmt.Println() fmt.Println("Database connection:") cfg.DBHost = ask("DB host", "127.0.0.1") cfg.DBPort = dbPort portStr := ask(fmt.Sprintf("DB port [%d]", dbPort), fmt.Sprintf("%d", dbPort)) _, _ = fmt.Sscanf(portStr, "%d", &cfg.DBPort) cfg.DBUser = ask("DB user", envOr("DB_USER", "dune")) cfg.DBPass = ask("DB password", "") if cfg.DBPass == "" { fmt.Fprintln(os.Stderr, "Database password is required. Aborting.") exitSetup(1) } cfg.DBName = ask("DB name", envOr("DB_NAME", "dune")) cfg.DBSchema = ask("DB schema", envOr("DB_SCHEMA", "dune")) fmt.Println() fmt.Println("Connecting to database...") dbHost = cfg.DBHost dbPort = cfg.DBPort dbUser = cfg.DBUser dbPass = cfg.DBPass dbName = cfg.DBName dbSchema = cfg.DBSchema exec := &localExecutor{} globalExecutor = &Executor{Executor: exec, ampUser: cfg.AmpUser} pool, err := connectDBDirect(context.Background(), *cfg) if err != nil { fail("DB connect failed: " + err.Error()) exitSetup(1) } globalDB = pool globalControl = newControlPlane("amp", *cfg) ok("Database connected as: " + cfg.DBUser) fmt.Println() fmt.Println("Reminder: dune-admin writes game settings to UserOverrides.ini and engine") fmt.Println("settings to UserEngine.ini as " + cfg.AmpUser + " (AMP owns UserGame.ini itself).") fmt.Println("Example /etc/sudoers.d/dune-admin entry:") fmt.Printf(" %s ALL=(%s) NOPASSWD: /usr/bin/tee %s/UserOverrides.ini, /usr/bin/tee %s/ue5-saved/UserSettings/UserEngine.ini\n", envOr("USER", "dune-admin"), cfg.AmpUser, cfg.ServerIniDir, cfg.ServerIniDir) fmt.Println(" (If the service user does not own the INI files, also add: /usr/bin/cat)") fmt.Println() cfg.ScripCurrency = scripCurrencyID } // ── Market bot setup ─────────────────────────────────────────────────────────── func runMarketBotSetup(ask func(string, string) string, ok func(string), cfg *appConfig) { fmt.Println("Embedded market bot:") enabled := strings.ToLower(strings.TrimSpace(ask("Enable embedded market bot [yes/no]", "yes"))) enabledBool := enabled != "n" && enabled != "no" && enabled != "false" && enabled != "0" cfg.MarketBotEnabled = &enabledBool if !enabledBool { ok("Embedded market bot disabled") // Offer remote proxy config. remoteURL := strings.TrimSpace(ask("Remote bot URL (leave blank to skip)", "")) if remoteURL != "" { cfg.MarketBotRemoteURL = remoteURL cfg.MarketBotRemoteToken = strings.TrimSpace(ask("Remote bot API token", "")) ok("Remote market bot proxy configured") } fmt.Println() return } cfg.MarketBotCacheDB = ask("Bot cache database path", filepath.Join(configDir(), "market-bot-cache.db")) cfg.MarketBotItemData = ask("Bot item-data.json path (optional)", "") parseFloat := func(input string, fallback float64) float64 { v, err := strconv.ParseFloat(strings.TrimSpace(input), 64) if err != nil || v <= 0 { return fallback } return v } parseInt := func(input string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(input)) if err != nil || v <= 0 { return fallback } return v } cfg.MarketBotBuyInt = ask("Buy tick interval", "5m") cfg.MarketBotListInt = ask("List tick interval", "30m") cfg.MarketBotThresh = parseFloat(ask("Buy threshold multiplier", "1.05"), 1.05) cfg.MarketBotMaxBuys = parseInt(ask("Max buys per tick", "50"), 50) ok("Embedded market bot configured") fmt.Println() } // ── Write config ────────────────────────────────────────────────────────────── func writeSetupConfig(ok, fail func(string), cfg appConfig) { cfgDir := configDir() if err := os.MkdirAll(cfgDir, 0700); err != nil { fail("Failed to create config directory: " + err.Error()) exitSetup(1) } cfgData, err := yaml.Marshal(cfg) if err != nil { fail("Failed to marshal config: " + err.Error()) exitSetup(1) } cfgFile := configPath() if err := os.WriteFile(cfgFile, cfgData, 0600); err != nil { fail("Failed to write config: " + err.Error()) exitSetup(1) } ok("Config written to " + cfgFile) fmt.Println() }