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>
894 lines
32 KiB
Go
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
|