Files
corrosion-admin-panel/docs/reference-repos/icehunter/cmd/dune-admin/main.go
Vantz Stockwell 651a35d4be
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s
docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:08:05 -04:00

894 lines
32 KiB
Go

// @title dune-admin API
// @version 1.0
// @description Admin panel API for a Dune Awakening private server.
// @host localhost:8080
// @BasePath /
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
_ "dune-admin/docs"
"gopkg.in/yaml.v3"
"dune-admin/internal/marketbot"
)
// AppVersion is the release version shown to users.
// Populated at build time via -ldflags "-X main.AppVersion=$(VERSION)".
// Falls back to "<VERSION file>-dev" for source builds without ldflags.
var AppVersion = "dev"
// GitCommit and BuildTime are stamped at build time.
var GitCommit = "unknown"
var BuildTime = "unknown"
func init() {
AppVersion = resolveAppVersion(AppVersion, ".")
}
// resolveAppVersion returns ldflagsVersion when it was set by the build pipeline.
// For plain `go build` / `go run` invocations that leave the default "dev", it
// reads the VERSION file from workDir and returns "<version>-dev" so operators
// can still tell which codebase they're running and update notifications work.
func resolveAppVersion(ldflagsVersion, workDir string) string {
if ldflagsVersion != "dev" {
return ldflagsVersion
}
data, err := os.ReadFile(filepath.Join(workDir, "VERSION"))
if err != nil {
return "dev"
}
v := strings.TrimSpace(string(data))
if v == "" {
return "dev"
}
return v + "-dev"
}
// ── config ────────────────────────────────────────────────────────────────────
var (
setupMode bool
cleanMarketMode bool
updateMode bool
reinstallMode bool
sqlQuery string
renderK8SOut string
sshHost string
sshUser string
sshKeyPath string
itemDataPath string
scripCurrencyID int
dbHost string
dbPort int
dbUser string
dbPass string
dbName string
dbSchema string
listenAddr string
controlPlane string
controlNS string
brokerGameAddr string
brokerAdminAddr string
brokerTLS bool
brokerUser string
brokerPass string
backupDir string
serverIniDir string
)
// appConfig mirrors the fields written to ~/.dune-admin/config.yaml.
// json tags match yaml tags so the /api/v1/config endpoint speaks snake_case
// to the frontend without needing a separate DTO.
type appConfig struct {
// Transport — SSH fields. If ssh_host is set all commands + TCP connections
// tunnel through SSH. If omitted everything runs/connects locally.
SSHHost string `yaml:"ssh_host" json:"ssh_host"`
SSHUser string `yaml:"ssh_user" json:"ssh_user"`
SSHKey string `yaml:"ssh_key" json:"ssh_key"`
// Database — always required.
DBHost string `yaml:"db_host" json:"db_host"`
DBPort int `yaml:"db_port" json:"db_port"`
DBUser string `yaml:"db_user" json:"db_user"`
DBPass string `yaml:"db_pass" json:"db_pass"`
DBName string `yaml:"db_name" json:"db_name"`
DBSchema string `yaml:"db_schema" json:"db_schema"`
// Control plane: "kubectl" | "docker" | "local" | "amp"
Control string `yaml:"control" json:"control"`
// kubectl-specific
ControlNamespace string `yaml:"control_namespace" json:"control_namespace"`
// docker-specific — container names
DockerGameserver string `yaml:"docker_gameserver" json:"docker_gameserver"`
DockerBrokerGame string `yaml:"docker_broker_game" json:"docker_broker_game"`
DockerBrokerAdmin string `yaml:"docker_broker_admin" json:"docker_broker_admin"`
DockerDB string `yaml:"docker_db" json:"docker_db"`
// local-specific — configurable shell commands
CmdStart string `yaml:"cmd_start" json:"cmd_start"`
CmdStop string `yaml:"cmd_stop" json:"cmd_stop"`
CmdRestart string `yaml:"cmd_restart" json:"cmd_restart"`
CmdStatus string `yaml:"cmd_status" json:"cmd_status"`
// Broker — optional; if set, notifications and capture are available.
BrokerGameAddr string `yaml:"broker_game_addr" json:"broker_game_addr"`
BrokerAdminAddr string `yaml:"broker_admin_addr" json:"broker_admin_addr"`
BrokerTLS bool `yaml:"broker_tls" json:"broker_tls"`
BrokerUser string `yaml:"broker_user" json:"broker_user"`
BrokerPass string `yaml:"broker_pass" json:"broker_pass"`
// BrokerJWTSecret is the base64-encoded HMAC key used to re-sign
// ServiceAuthTokens for CaptureJWT. Optional override for the baked-in
// default signing key (captureJWTSecretB64).
BrokerJWTSecret string `yaml:"broker_jwt_secret" json:"broker_jwt_secret"`
// BrokerExecPrefix is prepended to all rabbitmqctl calls. Use when the
// broker runs inside a container that isn't managed by the docker control
// plane — e.g. "podman exec AMP_MehDune01" or "docker exec my-broker".
BrokerExecPrefix string `yaml:"broker_exec_prefix" json:"broker_exec_prefix"`
// Backups — optional path accessed via the executor.
BackupDir string `yaml:"backup_dir" json:"backup_dir"`
// ServerIniDir is the directory containing UserGame.ini and UserOverrides.ini.
ServerIniDir string `yaml:"server_ini_dir" json:"server_ini_dir"`
// DefaultIniDir is a local or remote path that contains DefaultGame.ini and
// DefaultEngine.ini — the base layer of the INI hierarchy.
DefaultIniDir string `yaml:"default_ini_dir" json:"default_ini_dir"`
ScripCurrency int `yaml:"scrip_currency" json:"scrip_currency"`
ListenAddr string `yaml:"listen_addr" json:"listen_addr"`
// AMP-specific — used when Control == "amp" (CubeCoders AMP w/ podman).
AmpInstance string `yaml:"amp_instance" json:"amp_instance"`
AmpContainer string `yaml:"amp_container" json:"amp_container"`
AmpUser string `yaml:"amp_user" json:"amp_user"`
AmpLogPath string `yaml:"amp_log_path" json:"amp_log_path"`
AmpUseContainer *bool `yaml:"amp_use_container" json:"amp_use_container"`
// AmpContainerRuntime selects the container CLI used for in-container ops
// (logs/INI/rabbitmqctl) when AmpUseContainer is true: "podman" (default)
// or "docker". Empty → podman, so existing installs are unaffected.
AmpContainerRuntime string `yaml:"amp_container_runtime" json:"amp_container_runtime"`
AmpDataRoot string `yaml:"amp_data_root" json:"amp_data_root"`
// AMP Web API credentials — let dune-admin manage server settings under AMP
// by writing them through AMP's own config API (Core/SetConfig), so they
// survive AMP regenerating the game INIs. The API is the instance ADS,
// reached in-container at 127.0.0.1:<amp_api_port> (default 8081).
AmpAPIUser string `yaml:"amp_api_user" json:"amp_api_user"`
AmpAPIPass string `yaml:"amp_api_pass" json:"amp_api_pass"`
AmpAPIPort int `yaml:"amp_api_port" json:"amp_api_port"`
DirectorURL string `yaml:"director_url" json:"director_url"`
// DB backup tooling (#150). AmpPgBin/AmpPgLib locate the in-container PG17
// pg_dump/pg_restore + their shared libs; empty → validated AMP defaults.
// AmpBackupDir is the host dir for dumps; empty → <configDir>/db-backups.
AmpPgBin string `yaml:"amp_pg_bin" json:"amp_pg_bin"`
AmpPgLib string `yaml:"amp_pg_lib" json:"amp_pg_lib"`
AmpBackupDir string `yaml:"amp_backup_dir" json:"amp_backup_dir"`
// ── Embedded market bot ────────────────────────────────────────────────
// MarketBotEnabled starts the market bot as an in-process goroutine.
// Pointer so we can distinguish "unset" (default-on) from "explicitly false".
MarketBotEnabled *bool `yaml:"market_bot_enabled" json:"market_bot_enabled"`
MarketBotCacheDB string `yaml:"market_bot_cache_db" json:"market_bot_cache_db"`
MarketBotItemData string `yaml:"market_bot_item_data" json:"market_bot_item_data"`
MarketBotState string `yaml:"market_bot_state" json:"market_bot_state"`
MarketBotBuyInt string `yaml:"market_bot_buy_interval" json:"market_bot_buy_interval"`
MarketBotListInt string `yaml:"market_bot_list_interval" json:"market_bot_list_interval"`
MarketBotThresh float64 `yaml:"market_bot_buy_threshold" json:"market_bot_buy_threshold"`
MarketBotMaxBuys int `yaml:"market_bot_max_buys" json:"market_bot_max_buys"`
MarketBotRemoteURL string `yaml:"market_bot_remote_url" json:"market_bot_remote_url"`
MarketBotRemoteToken string `yaml:"market_bot_remote_token" json:"market_bot_remote_token"`
// ── Welcome package ────────────────────────────────────────────────────
// Auto-grants a configured item package to every player once, on first
// login. Defaults OFF — it mutates every player's inventory, so it must be
// explicitly opted into. Bump the version to re-issue to everyone.
WelcomePackageEnabled *bool `yaml:"welcome_package_enabled" json:"welcome_package_enabled"`
WelcomePackageScanSecs int `yaml:"welcome_package_scan_interval_secs" json:"welcome_package_scan_interval_secs"`
WelcomePackageActiveVersion string `yaml:"welcome_package_active_version" json:"welcome_package_active_version"`
WelcomePackages []welcomePackage `yaml:"welcome_packages" json:"welcome_packages"`
// Legacy pre-library fields, migrated into WelcomePackages on load.
WelcomePackageVersion string `yaml:"welcome_package_version,omitempty" json:"welcome_package_version,omitempty"`
WelcomePackageItems []welcomePackageItem `yaml:"welcome_package_items,omitempty" json:"welcome_package_items,omitempty"`
}
// marketBotEnabled returns the effective bot-enabled flag. Missing yaml key →
// default on (so upgrades enable the feature). Explicit false → off.
func marketBotEnabled(cfg appConfig) bool {
if cfg.MarketBotEnabled == nil {
return true
}
return *cfg.MarketBotEnabled
}
// startWelcomePackageScanner opens the ledger store, seeds the live runtime
// config, and starts the scanner goroutine. The goroutine always runs so the
// feature can be toggled on at runtime via the API; each tick is a cheap no-op
// while disabled. Returns a cancel func, or nil if the store could not open.
func startWelcomePackageScanner(_ appConfig) context.CancelFunc {
var store *welcomeStore
if globalStore != nil {
store = newWelcomeStore(globalStore)
} else {
var err error
store, err = openWelcomeStore(filepath.Join(configDir(), "welcome-package.db"))
if err != nil {
fmt.Fprintf(os.Stderr, "welcome-package: store open failed: %v\n", err)
return nil
}
}
welcomeStoreDB = store
// Load runtime from the DB store; seeds from YAML on first boot (migration).
if err := applyWelcomeConfigFromStore(); err != nil {
fmt.Fprintf(os.Stderr, "welcome-package: config load failed: %v\n", err)
}
ctx, cancel := context.WithCancel(context.Background())
go runWelcomePackageScanner(ctx)
return cancel
}
func configDir() string {
// DUNE_ADMIN_CONFIG_DIR allows operators to redirect config to a writable
// path in environments where the home directory is read-only (e.g. K8s with
// a ConfigMap-mounted home dir, or containers with a read-only root fs).
if dir := os.Getenv("DUNE_ADMIN_CONFIG_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return ".dune-admin"
}
return filepath.Join(home, ".dune-admin")
}
func configPath() string {
return filepath.Join(configDir(), "config.yaml")
}
func setEnvIfMissing(key, val string) {
if os.Getenv(key) == "" && val != "" {
_ = os.Setenv(key, val)
}
}
// loadedConfig holds the full parsed config.yaml so provider-specific fields
// (docker_*, cmd_*) remain available to connectAll() even though they have no
// corresponding env var or flag.
var loadedConfig appConfig
// loadConfig reads ~/.dune-admin/config.yaml and falls back to .env in the
// working directory for backward compatibility with existing unzipped-release
// installs.
func loadConfig() {
data, err := os.ReadFile(configPath())
if err == nil {
var cfg appConfig
if yaml.Unmarshal(data, &cfg) == nil {
loadedConfig = cfg
setEnvIfMissing("SSH_HOST", cfg.SSHHost)
setEnvIfMissing("SSH_USER", cfg.SSHUser)
setEnvIfMissing("SSH_KEY", cfg.SSHKey)
setEnvIfMissing("DB_HOST", cfg.DBHost)
if cfg.DBPort != 0 {
setEnvIfMissing("DB_PORT", strconv.Itoa(cfg.DBPort))
}
setEnvIfMissing("DB_USER", cfg.DBUser)
setEnvIfMissing("DB_PASS", cfg.DBPass)
setEnvIfMissing("DB_NAME", cfg.DBName)
setEnvIfMissing("DB_SCHEMA", cfg.DBSchema)
if cfg.ScripCurrency != 0 {
setEnvIfMissing("SCRIP_CURRENCY", strconv.Itoa(cfg.ScripCurrency))
}
setEnvIfMissing("LISTEN_ADDR", cfg.ListenAddr)
setEnvIfMissing("CONTROL", cfg.Control)
setEnvIfMissing("CONTROL_NAMESPACE", cfg.ControlNamespace)
setEnvIfMissing("BROKER_GAME_ADDR", cfg.BrokerGameAddr)
setEnvIfMissing("BROKER_ADMIN_ADDR", cfg.BrokerAdminAddr)
setEnvIfMissing("BROKER_USER", cfg.BrokerUser)
setEnvIfMissing("BROKER_PASS", cfg.BrokerPass)
setEnvIfMissing("BROKER_JWT_SECRET", cfg.BrokerJWTSecret)
setEnvIfMissing("BACKUP_DIR", cfg.BackupDir)
setEnvIfMissing("SERVER_INI_DIR", cfg.ServerIniDir)
detectStaleEnvFile(".")
return
}
}
loadDotEnv()
}
// detectStaleEnvFile warns when a .env file exists in workDir alongside a
// successfully-loaded config.yaml. A stale .env is ignored by dune-admin, but
// values pre-exported into the process environment before startup (e.g. via a
// shell that sourced the old file) can shadow config.yaml and silently break
// features like market-bot control. Returns true when the file is detected.
func detectStaleEnvFile(workDir string) bool {
if _, err := os.Stat(filepath.Join(workDir, ".env")); err != nil {
return false
}
log.Printf("[WARN] stale .env file found in %s", workDir)
log.Printf("[WARN] dune-admin is using %s — .env is ignored.", configPath())
log.Printf("[WARN] However, env vars pre-exported from .env before startup can")
log.Printf("[WARN] shadow config.yaml and silently break features (e.g. market-bot")
log.Printf("[WARN] control). Delete or rename .env and restart to be sure:")
log.Printf("[WARN] mv %s %s.bak", filepath.Join(workDir, ".env"), filepath.Join(workDir, ".env"))
return true
}
func loadDotEnv() {
f, err := os.Open(".env")
if err != nil {
return
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
v = v[1 : len(v)-1]
}
setEnvIfMissing(k, v)
}
}
// envOr returns the environment variable value if set, otherwise def.
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envIntOr(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func init() {
loadConfig()
flag.StringVar(&sshHost, "host", envOr("SSH_HOST", ""), "SSH host:port (if set, all connections tunnel through SSH)")
flag.StringVar(&sshUser, "user", envOr("SSH_USER", "dune"), "SSH user")
flag.StringVar(&sshKeyPath, "key", envOr("SSH_KEY", ""), "SSH private key path (auto-detected if empty)")
flag.StringVar(&itemDataPath, "itemdata", envOr("ITEM_DATA", ""), "Item data JSON path")
flag.IntVar(&scripCurrencyID, "scripcurrency", envIntOr("SCRIP_CURRENCY", 1), "Scrip currency id")
flag.StringVar(&dbHost, "dbhost", envOr("DB_HOST", "127.0.0.1"), "PostgreSQL host or DNS name")
flag.IntVar(&dbPort, "dbport", envIntOr("DB_PORT", 15432), "PostgreSQL port")
flag.StringVar(&dbUser, "dbuser", envOr("DB_USER", "dune"), "PostgreSQL user")
flag.StringVar(&dbPass, "dbpass", envOr("DB_PASS", ""), "PostgreSQL password")
flag.StringVar(&dbName, "dbname", envOr("DB_NAME", "dune"), "PostgreSQL database name")
flag.StringVar(&dbSchema, "schema", envOr("DB_SCHEMA", "dune"), "PostgreSQL schema")
flag.StringVar(&listenAddr, "addr", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address")
flag.StringVar(&controlPlane, "control", envOr("CONTROL", ""), "Control plane: kubectl | docker | local")
flag.StringVar(&controlNS, "control-ns", envOr("CONTROL_NAMESPACE", ""), "Kubernetes namespace (kubectl control plane)")
flag.StringVar(&brokerGameAddr, "broker-game", envOr("BROKER_GAME_ADDR", ""), "mq-game broker address host:port")
flag.StringVar(&brokerAdminAddr, "broker-admin", envOr("BROKER_ADMIN_ADDR", ""), "mq-admin broker address host:port")
flag.StringVar(&brokerUser, "broker-user", envOr("BROKER_USER", ""), "AMQP broker username (required for broker features)")
flag.StringVar(&brokerPass, "broker-pass", envOr("BROKER_PASS", ""), "AMQP broker password (required for broker features)")
flag.StringVar(&backupDir, "backup-dir", envOr("BACKUP_DIR", ""), "Backup directory path")
flag.StringVar(&serverIniDir, "ini-dir", envOr("SERVER_INI_DIR", ""), "Directory containing UserGame.ini / UserOverrides.ini")
flag.BoolVar(&setupMode, "setup", false, "Interactive setup wizard — writes ~/.dune-admin/config.yaml")
flag.BoolVar(&cleanMarketMode, "clean-market", false, "Delete all bot listings (Revy), then exit")
flag.StringVar(&sqlQuery, "sql", "", "Run a SQL query and print results to stdout, then exit")
flag.StringVar(&renderK8SOut, "render-k8s", "", "Render k8s manifest with values from loaded config (path or '-' for stdout)")
flag.BoolVar(&updateMode, "update", false, "Check for and apply the latest release")
flag.BoolVar(&reinstallMode, "reinstall", false, "Re-download and reinstall the current latest release (useful for testing updates)")
}
func resolveKeyPath() string {
if sshKeyPath != "" {
return sshKeyPath
}
home, _ := os.UserHomeDir()
exe, _ := os.Executable()
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(home, ".dune-admin", "sshKey"), // user config dir (package-manager installs)
filepath.Join(exeDir, "sshKey"), // next to the binary (drag-and-drop / unzipped release)
"./sshKey", // working directory fallback
}
if runtime.GOOS == "windows" {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
candidates = append([]string{filepath.Join(localAppData, "DuneSandboxServer", "sshKey")}, candidates...)
}
}
for _, p := range candidates { // #nosec G703 -- paths are hardcoded candidates, not user input
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(home, ".dune-admin", "sshKey")
}
// firstExistingPath returns the first path from candidates where os.Stat
// succeeds, or "" if none exist. It is the shared search-order primitive
// for all data-file resolvers.
func firstExistingPath(candidates []string) string {
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
// resolveDataFilePath returns the path to the named data file by searching
// the standard candidate locations in priority order:
// 1. ~/.dune-admin/<name> — user override
// 2. <exeDir>/<name> — next to the binary (release zip / /app/)
// 3. <exeDir>/../share/dune-admin/<name> — Homebrew pkgshare
// 4. ./<name> — cwd (dev / make dev)
func resolveDataFilePath(name string) string {
home, _ := os.UserHomeDir()
exe, _ := os.Executable()
exeDir := filepath.Dir(exe)
return firstExistingPath([]string{
filepath.Join(home, ".dune-admin", name),
filepath.Join(exeDir, name),
filepath.Join(exeDir, "..", "share", "dune-admin", name), // Homebrew pkgshare
"./" + name,
})
}
func resolveItemDataPath() string {
if itemDataPath != "" {
return itemDataPath
}
return resolveDataFilePath("item-data.json")
}
func resolveTagsDataPath() string {
return resolveDataFilePath("tags-data.json")
}
var tagsData tagsDataFile
func loadTagsData() error {
path := resolveTagsDataPath()
if path == "" {
return fmt.Errorf("tags-data.json not found — contract picker will be empty")
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read tags data %s: %w", path, err)
}
if err := json.Unmarshal(data, &tagsData); err != nil {
return fmt.Errorf("parse tags data %s: %w", path, err)
}
return nil
}
var itemData itemDataFile
func loadItemData() error {
path := resolveItemDataPath()
if path == "" {
return fmt.Errorf("item-data.json not found — item grant features will be broken")
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read item data %s: %w", path, err)
}
var parsed itemDataFile
if err := json.Unmarshal(data, &parsed); err != nil {
return fmt.Errorf("parse item data %s: %w", path, err)
}
normalizedItems := make(map[string]itemRule, len(parsed.Items))
for k, v := range parsed.Items {
normalizedItems[strings.ToLower(k)] = v
}
parsed.Items = normalizedItems
normalizedNames := make(map[string]string, len(parsed.Names))
for k, v := range parsed.Names {
normalizedNames[strings.ToLower(k)] = v
}
parsed.Names = normalizedNames
itemData = parsed
return nil
}
// ── main ──────────────────────────────────────────────────────────────────────
func needsSetup() bool {
// config.yaml takes priority over legacy .env.
if _, err := os.Stat(configPath()); err == nil {
return dbPass == ""
}
if _, err := os.Stat(".env"); err == nil {
return dbPass == ""
}
return true
}
func runSQLMode(query string) error {
if msg, ok := cmdConnect().(msgConnect); ok && msg.err != nil {
return fmt.Errorf("connect: %w", msg.err)
}
if msg, ok := cmdRunSQL(query)().(msgSQL); ok {
if msg.err != nil {
return msg.err
}
fmt.Println(msg.result)
}
return nil
}
// runCleanMarketMode wipes every active Revy listing then exits. Useful as a
// one-shot operation from cron, AMP, or an admin laptop without having to
// spin up the full HTTP server.
func runCleanMarketMode() error {
if err := loadItemData(); err != nil {
return fmt.Errorf("load item data: %w", err)
}
if msg, ok := cmdConnect().(msgConnect); ok && msg.err != nil {
return fmt.Errorf("connect: %w", msg.err)
}
defer closeGlobalConnections()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cacheDB, itemDataForBot, _ := resolveEmbeddedMarketBotPaths(loadedConfig, itemDataPath)
itemDataForBot = usableItemDataPath(itemDataForBot)
inst, err := marketbot.Run(ctx, marketbot.BotConfig{
DBPool: globalDB,
DBHost: dbHost,
DBPort: dbPort,
DBUser: dbUser,
DBPass: dbPass,
DBName: dbName,
DBSchema: dbSchema,
CacheDB: cacheDB,
ItemDataPath: itemDataForBot,
})
if err != nil {
return fmt.Errorf("init market bot: %w", err)
}
// Pause immediately so the tick loop spawned by Run does not race the
// cleanup we are about to perform.
inst.Pause()
orders, items, err := inst.CleanupListings(ctx)
if err != nil {
return fmt.Errorf("cleanup: %w", err)
}
fmt.Printf("market cleanup: deleted %d orders, %d items\n", orders, items)
return nil
}
func runImmediateModes() (handled bool, err error) {
// Explicit -setup flag: reconfigure and exit (don't start server).
if setupMode {
runSetup()
return true, nil
}
if reinstallMode {
runSelfUpdate(true)
return true, nil
}
if updateMode {
runSelfUpdate(false)
return true, nil
}
if sqlQuery != "" {
return true, runSQLMode(sqlQuery)
}
if cleanMarketMode {
return true, runCleanMarketMode()
}
if renderK8SOut != "" {
return true, renderK8SManifest(renderK8SOut)
}
return false, nil
}
func loadRuntimeData() error {
if err := loadItemData(); err != nil {
return err
}
if err := loadTagsData(); err != nil {
return err
}
return nil
}
func setupIfNeeded() bool {
// Auto-run setup wizard when no config exists — setup leaves us connected.
if !needsSetup() {
return false
}
runSetup()
fmt.Println()
fmt.Printf("Starting server on %s...\n", listenAddr)
return true
}
func closeGlobalConnections() {
if globalDB != nil {
globalDB.Close()
}
if globalSSH != nil {
_ = globalSSH.Close()
}
}
func refreshItemTemplates() {
if msg, ok := cmdFetchItemTemplates().(msgItemTemplates); ok {
mergeItemTemplates(msg.templates)
}
}
func connectAndPrimeTemplates(alreadyConnected bool) {
if alreadyConnected {
// Already connected by setup; just populate item templates.
refreshItemTemplates()
return
}
// Connect synchronously (SSH + DB).
if msg, ok := cmdConnect().(msgConnect); ok && msg.err != nil {
fmt.Fprintln(os.Stderr, "connect:", msg.err)
fmt.Fprintln(os.Stderr, "Starting server anyway — use /api/v1/reconnect to retry")
return
}
refreshItemTemplates()
}
func resolveEmbeddedMarketBotPaths(cfg appConfig, fallbackItemDataPath string) (cacheDB string, itemDataForBot string, statePath string) {
cacheDB = cfg.MarketBotCacheDB
if cacheDB == "" {
cacheDB = filepath.Join(configDir(), "market-bot-cache.db")
}
itemDataForBot = cfg.MarketBotItemData
if itemDataForBot == "" {
if fallbackItemDataPath != "" {
itemDataForBot = fallbackItemDataPath
} else {
itemDataForBot = resolveItemDataPath()
}
}
statePath = cfg.MarketBotState
if statePath == "" {
statePath = filepath.Join(configDir(), "market-bot-state.json")
}
return cacheDB, itemDataForBot, statePath
}
// itemDataPathResolvable reports whether path points to (or contains) a readable
// item-data.json, mirroring marketbot.loadCatalog's resolution (a directory is
// resolved to item-data.json inside it).
func itemDataPathResolvable(path string) bool {
if path == "" {
return false
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
path = filepath.Join(path, "item-data.json")
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// usableItemDataPath returns configured if it resolves to a readable
// item-data.json; otherwise it falls back to the standard search locations so a
// stale or mistyped market_bot_item_data (e.g. "optional", #136) doesn't crash
// bot startup with a cryptic "open <path>" error. If item-data.json can't be
// found anywhere either, the original value is returned so loadCatalog surfaces
// a clear not-found error.
func usableItemDataPath(configured string) string {
if itemDataPathResolvable(configured) {
return configured
}
if fb := resolveItemDataPath(); itemDataPathResolvable(fb) {
if configured != "" {
log.Printf("market-bot: item-data path %q not found; falling back to %q", configured, fb)
}
return fb
}
return configured
}
func startEmbeddedMarketBotIfEnabled(cfg appConfig) context.CancelFunc {
if !marketBotEnabled(cfg) {
return nil
}
embeddedBotConfigured = true
botCtx, botCancel := context.WithCancel(context.Background())
cacheDB, itemDataForBot, statePath := resolveEmbeddedMarketBotPaths(cfg, itemDataPath)
itemDataForBot = usableItemDataPath(itemDataForBot)
inst, err := marketbot.Run(botCtx, marketbot.BotConfig{
DBPool: globalDB,
DBHost: dbHost,
DBPort: dbPort,
DBUser: dbUser,
DBPass: dbPass,
DBName: dbName,
DBSchema: dbSchema,
CacheDB: cacheDB,
StatePath: statePath,
ItemDataPath: itemDataForBot,
BuyInterval: parseDurString(cfg.MarketBotBuyInt, 5*time.Minute),
ListInterval: parseDurString(cfg.MarketBotListInt, 30*time.Minute),
BuyThreshold: cfg.MarketBotThresh,
MaxBuys: cfg.MarketBotMaxBuys,
})
if err != nil {
fmt.Fprintf(os.Stderr, "market-bot: startup failed: %v\n", err)
botCancel()
return nil
}
embeddedBot = inst
return botCancel
}
func main() {
flag.Parse()
handled, err := runImmediateModes()
if handled {
if err != nil {
label := ""
if renderK8SOut != "" {
label = "render-k8s: "
}
if cleanMarketMode {
label = "clean-market: "
}
fmt.Fprintln(os.Stderr, label+err.Error())
os.Exit(1)
}
return
}
if err := loadRuntimeData(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
alreadyConnected := setupIfNeeded()
defer closeGlobalConnections()
connectAndPrimeTemplates(alreadyConnected)
closeStore := initUnifiedStoreOnce()
defer closeStore()
sessionCancel := startSessionTracking()
defer sessionCancel()
if cancel := startEmbeddedMarketBotIfEnabled(loadedConfig); cancel != nil {
globalBotCancel = cancel
defer func() {
if globalBotCancel != nil {
globalBotCancel()
}
}()
}
if loadedConfig.MarketBotRemoteURL != "" {
remoteBotProxy = newRemoteBotClient(loadedConfig.MarketBotRemoteURL, loadedConfig.MarketBotRemoteToken)
}
globalWelcomeCancel = startWelcomePackageScanner(loadedConfig)
defer stopWelcomeScanner()
// Scheduled restarts (#145): load persisted config + run the scheduler for
// the process lifetime (independent of the welcome scanner's lifecycle).
loadScheduledRestartConfig()
go runRestartScheduler(context.Background())
// Scheduled DB backups (#150): same lifecycle as scheduled restarts.
loadScheduledBackupConfig()
go runBackupScheduler(context.Background())
// Web interfaces (#155): load the operator-configured Server Health links.
loadWebInterfaces()
initLocationStore()
initGivePacksStore()
startServer(listenAddr)
}
// initLocationStore opens (or creates) the persistent location store and sets
// globalLocationStore. A failure is non-fatal — the store guard in each handler
// surfaces a 503 for the affected endpoints.
func initLocationStore() {
var s *locationStore
if globalStore != nil {
s = newLocationStore(globalStore)
if err := s.seedIfEmpty(); err != nil {
fmt.Fprintf(os.Stderr, "location store seed: %v\n", err)
}
} else {
var err error
s, err = openLocationStore(filepath.Join(configDir(), "locations.db"))
if err != nil {
fmt.Fprintf(os.Stderr, "location store: %v (using empty list)\n", err)
return
}
}
globalLocationStore = s
}
// initGivePacksStore opens (or creates) the give-packs SQLite store and seeds
// it from the embedded default packs.json snapshot on first boot. A failure is
// non-fatal — handlers guard for a nil store and return 503.
func initGivePacksStore() {
var s *givePacksStore
if globalStore != nil {
s = newGivePacksStore(globalStore)
} else {
var err error
s, err = openGivePacksStore(filepath.Join(configDir(), "give-packs.db"))
if err != nil {
fmt.Fprintf(os.Stderr, "give-packs store: %v (using empty packs)\n", err)
return
}
}
givePacksStoreDB = s
loaded, _, ok, err := s.loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "give-packs store load: %v\n", err)
return
}
// Seed from the embedded default snapshot on first boot or when the flag
// was never set. Once base_packs_loaded=true, no seeding ever happens again.
if !ok || !loaded {
if seedErr := seedGivePacks(); seedErr != nil {
fmt.Fprintf(os.Stderr, "give-packs seed: %v\n", seedErr)
}
}
}
// globalWelcomeCancel stops the welcome-package scanner goroutine on shutdown.
var globalWelcomeCancel context.CancelFunc
// stopWelcomeScanner cancels the welcome-package scanner if it is running.
func stopWelcomeScanner() {
if globalWelcomeCancel != nil {
globalWelcomeCancel()
}
}
// embeddedBot holds the live market bot instance when market_bot_enabled=true.
// Nil when bot is disabled.
var embeddedBot *marketbot.Instance
// embeddedBotConfigured is true whenever the server config has market_bot_enabled=true,
// regardless of whether the bot instance is currently running. Never reset to false.
var embeddedBotConfigured bool
// globalBotCancel cancels the embedded bot's context, stopping it cleanly.
// Set by startEmbeddedMarketBotIfEnabled; nil when no bot is running.
var globalBotCancel context.CancelFunc
// remoteBotProxy forwards /api/v1/market-bot/* to a remote bot when set.
// Takes precedence when embeddedBot is nil.
var remoteBotProxy *remoteBotClient