docs(reference): import Dune: Awakening server-manager references
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

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>
This commit is contained in:
Vantz Stockwell
2026-06-11 21:08:05 -04:00
parent 0715492ddf
commit 651a35d4be
1334 changed files with 238971 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/dune-admin ./cmd/dune-admin"
bin = "./tmp/dune-admin"
full_bin = "./tmp/dune-admin"
include_ext = ["go"]
exclude_dir = ["web", "bin", "tmp", "db-routines", "db-snapshots", "docs", "deploy"]
exclude_file = ["*_test.go"]
exclude_regex = ["_test\\.go$"]
delay = 1000
kill_delay = "0s"
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
log = "build-errors.log"
[log]
time = false
main_only = true
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

View File

@@ -0,0 +1,9 @@
web/node_modules
web/dist
dune-admin
dune-admin-linux
db-snapshots
.env
sshKey
sshKey.pub
*.log

View File

@@ -0,0 +1,23 @@
# dune-admin configuration
# Copy to .env and run: go run . -setup (or: ./dune-admin -setup)
# The setup wizard fills this in automatically.
# Connection mode: 'direct' (no SSH, connect to DB directly) or 'ssh' (tunnel through VM)
CONNECTION_MODE=direct
# ── Direct mode settings ──────────────────────────────────────────────────────
# Used when CONNECTION_MODE=direct — connect to PostgreSQL directly.
DB_HOST=127.0.0.1
DB_PORT=15432
DB_USER=dune
DB_PASS=dune
DB_NAME=dune
DB_SCHEMA=dune
# ── SSH mode settings (optional — only needed for CONNECTION_MODE=ssh) ────────
# SSH_HOST=192.168.0.72:22
# SSH_USER=dune
# SSH_KEY=./sshKey
SCRIP_CURRENCY=1
LISTEN_ADDR=:9090

View File

@@ -0,0 +1,15 @@
# Force LF line endings for files where line endings matter at runtime or for
# tooling correctness. Without this, contributors who clone on Windows with
# the default `core.autocrlf=true` get CRLF in the working tree, which makes
# `gofmt -l .` (and therefore `make fmt-check` / the pre-commit hook) report
# every Go file as unformatted on first checkout.
*.go text eol=lf
*.sh text eol=lf
*.bash text eol=lf
Makefile text eol=lf
.githooks/* text eol=lf
# Treat lockfiles as binary so diffs don't churn on encoding changes.
go.sum -text
web/pnpm-lock.yaml -text

View File

@@ -0,0 +1,145 @@
#!/bin/bash
# Pre-commit hook: auto-fix formatting and fast checks
# Skip with: git commit --no-verify
#
# This hook is contextual - it only runs checks for file types that are staged.
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Detect which file types are staged
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
HAS_GO_FILES=$(echo "$STAGED_FILES" | grep -E '\.go$' || true)
HAS_MD_FILES=$(echo "$STAGED_FILES" | grep -E '\.md$' || true)
HAS_TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
# Exit early if no relevant files are staged
if [ -z "$HAS_GO_FILES" ] && [ -z "$HAS_MD_FILES" ] && [ -z "$HAS_TS_FILES" ]; then
echo -e "${GREEN}✓${NC} No Go, Markdown, or TypeScript files staged, skipping checks"
exit 0
fi
# Header
echo ""
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║${NC} ${BOLD}PRE-COMMIT CHECKS${NC} ${BLUE}║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
echo ""
# Show what's being checked
if [ -n "$HAS_GO_FILES" ]; then
echo -e "${YELLOW}Go files staged:${NC} $(echo "$HAS_GO_FILES" | wc -l | tr -d ' ') file(s)"
fi
if [ -n "$HAS_MD_FILES" ]; then
echo -e "${YELLOW}Markdown files staged:${NC} $(echo "$HAS_MD_FILES" | wc -l | tr -d ' ') file(s)"
fi
if [ -n "$HAS_TS_FILES" ]; then
echo -e "${YELLOW}TypeScript files staged:${NC} $(echo "$HAS_TS_FILES" | wc -l | tr -d ' ') file(s)"
fi
echo ""
# Track if any check fails
FAILED=0
# Go checks (only if .go files are staged)
if [ -n "$HAS_GO_FILES" ]; then
# 1. Auto-format Go code
echo -e "${YELLOW}▶${NC} Auto-formatting Go code..."
if make fmt > /tmp/hook-output.txt 2>&1; then
# Check if any files were modified
if git diff --name-only | grep -q "\.go$"; then
echo -e "${GREEN}✓${NC} Go code formatted (changes staged)"
git add -u # Stage modified tracked files
else
echo -e "${GREEN}✓${NC} Go code already formatted"
fi
else
echo -e "${RED}✗${NC} Go formatting failed"
cat /tmp/hook-output.txt
FAILED=1
fi
# 2. Run go vet
echo -e "${YELLOW}▶${NC} Running go vet (static analysis)..."
if make vet > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} go vet passed"
else
echo -e "${RED}✗${NC} go vet failed"
cat /tmp/hook-output.txt
FAILED=1
fi
# 3. Run golangci-lint
echo -e "${YELLOW}▶${NC} Running golangci-lint..."
if make lint-go > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} golangci-lint passed"
else
echo -e "${RED}✗${NC} golangci-lint failed"
cat /tmp/hook-output.txt
FAILED=1
fi
# 4. Run gosec (high-severity security scan) — sprung here so findings
# surface at commit time, not only at pre-push.
echo -e "${YELLOW}▶${NC} Running gosec (security scan)..."
if make gosec > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} gosec passed"
else
echo -e "${RED}✗${NC} gosec failed"
cat /tmp/hook-output.txt
FAILED=1
fi
fi
# Markdown checks (only if .md files are staged)
if [ -n "$HAS_MD_FILES" ]; then
echo -e "${YELLOW}▶${NC} Running markdownlint..."
if make lint-md > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} markdownlint passed"
else
echo -e "${RED}✗${NC} markdownlint failed"
cat /tmp/hook-output.txt
FAILED=1
fi
fi
# TypeScript checks (only if .ts/.tsx files are staged)
if [ -n "$HAS_TS_FILES" ]; then
echo -e "${YELLOW}▶${NC} Running ESLint (TypeScript)..."
if (cd web && pnpm lint) > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} ESLint passed"
else
echo -e "${RED}✗${NC} ESLint failed"
cat /tmp/hook-output.txt
FAILED=1
fi
echo -e "${YELLOW}▶${NC} Running TypeScript type check (tsc --noEmit)..."
if make tsc > /tmp/hook-output.txt 2>&1; then
echo -e "${GREEN}✓${NC} TypeScript type check passed"
else
echo -e "${RED}✗${NC} TypeScript type check failed"
cat /tmp/hook-output.txt
FAILED=1
fi
fi
echo ""
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
if [ $FAILED -eq 0 ]; then
echo -e "${BLUE}║${NC} ${GREEN}${BOLD}✓ ALL CHECKS PASSED!${NC} ${BLUE}║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
exit 0
else
echo -e "${BLUE}║${NC} ${RED}${BOLD}✗ CHECKS FAILED${NC} ${BLUE}║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
echo -e "${YELLOW}Fix the issues above or use --no-verify to skip${NC}"
exit 1
fi

View File

@@ -0,0 +1,206 @@
#!/bin/bash
# Pre-push hook: full verification including security checks
# Skip with: git push --no-verify
#
# This hook is contextual - it only runs checks for file types in the commits being pushed.
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Get the commits being pushed
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null || echo "")
if [ -z "$UPSTREAM" ]; then
# No upstream, compare against main/master
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
CHANGED_FILES=$(git diff --name-only "origin/${BASE_BRANCH}...HEAD" 2>/dev/null || git diff --name-only HEAD~10..HEAD 2>/dev/null || echo "")
else
# Compare against upstream
CHANGED_FILES=$(git diff --name-only @{u}..HEAD 2>/dev/null || echo "")
fi
# Detect which file types are in the commits being pushed
HAS_GO_FILES=$(echo "$CHANGED_FILES" | grep -E '\.go$' || true)
HAS_MD_FILES=$(echo "$CHANGED_FILES" | grep -E '\.md$' || true)
HAS_TS_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx)$' || true)
# Exit early if no relevant files in the push
if [ -z "$HAS_GO_FILES" ] && [ -z "$HAS_MD_FILES" ] && [ -z "$HAS_TS_FILES" ]; then
echo -e "${GREEN}✓${NC} No Go, Markdown, or TypeScript files in push, skipping checks"
exit 0
fi
# Header
echo ""
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║${NC} ${BOLD}PRE-PUSH VERIFICATION${NC} ${BLUE}║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
echo ""
# Show what's being pushed
echo -e "${YELLOW}Branch:${NC} ${CURRENT_BRANCH}"
echo -e "${YELLOW}Commits:${NC}"
if [ -n "$UPSTREAM" ]; then
git log --oneline @{u}.. 2>/dev/null | head -5 | sed 's/^/ /' || echo " (new branch)"
else
echo " (new branch)"
fi
echo ""
# Show what's being checked
if [ -n "$HAS_GO_FILES" ]; then
GO_COUNT=$(echo "$HAS_GO_FILES" | wc -l | tr -d ' ')
echo -e "${YELLOW}Go files changed:${NC} ${GO_COUNT} file(s)"
fi
if [ -n "$HAS_MD_FILES" ]; then
MD_COUNT=$(echo "$HAS_MD_FILES" | wc -l | tr -d ' ')
echo -e "${YELLOW}Markdown files changed:${NC} ${MD_COUNT} file(s)"
fi
if [ -n "$HAS_TS_FILES" ]; then
TS_COUNT=$(echo "$HAS_TS_FILES" | wc -l | tr -d ' ')
echo -e "${YELLOW}TypeScript files changed:${NC} ${TS_COUNT} file(s)"
fi
echo ""
# Track if any check fails
FAILED=0
START_TIME=$(date +%s)
# Count total checks to run
TOTAL_CHECKS=0
if [ -n "$HAS_GO_FILES" ]; then
TOTAL_CHECKS=$((TOTAL_CHECKS + 6)) # fmt-check, vet, lint-go, gosec, vulncheck, test-race
fi
if [ -n "$HAS_MD_FILES" ]; then
TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) # markdownlint
fi
if [ -n "$HAS_TS_FILES" ]; then
TOTAL_CHECKS=$((TOTAL_CHECKS + 2)) # eslint + tsc
fi
CURRENT_CHECK=0
# Helper function to run a check with timing
run_check() {
local name=$1
local command=$2
local start=$(date +%s)
CURRENT_CHECK=$((CURRENT_CHECK + 1))
echo -e "${CYAN}[${CURRENT_CHECK}/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} ${name}..."
if eval "$command" > /tmp/hook-output.txt 2>&1; then
local duration=$(($(date +%s) - start))
echo -e " ${GREEN}✓${NC} ${name} passed ${CYAN}(${duration}s)${NC}"
return 0
else
echo -e " ${RED}✗${NC} ${name} failed"
echo ""
cat /tmp/hook-output.txt
echo ""
return 1
fi
}
# Go checks (only if .go files are in the push)
if [ -n "$HAS_GO_FILES" ]; then
# 1. Format check
run_check "Checking Go code formatting" "make fmt-check" || FAILED=1
# 2. Static analysis (go vet)
run_check "Running static analysis (go vet)" "make vet" || FAILED=1
# 3. Go linting
run_check "Running golangci-lint" "make lint-go" || FAILED=1
# 4. Security scan (gosec)
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Running security scan (gosec)..."
CURRENT_CHECK=$((CURRENT_CHECK + 1))
START=$(date +%s)
if make gosec > /tmp/hook-output.txt 2>&1; then
DURATION=$(($(date +%s) - START))
ISSUES=$(grep -oE "Issues : [0-9]+" /tmp/hook-output.txt | grep -oE "[0-9]+" || echo "0")
NOSEC=$(grep -oE "Nosec : [0-9]+" /tmp/hook-output.txt | grep -oE "[0-9]+" || echo "0")
echo -e " ${GREEN}✓${NC} Security scan passed - ${ISSUES} issues, ${NOSEC} suppressed ${CYAN}(${DURATION}s)${NC}"
else
echo -e " ${RED}✗${NC} Security scan failed"
echo ""
cat /tmp/hook-output.txt
echo ""
FAILED=1
fi
# 5. Vulnerability check (govulncheck)
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Checking for vulnerabilities (govulncheck)..."
CURRENT_CHECK=$((CURRENT_CHECK + 1))
START=$(date +%s)
if make vulncheck > /tmp/hook-output.txt 2>&1; then
DURATION=$(($(date +%s) - START))
if grep -q "No vulnerabilities found" /tmp/hook-output.txt; then
echo -e " ${GREEN}✓${NC} No vulnerabilities found ${CYAN}(${DURATION}s)${NC}"
else
echo -e " ${GREEN}✓${NC} Vulnerability check completed ${CYAN}(${DURATION}s)${NC}"
fi
else
echo -e " ${RED}✗${NC} Vulnerability check failed"
echo ""
cat /tmp/hook-output.txt
echo ""
FAILED=1
fi
# 6. Tests with race detector and coverage
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Running tests with race detector..."
CURRENT_CHECK=$((CURRENT_CHECK + 1))
START=$(date +%s)
if make test-race > /tmp/hook-output.txt 2>&1; then
DURATION=$(($(date +%s) - START))
PASSED=$(grep -c "^ok" /tmp/hook-output.txt || echo "0")
echo -e " ${GREEN}✓${NC} All tests passed (${PASSED} packages) ${CYAN}(${DURATION}s)${NC}"
else
echo -e " ${RED}✗${NC} Tests failed"
cat /tmp/hook-output.txt
FAILED=1
fi
fi
# Markdown checks (only if .md files are in the push)
if [ -n "$HAS_MD_FILES" ]; then
run_check "Running markdownlint" "make lint-md" || FAILED=1
fi
# TypeScript checks (only if .ts/.tsx files are in the push)
if [ -n "$HAS_TS_FILES" ]; then
run_check "Running ESLint (TypeScript)" "(cd web && pnpm lint)" || FAILED=1
run_check "TypeScript type check (tsc --noEmit)" "make tsc" || FAILED=1
fi
# Summary
TOTAL_TIME=$(($(date +%s) - START_TIME))
echo ""
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
if [ $FAILED -eq 0 ]; then
echo -e "${BLUE}║${NC} ${GREEN}${BOLD}✓ ALL CHECKS PASSED!${NC} ${BLUE}║${NC}"
printf "${BLUE}║${NC} ${CYAN}Total time: %-37s${NC}${BLUE}║${NC}\n" "${TOTAL_TIME}s"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${GREEN}Ready to push!${NC}"
echo ""
exit 0
else
echo -e "${BLUE}║${NC} ${RED}${BOLD}✗ CHECKS FAILED${NC} ${BLUE}║${NC}"
printf "${BLUE}║${NC} ${CYAN}Total time: %-37s${NC}${BLUE}║${NC}\n" "${TOTAL_TIME}s"
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${RED}Fix the issues above before pushing.${NC}"
echo -e "${YELLOW}Or skip with: ${NC}git push --no-verify"
echo ""
exit 1
fi

View File

@@ -0,0 +1,38 @@
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
working-directory: web
- run: pnpm run build
working-directory: web
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_CDN_BASE_URL: ${{ vars.VITE_CDN_BASE_URL }}
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy dist --project-name=dune-admin
workingDirectory: web

View File

@@ -0,0 +1,120 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
sast:
name: SAST (gosec)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run gosec
run: make gosec
sca:
name: SCA (govulncheck + pnpm audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: pnpm/action-setup@v4
- name: Run govulncheck
run: make vulncheck
- name: Run pnpm audit
run: make pnpm-audit
release:
name: Release
runs-on: macos-latest # macOS so codesign is available for ad-hoc signing
needs: [sast, sca]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: "1.26.4"
cache: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: web/pnpm-lock.yaml
- name: Build frontend (embeds into binary)
run: make web
- name: Install syft (SBOMs)
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
- uses: goreleaser/goreleaser-action@v7
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT with `repo` scope on Icehunter/homebrew-tap.
# If unset, the brews publisher will fail; configure before
# cutting a release that should land in the tap.
# PAT with `repo` scope on Icehunter/scoop-bucket.
# PAT with `repo` scope and a fork of microsoft/winget-pkgs
# at Icehunter/winget-pkgs.
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
deploy-frontend:
name: Deploy Frontend to Cloudflare Pages
runs-on: ubuntu-latest
needs: release
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
working-directory: web
- run: pnpm run build
working-directory: web
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_CDN_BASE_URL: ${{ vars.VITE_CDN_BASE_URL }}
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy dist --project-name=dune-admin
workingDirectory: web

View File

@@ -0,0 +1,21 @@
name: SAST
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gosec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run gosec
run: make gosec

View File

@@ -0,0 +1,31 @@
name: SCA
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run govulncheck
run: make vulncheck
pnpm-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Run pnpm audit
run: make pnpm-audit

View File

@@ -0,0 +1,24 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test -race ./...

View File

@@ -0,0 +1,58 @@
db-snapshots/
.env
.env.*
!.env.example
settings.local.json
# SSH keys — never commit
sshKey
sshKey.pub
# Browser fetch script — local dev tool, not for sharing
fetch-item-data.js
# Compiled binaries
/bin/
/dune-admin
/dune-admin-linux
/dune-admin.exe
*.exe
# Embedded frontend dist (generated by make web, not committed)
/cmd/dune-admin/dist/
# Tools (downloaded, not part of the project)
repak_win/
repak_win.zip
*.tar.gz
*.tar.xz
# macOS
.DS_Store
**/.DS_Store
blueprint.json
# Local Go build cache (used when system cache is unavailable)
.go-cache/
.superpowers/
# docs/ — default-private (local notes, scratch).
# Exception: community-facing ADRs, deployment docs, and generated Swagger spec are tracked.
docs/*
!docs/amp-direct-connect/
!docs/adr/
!docs/plans/
!docs/superpowers/
!docs/docs.go
!docs/swagger.json
!docs/swagger.yaml
# Local dev/testing artifacts
local/
deploy/k8s/dune-admin.rendered.yaml
# Reference material / ideas / scratch — not for sharing
Samples-Ideas/
tmp/dune-admin
.worktrees

View File

@@ -0,0 +1,3 @@
# Functions excluded from the gocognit complexity gate.
# Each entry has a GitHub issue tracking the refactor work.
# Format: FuncName file:line score issue-url

View File

@@ -0,0 +1,6 @@
version: "2"
linters:
exclusions:
paths:
- web/node_modules

View File

@@ -0,0 +1,216 @@
version: 2
project_name: dune-admin
before:
hooks:
- go mod tidy
builds:
- id: dune-admin
main: ./cmd/dune-admin
binary: dune-admin
env:
- CGO_ENABLED=0
flags:
- -tags=embed
ldflags:
- -s -w
- -X main.AppVersion={{.Version}}
- -X main.GitCommit={{.Commit}}
- -X main.BuildTime={{.Date}}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
# Combine darwin amd64 + arm64 into a single fat binary so `brew install`
# gets one artifact that runs natively on both Intel and Apple Silicon.
universal_binaries:
- id: dune-admin-darwin-universal
ids:
- dune-admin
replace: true
name_template: dune-admin
# Ad-hoc sign the macOS universal binary. Free, no Apple Developer
# account required. Satisfies the Apple Silicon "must be signed"
# requirement; Gatekeeper still won't run a notarization check because
# Homebrew's curl-based download doesn't set the quarantine xattr.
signs:
- id: macos-adhoc
ids:
- dune-admin-darwin-universal
cmd: codesign
args:
- "-s"
- "-"
- "--force"
- "--preserve-metadata=entitlements,requirements,flags,runtime"
- "${artifact}"
artifacts: binary
output: true
archives:
- id: dune-admin
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "dune-admin_{{ .Os }}_{{ .Arch }}"
files:
- item-data.json
- quality-data.json
- tags-data.json
- gameplayTags.json
- skillModules.json
- vehicles.json
- cheatScripts.json
- README.md
- .env.example
source:
enabled: true
name_template: "{{ .ProjectName }}_{{ .Version }}_source"
checksum:
name_template: checksums.txt
algorithm: sha256
sboms:
- artifacts: archive
changelog:
use: github
sort: asc
groups:
- title: Features
regexp: "^feat"
order: 0
- title: Bug Fixes
regexp: "^fix"
order: 1
- title: Performance
regexp: "^perf"
order: 2
- title: Other
order: 999
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "Merge pull request"
- "Merge branch"
release:
github:
owner: Icehunter
name: dune-admin
name_template: "{{.ProjectName}} {{.Tag}}"
draft: false
prerelease: auto
# Homebrew formula. GoReleaser renamed the `brews:` key to
# `homebrew_casks:` in v2.13 but that type generates a Homebrew Cask
# (for GUI .app bundles), not a Formula — wrong for a CLI binary.
# `brews:` still generates a proper Formula and is the correct key for
# this use case. The deprecation warning can be ignored until GoReleaser
# provides a first-class Formula key again. Requires RELEASE_TOKEN
# secret (PAT with `repo` scope on Icehunter/homebrew-tap). Skipped on
# prereleases.
brews:
- name: dune-admin
ids:
- dune-admin
repository:
owner: Icehunter
name: homebrew-tap
branch: main
token: "{{ .Env.RELEASE_TOKEN }}"
directory: Formula
homepage: "https://github.com/Icehunter/dune-admin"
description: "Local-first, provider-aware terminal agent (Claude Code wire-compatible)."
license: "MIT"
skip_upload: auto
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "brew: bump {{ .ProjectName }} to {{ .Tag }}"
test: |
system "#{bin}/dune-admin version"
install: |
bin.install "dune-admin"
pkgshare.install "item-data.json"
pkgshare.install "quality-data.json"
pkgshare.install "tags-data.json"
pkgshare.install "gameplayTags.json"
pkgshare.install "skillModules.json"
pkgshare.install "vehicles.json"
pkgshare.install "cheatScripts.json"
# Scoop bucket. Requires RELEASE_TOKEN secret (PAT with `repo` scope on
# Icehunter/scoop-bucket). Windows-only; goreleaser uses the windows
# zip archive.
scoops:
- name: dune-admin
ids:
- dune-admin
repository:
owner: Icehunter
name: scoop-bucket
branch: main
token: "{{ .Env.RELEASE_TOKEN }}"
directory: bucket
homepage: "https://github.com/Icehunter/dune-admin"
description: "Local-first, provider-aware terminal agent (Claude Code wire-compatible)."
license: "MIT"
skip_upload: auto
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "scoop: bump {{ .ProjectName }} to {{ .Tag }}"
# winget manifest. Requires RELEASE_TOKEN secret (PAT with `repo` scope
# and a fork of microsoft/winget-pkgs at Icehunter/winget-pkgs).
# GoReleaser commits the manifest to your fork on the branch
# `dune-admin-{version}` and you (or automation) open the PR upstream.
winget:
- name: dune-admin
ids:
- dune-admin
publisher: Icehunter
publisher_url: "https://github.com/Icehunter"
publisher_support_url: "https://github.com/Icehunter/dune-admin/issues"
package_identifier: Icehunter.dune-admin
short_description: "Local-first, provider-aware terminal agent."
description: "dune-admin is a local-first, provider-aware terminal agent that maintains Claude Code wire compatibility while innovating on behavior and architecture."
license: "MIT"
license_url: "https://github.com/Icehunter/dune-admin/blob/main/LICENSE"
homepage: "https://github.com/Icehunter/dune-admin"
skip_upload: auto
repository:
owner: Icehunter
name: winget-pkgs
branch: "dune-admin-{{.Version}}"
token: "{{ .Env.RELEASE_TOKEN }}"
pull_request:
enabled: true
draft: false
base:
owner: microsoft
name: winget-pkgs
branch: master
commit_author:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "New version: {{ .ProjectName }} {{ .Version }}"

View File

@@ -0,0 +1,13 @@
ignores:
- "web/node_modules/**"
- "node_modules/**"
config:
MD013: false
MD024: false
MD033: false
MD036: false
MD040: false
MD051: false
MD056: false
MD060: false

View File

@@ -0,0 +1,23 @@
MIT License
Copyright (c) 2026 Ryan Wilson
---
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,283 @@
.PHONY: build web go go-embed linux dev dev-server dev-backend dev-web setup deploy-web \
render-k8s render-k8s-stdout k8s-dry-run \
vulncheck gosec pnpm-audit \
test test-race vet fmt fmt-check tsc \
tools docs verify \
version version-patch version-minor version-major
# ── Build ─────────────────────────────────────────────────────────────────────
CMD := ./cmd/dune-admin
PKG := ./...
GO := go
PREFIX ?= /usr/local
COGNIT_TARGET := $(if $(wildcard cmd/dune-admin),./cmd/dune-admin,.)
# On Windows, Make defaults to cmd.exe which can't run POSIX recipes.
# Force bash from Git for Windows so all targets work from any terminal.
ifeq ($(OS),Windows_NT)
SHELL := cmd.exe
.SHELLFLAGS := /C
BIN := bin/dune-admin.exe
LOCAL_BIN := dune-admin.exe
else
BIN := bin/dune-admin
LOCAL_BIN := dune-admin
endif
ifeq ($(OS),Windows_NT)
VERSION ?= $(shell type VERSION 2>NUL || echo dev)
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>NUL || echo unknown)
BUILD_TIME ?= $(shell powershell -NoProfile -Command "[DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')")
else
VERSION ?= $(shell cat VERSION 2>/dev/null || git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
endif
LDFLAGS := -ldflags "-s -w -X main.AppVersion=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)"
# Build frontend + backend binary with embedded SPA.
build: web go-embed
# Build backend binary only (no embedded frontend).
go:
ifeq ($(OS),Windows_NT)
@if not exist bin mkdir bin
$(GO) build -trimpath $(LDFLAGS) -o $(BIN) $(CMD)
@copy /Y "bin\dune-admin.exe" "$(LOCAL_BIN)" >NUL
else
@mkdir -p bin
$(GO) build -trimpath $(LDFLAGS) -o $(BIN) $(CMD)
install -m 0755 $(BIN) ./$(LOCAL_BIN)
endif
# Build backend binary with embedded frontend (requires make web first).
go-embed:
ifeq ($(OS),Windows_NT)
@if not exist bin mkdir bin
$(GO) build -trimpath $(LDFLAGS) -tags embed -o $(BIN) $(CMD)
@copy /Y "bin\dune-admin.exe" "$(LOCAL_BIN)" >NUL
else
@mkdir -p bin
$(GO) build -trimpath $(LDFLAGS) -tags embed -o $(BIN) $(CMD)
install -m 0755 $(BIN) ./dune-admin
endif
# Install the binary system-wide (Linux/macOS only).
install: go
ifeq ($(OS),Windows_NT)
@echo "Use 'make go' to build. Copy $(BIN) to your desired location manually."
else
install -d $(DESTDIR)$(PREFIX)/bin
install -m 0755 $(BIN) $(DESTDIR)$(PREFIX)/bin/dune-admin
endif
linux:
GOOS=linux GOARCH=amd64 $(GO) build -trimpath $(LDFLAGS) -o dune-admin-linux $(CMD)
dev-server:
go run $(CMD)
dev-backend:
ifeq ($(OS),Windows_NT)
go tool github.com/air-verse/air -build.cmd "go build -o ./tmp/dune-admin.exe ./cmd/dune-admin" -build.bin "./tmp/dune-admin.exe" -build.full_bin "./tmp/dune-admin.exe"
else
go tool github.com/air-verse/air
endif
dev-web:
cd web && pnpm dev
dev:
ifeq ($(OS),Windows_NT)
@start "dune-admin-backend" /MIN $(MAKE) dev-backend
-@cd web && node node_modules\vite\bin\vite.js
-@taskkill /F /FI "WINDOWTITLE eq dune-admin-backend*" >NUL 2>&1
-@taskkill /F /IM dune-admin.exe >NUL 2>&1
else
@set -e; \
AIR_PID=; VITE_PID=; \
cleanup() { \
trap - EXIT INT TERM; \
[ -n "$$AIR_PID" ] && kill $$AIR_PID 2>/dev/null || true; \
[ -n "$$VITE_PID" ] && kill $$VITE_PID 2>/dev/null || true; \
[ -n "$$AIR_PID" ] && wait $$AIR_PID 2>/dev/null || true; \
[ -n "$$VITE_PID" ] && wait $$VITE_PID 2>/dev/null || true; \
}; \
trap 'cleanup' EXIT INT TERM; \
$(MAKE) dev-backend & AIR_PID=$$!; \
$(MAKE) dev-web & VITE_PID=$$!; \
set +e; \
while kill -0 $$AIR_PID 2>/dev/null && kill -0 $$VITE_PID 2>/dev/null; do \
sleep 1; \
done; \
if ! kill -0 $$AIR_PID 2>/dev/null; then \
wait $$AIR_PID; status=$$?; \
kill $$VITE_PID 2>/dev/null || true; \
wait $$VITE_PID 2>/dev/null || true; \
else \
wait $$VITE_PID; status=$$?; \
kill $$AIR_PID 2>/dev/null || true; \
wait $$AIR_PID 2>/dev/null || true; \
fi; \
exit $$status
endif
setup:
go run $(CMD) -setup
# ── Web ───────────────────────────────────────────────────────────────────────
web:
cd web && pnpm install --frozen-lockfile && pnpm build
ifeq ($(OS),Windows_NT)
@if exist cmd\dune-admin\dist rmdir /S /Q cmd\dune-admin\dist
@xcopy /E /I /Q web\dist cmd\dune-admin\dist >NUL
else
rm -rf cmd/dune-admin/dist
cp -r web/dist cmd/dune-admin/dist
endif
deploy-web:
cd web && pnpm install --frozen-lockfile && pnpm build && wrangler pages deploy dist --project-name dune-admin
render-k8s:
go run $(CMD) -render-k8s deploy/k8s/dune-admin.rendered.yaml
render-k8s-stdout:
go run $(CMD) -render-k8s -
k8s-dry-run:
@$(MAKE) render-k8s-stdout | kubectl apply --dry-run=client -f -
# ── Test ──────────────────────────────────────────────────────────────────────
test:
go test $(PKG)
test-race:
go test -race $(PKG)
# ── Quality ───────────────────────────────────────────────────────────────────
vet:
go vet $(PKG)
fmt:
go fmt $(PKG)
gofmt -s -w .
fmt-check:
ifeq ($(OS),Windows_NT)
@powershell -NoProfile -Command "if (gofmt -l .) { Write-Host 'Code is not formatted. Run make fmt'; exit 1 }"
else
@test -z "$$(gofmt -l .)" || (echo "Code is not formatted. Run 'make fmt'" && exit 1)
endif
vulncheck:
go tool golang.org/x/vuln/cmd/govulncheck $(PKG)
gocognit:
@echo "Running code complexity analysis with gocognit..."
ifeq ($(OS),Windows_NT)
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -over 15 -ignore "_test|node_modules" $(COGNIT_TARGET) \
> %TEMP%\gocognit-out.txt 2>&1 || (exit /b 0)
@powershell -NoProfile -Command "\
$$ignore = (Get-Content .gocognit-ignore | Where-Object { $$_ -notmatch '^\s*#' -and $$_.Trim() } | ForEach-Object { ($$_ -split '\s+')[0] }); \
$$lines = Get-Content $$env:TEMP\gocognit-out.txt -ErrorAction SilentlyContinue | Where-Object { $$line = $$_; -not ($$ignore | Where-Object { $$line -like \"*$$_*\" }) }; \
if ($$lines) { $$lines | Write-Host; exit 1 }"
else
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -over 15 -ignore "_test|node_modules" $(COGNIT_TARGET) \
> /tmp/gocognit-out.txt 2>&1 || true; \
grep -v '^#' .gocognit-ignore | awk '{print $$1}' > /tmp/gocognit-ignore.txt; \
grep -v -F -f /tmp/gocognit-ignore.txt /tmp/gocognit-out.txt > /tmp/gocognit-new.txt || true; \
if [ -s /tmp/gocognit-new.txt ]; then cat /tmp/gocognit-new.txt; exit 1; fi
endif
gosec:
go tool github.com/securego/gosec/v2/cmd/gosec -severity high -confidence high $(PKG)
pnpm-audit:
cd web && pnpm audit --audit-level=high
tsc:
cd web && pnpm typecheck
lint:
@$(MAKE) lint-go
@$(MAKE) lint-md
lint-go:
@$(GO) tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint run
lint-md:
@npx -y markdownlint-cli2 --fix "**/*.md"
verify:
@$(MAKE) fmt-check
@$(MAKE) vet
@$(MAKE) test-race
@$(MAKE) vulncheck
@$(MAKE) lint
@$(MAKE) gocognit
@echo "All verification checks passed!"
# ── Docs ──────────────────────────────────────────────────────────────────────
docs:
$(GO) tool github.com/swaggo/swag/cmd/swag init -g cmd/dune-admin/main.go -o docs
# ── Tools ─────────────────────────────────────────────────────────────────────
tools:
@echo "Caching dev tools (versions pinned in go.mod)..."
@$(GO) tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint --version || true
@$(GO) tool github.com/air-verse/air -v || true
@$(GO) tool golang.org/x/vuln/cmd/govulncheck -version || true
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -version || true
@$(GO) tool github.com/securego/gosec/v2/cmd/gosec --version || true
@echo "Done!"
# Print current version.
version:
@echo $(VERSION)
# Setup git hooks
hooks:
@git config core.hooksPath .githooks
@echo "Git hooks configured!"
# Bump patch version (1.0.0 → 1.0.1), commit, tag, and push — triggers release workflow.
version-patch:
@V=$$(cat VERSION); \
MAJOR=$$(echo $$V | cut -d. -f1); \
MINOR=$$(echo $$V | cut -d. -f2); \
PATCH=$$(echo $$V | cut -d. -f3); \
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
echo $$NEW > VERSION; \
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
git push && git push origin "v$$NEW"; \
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"
# Bump minor version (1.0.0 → 1.1.0), commit, tag, and push — triggers release workflow.
version-minor:
@V=$$(cat VERSION); \
MAJOR=$$(echo $$V | cut -d. -f1); \
MINOR=$$(echo $$V | cut -d. -f2); \
NEW="$$MAJOR.$$((MINOR + 1)).0"; \
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
echo $$NEW > VERSION; \
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
git push && git push origin "v$$NEW"; \
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"
# Bump major version (1.0.0 → 2.0.0), commit, tag, and push — triggers release workflow.
version-major:
@V=$$(cat VERSION); \
MAJOR=$$(echo $$V | cut -d. -f1); \
NEW="$$((MAJOR + 1)).0.0"; \
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
echo $$NEW > VERSION; \
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
git push && git push origin "v$$NEW"; \
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"

View File

@@ -0,0 +1,332 @@
# dune-admin
<img width="2172" height="724" alt="image" src="https://github.com/user-attachments/assets/facd62b8-3c06-4f92-8d2c-cf06b46e983e" />
Web-based admin panel for a Dune Awakening private server. Works against any deployment topology: CubeCoders AMP (podman or docker), k3s/k8s over SSH, Docker containers, or a bare-metal install.
---
## 🙏 Thank You
**[@dakkinbyte](https://github.com/dakkinbyte)** deserves special recognition for substantial ongoing contributions: bug fixes, feature work across the dashboard, issue triage, and keeping the project moving. The breadth and consistency of that work has made a real difference.
---
A huge thank you to **[@adainrivers](https://github.com/adainrivers)** and the [**dune-dedicated-server-manager**](https://github.com/adainrivers/dune-dedicated-server-manager) project.
The RabbitMQ server command integration in dune-admin (the envelope format, auth token, AMQP publish path, and the complete catalogue of working server commands) was made possible by the research and live-testing work done in that project. Without it, we would not have known which commands actually work over MQ, what the correct field names are, or that the outer envelope must be base64-encoded before publishing via `rabbitmqctl eval`.
If you run a Dune Awakening private server, check out their project, a full-featured dedicated server manager with a Tauri desktop frontend.
---
## Quick install
On a fresh Ubuntu 22.04 / 24.04 host with passwordless sudo and your game-server stack already running:
```bash
curl -fsSL https://raw.githubusercontent.com/Icehunter/dune-admin/main/scripts/install.sh \
| bash
```
The script installs the build toolchain (Go 1.26, Node 22 LTS, pnpm 10.28, build-essential), clones the source, builds the binary and SPA, and installs them into `/opt/dune-admin/`. It refuses to overwrite a running service and leaves `.prev` backups for one-step rollback. See `--help` for flags (`--branch`, `--install-dir`, `--service-user`, `--patches-dir`, `--no-patches`).
When the script finishes it prints the next manual steps: run the setup wizard, apply the sudoers entry it generates, drop a systemd unit, and start the service.
For development or non-Ubuntu hosts, see [Manual build](#manual-build).
---
## Providers
Pick the provider that matches your game-server topology. Each guide covers prerequisites, wizard answers, and provider-specific config keys.
| Provider | Use when | Guide |
|----------|----------|-------|
| **amp** | Game server runs under CubeCoders AMP (host, or a podman/docker container) | [SETUP_AMP.md](SETUP_AMP.md) |
| **kubectl** | Game server runs in k3s/K8s on a remote VM | [SETUP_KUBECTL.md](SETUP_KUBECTL.md) |
| **docker** | Game server runs as Docker containers (compose or standalone) | [SETUP_DOCKER.md](SETUP_DOCKER.md) |
| **local** | Game server runs on the same machine (bare metal, LGSM, custom) | [SETUP_LOCAL.md](SETUP_LOCAL.md) |
---
## Setup wizard
After install, configure dune-admin with the built-in wizard:
```bash
cd /opt/dune-admin
./dune-admin -setup
```
It asks which control plane to use, then prompts for the settings that provider needs (instance name, paths, DB credentials, etc.). When you select `amp`, the wizard auto-detects instances via `ampinstmgr -l` and pre-fills prompts with discovered values, so you can typically accept the defaults straight through. For container topology it also probes the container to discover the actual game install path, so the wizard isn't pinned to any one AMP module's directory layout.
When done it writes `~/.dune-admin/config.yaml` (mode 600, never committed) and prints a sudoers entry to copy into `/etc/sudoers.d/dune-admin`.
Re-run the wizard any time your configuration changes.
---
## Deploy modes
The same binary supports three deployment shapes:
**Single-binary on a host (AMP, local Go, k3s port-forward)**
The binary serves both the API and the SPA from `./dist` next to itself. `scripts/install.sh` lays this out for you. The simplest model: one process, one port, no CDN.
**k3s / k8s cluster**
A unified manifest deploys dune-admin into a cluster with PostgreSQL and RabbitMQ alongside. Helper scripts handle kubeconfig pull, image build/import, manifest render/apply, and port-forward:
```bash
./deploy.sh # macOS / Linux
./deploy.ps1 # Windows
```
See [SETUP_KUBECTL.md](SETUP_KUBECTL.md) for full options and troubleshooting.
**Hosted SPA + local backend**
Run the binary as an API-only process and serve the SPA from Cloudflare Pages (or any static host). The SPA prompts for a backend URL on first load, stored in localStorage. The backend adds CORS headers automatically, no extra config needed.
```bash
make deploy-web # builds and pushes the SPA to Cloudflare Pages
```
Modern browsers allow HTTPS pages to reach HTTP localhost without mixed-content errors, so `https://your-site.pages.dev``http://localhost:8080` works out of the box.
---
## Configuration
Config is loaded in this order (first match wins per field):
1. `~/.dune-admin/config.yaml`, written by `dune-admin -setup`
2. `.env` in the working directory, legacy fallback for existing installs
3. Environment variables
4. Command-line flags
### Common fields
| Env var | Flag | Default | Description |
|---------|------|---------|-------------|
| `CONTROL` | `-control` | *(auto)* | Control plane: `amp`, `kubectl`, `docker`, or `local` |
| `SSH_HOST` | `-host` | - | VM `host:port`, tunnels all connections through SSH when set |
| `SSH_USER` | `-user` | `dune` | SSH user |
| `SSH_KEY` | `-key` | *(auto-detected)* | SSH private key path |
| `DB_HOST` | `-dbhost` | `127.0.0.1` | PostgreSQL host or Docker DNS name |
| `DB_PORT` | `-dbport` | `15432` | PostgreSQL port |
| `DB_USER` | `-dbuser` | `dune` | PostgreSQL user |
| `DB_PASS` | `-dbpass` | - | PostgreSQL password |
| `DB_NAME` | `-dbname` | `dune` | PostgreSQL database name |
| `DB_SCHEMA` | `-schema` | `dune` | PostgreSQL schema |
| `CONTROL_NAMESPACE` | `-control-ns` | *(auto-discovered)* | K8s namespace (kubectl only) |
| `BROKER_GAME_ADDR` | `-broker-game` | - | mq-game broker `host:port` |
| `BROKER_ADMIN_ADDR` | `-broker-admin` | - | mq-admin broker `host:port` |
| `BACKUP_DIR` | `-backup-dir` | - | Backup directory path |
| `LISTEN_ADDR` | `-addr` | `:8080` | HTTP listen address |
| `SCRIP_CURRENCY` | `-scripcurrency` | `1` | Scrip currency ID |
Provider-specific fields (`docker_gameserver`, `amp_instance`, `cmd_start`, etc.) have no env var equivalents and are set via the wizard or `config.yaml` directly. See the provider guides for the full list.
### Market bot
dune-admin runs the market bot **embedded**, an in-process goroutine that shares the main DB pool. Enable in `config.yaml`:
```yaml
market_bot_enabled: true
market_bot_cache_db: ~/.dune-admin/market-bot-cache.db # auto-created (SQLite, pure Go)
market_bot_item_data: ./item-data.json # falls back to standard search paths
market_bot_buy_interval: 5m
market_bot_list_interval: 30m
market_bot_buy_threshold: 1.05
market_bot_max_buys: 50
```
The Market tab's lifecycle buttons map to in-process actions:
- **Start** -> `Resume()`: flips the bot's enabled flag back on
- **Stop** -> `Pause()`: flips the bot's enabled flag off (goroutine stays resident)
- **Restart** -> `Restart()`: pauses, reinitializes the exchange, resumes
Config edits in the Market tab apply directly to the live runtime config. A full process restart only matters when changing the cache DB path or item-data path.
See [ADR 0002](docs/adr/0002-embed-market-bot-as-library.md) and [ADR 0004](docs/adr/0004-in-process-bot-lifecycle.md) for the design rationale.
### Welcome Kits
An opt-in feature that auto-grants a configured item package to every player **once, on first login**. Manage it in the **Welcome Kits** tab, or in `config.yaml`:
```yaml
welcome_package_enabled: true
welcome_package_scan_interval_secs: 30
welcome_package_active_version: v1
welcome_packages:
- version: v1
items:
- { template: AluminiumBar, qty: 5, quality: 0 }
```
dune-admin keeps a library of named packages plus an active-version pointer. An in-process scanner grants the active package once per `(player, version)`, tracked in a persistent SQLite ledger at `~/.dune-admin/welcome-package.db` (so a restart never re-grants). Bumping the active version re-issues to everyone. It defaults **off** (it mutates every player's inventory) and delivers items through the same live-RMQ + DB-fallback path as manual give-items.
**Message of the Day (MOTD).** The same tab (**Welcome Kits → Config**) also has a separate **Message of the Day** card: an in-game message whispered to a player from the GM persona **every time they join** — every session, unlike the once-per-version welcome above. It's decoupled from the package system, so it works even with no active package. Use `{player}` in the message for the player's character name, and leave the sender blank to use the seeded GM persona. A presence tracker diffs the online set on each scan tick to detect joins, and seeds a silent baseline on startup so a restart never re-messages players already in-game. Defaults **off**; configure and toggle it live (no restart) in the UI.
### Server settings
The **Server Settings** tab manages gameplay config (mining/vehicle output, PvP & security zones, sandstorm/sandworm toggles, building limits, item deterioration, server name/password, …). How it writes depends on the provider:
- **amp**: settings are written through AMP's Web API, because AMP regenerates the game INIs from its own config on every start (a direct file edit would be clobbered). This requires AMP API credentials in `config.yaml`:
```yaml
amp_api_user: admin # an AMP panel login for the instance
amp_api_pass: yourpassword
amp_api_port: 8081 # instance ADS API port (default 8081)
```
A game restart applies them; dune-admin's **Restart** recycles the AMP container (`<runtime> restart`, matching `amp_container_runtime`), which is what actually cycles the game processes. The container-runtime sudoers grant must allow this. See [SETUP_AMP.md](SETUP_AMP.md#server-settings-gameplay-config).
- **docker / kubectl / local**: settings are written straight to `UserGame.ini` / `UserEngine.ini`; no API credentials needed.
Either way, a server restart is required for changes to take effect.
### SSH key lookup order
When `SSH_KEY` / `-key` is not set, dune-admin checks these paths in order:
1. `./sshKey`
2. `~/.dune-admin/sshKey`
3. `~/.ssh/dune`
4. `~/.ssh/id_ed25519`
5. `~/.ssh/id_rsa`
---
## Tabs
Tabs are organised into a grouped left sidebar.
**Operations**
| Tab | What it does |
|-----|--------------|
| **Battlegroup** | Start/stop game-server pods; stream container logs; manage backups |
| **Logs** | Stream live logs; view cheat detection events |
| **Database** | Run raw SQL against the game DB; browse tables |
| **Server Settings** | Edit UE5 server settings; writes go to `UserGame.ini` in a managed block |
**Player World**
| Tab | What it does |
|-----|--------------|
| **Players** | Browse players; view/edit inventory, specs, currency, XP, faction rep; journey nodes; teleport; session history |
| **Storage** | Browse server-side storage containers |
| **Bases** | Browse and export player base placements |
| **Blueprints** | View all unlockable blueprint definitions |
**Economy**
| Tab | What it does |
|-----|--------------|
| **Market Bot** | View live market listings; control the embedded market bot |
| **Welcome Kits** | Auto-grant a configured item package to every player once, on first login — plus an optional Message-of-the-Day whispered on every join |
---
## Manual build
For development or if you'd rather not use `scripts/install.sh`:
```bash
git clone https://github.com/Icehunter/dune-admin
cd dune-admin
# Frontend (Vite + Rolldown, needs node-linker=hoisted for the native binding)
echo 'node-linker=hoisted' > web/.npmrc
cd web && pnpm install --frozen-lockfile && pnpm build && cd ..
# Backend
make linux # cross-compile a Linux amd64 binary (dune-admin-linux)
# or
make build # host OS binary (bin/dune-admin), plus the frontend build
```
Then run the setup wizard:
```bash
./dune-admin -setup
```
Prerequisites: Go 1.26+, Node 20.19+ or 22.12+, pnpm 10.28+, `make`.
> **Windows note:** `make build` works from PowerShell or `cmd.exe` as long as
> [GNU Make](https://gnuwin32.sourceforge.net/packages/make.htm) is installed (e.g.
> `winget install GnuWin32.Make` or `choco install make`). The binary is named
> `dune-admin.exe`. For `make dev`, `make verify`, and the `make version-*` targets,
> run from a **Git Bash** shell (right-click the folder -> "Git Bash Here"); those
> recipes use POSIX shell features that cmd.exe can't run.
---
## Development
dune-admin ships pre-commit and pre-push hooks that mirror the CI quality gate. Set them up once:
```bash
make hooks # wires git's core.hooksPath to .githooks/
make tools # caches golangci-lint, govulncheck, gocognit, gosec, air (via Go's go tool mechanism)
```
After that, every `git commit` runs `gofmt -w` + `go vet` + `golangci-lint` on staged Go files (and `markdownlint-cli2` on `.md` files), and every `git push` adds `gosec`, `govulncheck`, and `go test -race`. Bypass with `--no-verify` if you really need to.
You can run the full suite by hand at any time:
```bash
make verify # fmt-check + vet + test-race + vulncheck + lint + gocognit
```
> **Windows note:** the race detector needs cgo. Either install MinGW (e.g. `winget install BrechtSanders.WinLibs.POSIX.UCRT`) or skip race testing locally and rely on the pre-push hook on a Linux dev box or CI.
---
## Makefile targets
| Target | Description |
|--------|-------------|
| `make setup` | Run the interactive setup wizard |
| `make build` | Build frontend + Go binary for the host OS |
| `make linux` | Cross-compile a Linux amd64 binary (`dune-admin-linux`) |
| `make dev` | Run backend + frontend in live mode (Air + Vite HMR) |
| `make dev-server` | Run backend only (`go run ./cmd/dune-admin`) |
| `make web` | Build the frontend only |
| `make deploy-web` | Build and deploy the SPA to Cloudflare Pages |
| `make render-k8s` | Render `deploy/k8s/dune-admin.rendered.yaml` from `~/.dune-admin/config.yaml` |
| `make k8s-dry-run` | Render and run `kubectl apply --dry-run=client` |
| `make test` | Run tests |
| `make verify` | Run all quality checks (fmt-check, vet, test-race, vulncheck, lint, gocognit) |
| `make hooks` | Install the pre-commit + pre-push hooks |
| `make tools` | Cache the dev toolchain (`golangci-lint`, `gosec`, etc.) |
---
## Item data (optional)
`item-data.json` provides friendly item names, stack limits, volume, tier, and rarity. It ships with the repo.
Without it the panel still works; inventory items show raw template IDs.
---
## Architecture
dune-admin is a single Go binary that exposes a REST API over the game's PostgreSQL + RabbitMQ stack. The React SPA can be served by the same binary (from `./dist`) or hosted independently (Cloudflare Pages). A control-plane abstraction (`amp` / `kubectl` / `docker` / `local`) drives lifecycle operations, log streaming, INI editing, and broker access against whatever topology you're running.
For design rationale and trade-offs, see the architecture decision records in [`docs/adr/`](docs/adr/):
- [0001 - Standard Go project layout](docs/adr/0001-standard-go-layout.md)
- [0002 - Embed market bot as `internal/marketbot` library](docs/adr/0002-embed-market-bot-as-library.md)
- [0003 - Ship a single binary and container image](docs/adr/0003-single-binary-deployment.md)
- [0004 - In-process bot lifecycle control](docs/adr/0004-in-process-bot-lifecycle.md)
- [0005 - Ring-buffer for embedded bot log streaming](docs/adr/0005-ring-buffer-log-streaming.md)
- [0006 - Replace per-project k8s manifests with one unified manifest](docs/adr/0006-unified-k8s-manifest.md)
- [0007 - Persistent volume for SQLite market-bot cache](docs/adr/0007-sqlite-cache-storage.md)
- [0008 - Extend config.yaml for embedded-bot settings](docs/adr/0008-config-yaml-extensions.md)

View File

@@ -0,0 +1,148 @@
# Provider: amp (CubeCoders AMP)
Use this provider when your Dune server is managed by AMP (`ampinstmgr`) and RabbitMQ/Postgres live in the AMP stack (host-native, or in a podman- or docker-backed container). Set `amp_container_runtime` to match (`podman` default, or `docker`).
```
dune-admin
├─ ampinstmgr / podman exec → lifecycle + logs + broker ops
├─ elevated INI writes as amp user
└─ TCP → PostgreSQL (host or SSH-tunnelled host)
```
## Prerequisites
| Requirement | Notes |
|-------------|-------|
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
| **AMP host access** | Run dune-admin on the AMP host, or set `ssh_host` to run remotely over SSH |
| **Sudoers grant** | dune-admin user must run `ampinstmgr`, the container runtime (`podman`, or `docker` when `amp_container_runtime: docker`), and `tee` as AMP user without prompts. The container-runtime grant covers both `exec` (logs/broker) and `restart` (applying server settings). |
Example sudoers entry (adjust user/path names as needed) — **podman backend (default)**:
```bash
dune-admin ALL=(amp) NOPASSWD: /usr/bin/ampinstmgr, /usr/bin/podman, /usr/bin/tee
```
If your AMP container runs on **docker** (`amp_container_runtime: docker`), grant `docker` instead:
```bash
dune-admin ALL=(amp) NOPASSWD: /usr/bin/ampinstmgr, /usr/bin/docker, /usr/bin/tee
```
The same runtime grant is what lets dune-admin's **Restart** action cycle the container
(`<runtime> restart <container>`) — see [Server settings](#server-settings-gameplay-config) for why a
real container restart is required.
## Quick start (wizard)
```bash
make setup
# Select: amp
# Fill AMP instance/container/user details
make build # builds frontend + dune-admin binary
./dune-admin
```
## Manual config (`~/.dune-admin/config.yaml`)
```yaml
control: amp
# Optional if running dune-admin from a different machine:
# ssh_host: 192.168.0.72:22
# ssh_user: dune-admin
# ssh_key: /home/you/.ssh/amp-host
db_host: 127.0.0.1
db_port: 15432
db_user: postgres
db_pass: yourpassword
db_name: dune
db_schema: dune
amp_instance: DuneAwakening01
amp_container: AMP_DuneAwakening01
amp_user: amp
amp_log_path: /AMP/duneawakening/logs
server_ini_dir: /home/amp/.ampdata/instances/DuneAwakening01/duneawakening/server/state
# Optional:
amp_use_container: true
amp_container_runtime: docker # podman (default) | docker — match your AMP container backend
amp_data_root: /AMP/duneawakening
director_url: http://127.0.0.1:11717
broker_exec_prefix: "sudo -i -u amp podman exec AMP_DuneAwakening01"
listen_addr: :18080 # avoids collision with AMP web panel on :8080
# AMP Web API — required to manage gameplay settings under AMP (see "Server settings" below).
# These are an AMP panel login for the instance; the API is reached in-container at
# 127.0.0.1:<amp_api_port>, so no host port needs to be exposed.
amp_api_user: admin
amp_api_pass: yourpassword
amp_api_port: 8081 # instance ADS API port (default 8081)
```
## Embedded market bot (recommended in AMP)
```yaml
market_bot_enabled: true
market_bot_cache_db: /home/amp/.dune-admin/market-bot-cache.db
market_bot_item_data: /path/to/dune-admin/item-data.json
market_bot_buy_interval: 5m
market_bot_list_interval: 30m
market_bot_buy_threshold: 1.05
market_bot_max_buys: 50
```
External market-bot mode is removed; use embedded mode for AMP deployments.
## Server settings (gameplay config)
The **Server Settings** tab manages gameplay knobs — mining/vehicle output, PvP/security zones,
sandstorm and sandworm toggles, building limits, item deterioration, server name/password, and so on.
Under AMP this path is different from every other provider, and it matters:
- **AMP owns the game INI files.** AMP regenerates `UserEngine.ini` / `UserGame.ini` from its own
config on every start, so editing those files directly is silently clobbered. dune-admin therefore
writes settings through **AMP's Web API** (`Core/SetConfig`), which persists them in AMP's config and
survives restarts. This is why the `amp_api_user` / `amp_api_pass` / `amp_api_port` credentials above
are required — without them, saving a setting under AMP returns an error rather than failing silently.
- **A restart is required to apply.** Saving writes the value to AMP's config; the running game only
picks it up on the next start. dune-admin's **Restart** action recycles the AMP **container**
(`<runtime> restart <container>`), which is the action that actually reaps the
`DuneSandboxServer` processes — `ampinstmgr` alone leaves them running, so the change never takes
effect. The container restart also briefly cycles the in-container PostgreSQL and broker; dune-admin
reconnects to the database automatically afterwards.
Typical flow: change a value in **Server Settings****Save****Restart** (Operations) → the new
value is live in-game.
Other providers (docker / kubectl / local) have no AMP layer to clobber the files, so they write the
INIs directly and do **not** need the `amp_api_*` credentials.
## What works
| Feature | Supported |
|---------|-----------|
| Battlegroup status | Yes |
| Start / stop | Yes (`ampinstmgr`) |
| Restart | Yes — cycles the container (`<runtime> restart`) so game processes actually recycle |
| Process list | Yes |
| Log streaming | Yes |
| DB access | Yes (direct or SSH tunnel) |
| Broker command path | Yes (`broker_exec_prefix`) |
| Server settings (gameplay) | Yes — written via AMP Web API (`amp_api_*`); restart to apply |
| INI read/write | Yes (`ampExecutor` writes as AMP user; non-gameplay/raw sections) |
## Troubleshooting
**`sudo` prompts or permission denied** — fix `/etc/sudoers.d/*` for the dune-admin user and AMP user.
**INI changes fail** — verify `server_ini_dir` and that AMP user owns `UserGame.ini` / `UserEngine.ini`.
**Broker commands fail** — set `broker_exec_prefix` to the exact `podman exec` (or `docker exec`) wrapper used on your AMP host.
**Saving a server setting returns an error (502)** — dune-admin could not reach or authenticate to the AMP Web API. Check `amp_api_user` / `amp_api_pass` (an AMP panel login) and `amp_api_port` (default `8081`), and that the instance ADS is running inside the container.
**A server setting saved but did nothing in-game** — settings apply on the next game start. Use dune-admin's **Restart** (which does `<runtime> restart <container>`), not just `ampinstmgr`; confirm the container-runtime sudoers grant above is in place so the restart can run.

View File

@@ -0,0 +1,109 @@
# Provider: docker
Use this provider when your Dune server runs as Docker containers (e.g. alongside a compose stack) and dune-admin can reach the Docker daemon directly — either co-located on the same host or SSH'd into a Docker host.
```
dune-admin
├─ docker CLI → container lifecycle + logs
├─ docker exec → RabbitMQ broker commands
└─ TCP (Docker DNS) → PostgreSQL (database:15432)
```
## Prerequisites
| Requirement | Notes |
|-------------|-------|
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
| **Docker CLI** | Must be in `$PATH` |
| **Docker access** | The user running dune-admin must be able to run `docker` (i.e. in the `docker` group or running as root) |
### If dune-admin runs on a different host
Add `SSH_HOST` to your config so all commands and DB connections tunnel through SSH:
```yaml
ssh_host: 192.168.0.72:22
ssh_user: dune
ssh_key: /home/you/.ssh/key
```
With SSH set, `docker` CLI commands run on the remote host and DB connections are tunnelled — no ports need to be exposed.
## Quick start (wizard)
```bash
make setup
# Select: docker
# Enter container names when prompted
make build # builds frontend + dune-admin binary
./dune-admin
```
The wizard asks for container names, tests them with `docker inspect`, and asks for DB connection details.
## Manual config (`~/.dune-admin/config.yaml`)
```yaml
control: docker
# Container names — must match exactly what `docker ps` shows:
docker_gameserver: dune-gameserver
docker_broker_game: dune-mq-game # optional — for broker command path
docker_broker_admin: dune-mq-admin # optional — for broker command path
# Database — use Docker DNS name or IP:
db_host: database # service name in your compose file
db_port: 15432
db_user: dune
db_pass: yourpassword
db_name: dune
db_schema: dune
# Optional:
backup_dir: /backups
broker_game_addr: dune-mq-game:5672 # defaults to docker_broker_game container DNS if omitted
broker_admin_addr: dune-mq-admin:5672
broker_tls: false
listen_addr: :8080
scrip_currency: 1
```
> **Note:** `docker_*` and `cmd_*` fields are only read from `~/.dune-admin/config.yaml` — they have no env var equivalents. Use `make setup` or edit the file directly.
## Typical compose layout
Your compose file doesn't need to change. dune-admin just needs the container names:
```yaml
services:
gameserver:
container_name: dune-gameserver # ← docker_gameserver
database:
container_name: dune-db
mq-game:
container_name: dune-mq-game # ← docker_broker_game
mq-admin:
container_name: dune-mq-admin # ← docker_broker_admin
```
## What works
| Feature | Supported |
|---------|-----------|
| Battlegroup status | Partial — shows container state, not K8s CRD fields |
| Start / stop / restart | Yes — `docker start/stop/restart` |
| Update / backup | Not supported (no `battlegroup.sh`) |
| Container list | Yes — `docker ps` |
| Log streaming | Yes — `docker logs -f` |
| DB access | Yes — direct TCP to `db_host:db_port` |
| RabbitMQ broker commands | Yes — `docker exec` into broker container |
| Backup download / upload | Yes — through executor file I/O |
| Backup restore | Yes — `pg_restore` run via executor |
## Troubleshooting
**"docker inspect failed"** — the container name is wrong or Docker is not running. Check with `docker ps` and update `docker_gameserver` in config.yaml.
**DB connection fails** — verify `db_host` matches the container's DNS name or IP. Inside a compose network, use the service/container name directly (e.g. `database`). Outside the network, use the host IP and a mapped port.
**Logs show nothing** — confirm `docker_gameserver` is the correct container name. Container names are exact-match, not prefix.

View File

@@ -0,0 +1,235 @@
# Provider: kubectl (Kubernetes / k3s)
Use this provider when your Dune server runs inside a Kubernetes cluster (e.g. k3s on a VM) and dune-admin runs on your local machine or another host with SSH access to that VM.
All commands run over SSH — no exposed ports, no VPN.
```
your machine
└─ SSH tunnel → VM
├─ kubectl → battlegroup CRDs / pod logs
└─ TCP tunnel → PostgreSQL (pod IP:15432)
```
## Prerequisites
| Requirement | Notes |
|-------------|-------|
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
| **SSH key** | Private key authorised on the VM |
| **VM access** | Port 22 reachable; SSH user needs passwordless `sudo kubectl` |
## Quick start (wizard)
```bash
# Place SSH key (checked automatically in this order):
# ./sshKey │ ~/.dune-admin/sshKey │ ~/.ssh/dune │ ~/.ssh/id_ed25519 │ ~/.ssh/id_rsa
cp /path/to/key ./sshKey && chmod 600 ./sshKey
make setup # prompts for VM host:port, discovers namespace + DB password automatically
make build # builds frontend + dune-admin binary
./dune-admin
```
The wizard:
1. Locates your SSH key
2. SSHes into the VM
3. Runs `kubectl get pods -A` to find the database pod and namespace
4. Reads `~/.dune/<battlegroup>.yaml` on the VM for DB credentials
5. Writes `~/.dune-admin/config.yaml`
## External VM deploy example (`dune@192.168.0.72`)
```bash
cd /Volumes/Engineering/Icehunter/dune-admin
# Pull kubeconfig from VM and point kubectl at the external cluster.
mkdir -p ~/.kube
ssh dune@192.168.0.72 "sudo cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/dune-external.yaml
sed -i '' 's/127.0.0.1/192.168.0.72/g' ~/.kube/dune-external.yaml
export KUBECONFIG=~/.kube/dune-external.yaml
kubectl get nodes
# Configure dune-admin runtime values.
make setup
# Ensure these are set in ~/.dune-admin/config.yaml for container runtime:
# market_bot_enabled: true
# market_bot_item_data: /app/item-data.json
# market_bot_cache_db: /data/market-bot-cache.db
# Build local image and import it into k3s runtime on the VM.
docker buildx build --platform linux/amd64 -f deploy/Dockerfile -t dune-admin:local --load .
docker save dune-admin:local | ssh dune@192.168.0.72 "sudo k3s ctr images import -"
# Render deployment manifest from ~/.dune-admin/config.yaml and switch image tag.
make render-k8s
sed -i '' 's#ghcr.io/icehunter/dune-admin:latest#dune-admin:local#g' deploy/k8s/dune-admin.rendered.yaml
# Deploy and wait for readiness.
kubectl apply -f deploy/k8s/dune-admin.rendered.yaml
kubectl -n dune-admin rollout status deploy/dune-admin
kubectl -n dune-admin get pods,svc
# Verify API and bot from inside the cluster.
kubectl -n dune-admin run curl --rm -it --restart=Never --image=curlimages/curl -- \
sh -c "curl -s http://dune-admin:8080/api/v1/market-bot/status"
# Local access from your laptop.
kubectl -n dune-admin port-forward svc/dune-admin 8080:8080
```
## Scripted deploy (Linux/macOS + Windows)
From repo root:
```bash
./deploy.sh
```
```powershell
./deploy.ps1
```
> On Windows, if script execution is blocked, run once:
> `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`
Both scripts run the full k8s flow:
1. Pull kubeconfig from VM (`dune@192.168.0.72` by default) unless skipped
2. Build a fresh timestamped image tag (`dune-admin:local-<timestamp>` by default)
3. Import image into VM k3s runtime (`k3s ctr images import`)
4. Render and apply `deploy/k8s/dune-admin.rendered.yaml`
5. Auto-fix embedded bot deployment settings in the rendered manifest:
- `MARKET_BOT_ENABLED: "true"`
- `market_bot_enabled: true`
- `market_bot_item_data: /app/item-data.json`
- `market_bot_cache_db: /data/market-bot-cache.db`
6. Restart rollout, wait for old terminating pods to drain, then run in-cluster health checks for:
- `/api/v1/status` reachable
- `/` returns HTTP 200 (no UI 404)
- `/api/v1/market-bot/status` reports `"enabled":true`
7. Open `kubectl port-forward` on `127.0.0.1:8080` (unless disabled)
SSH auth behavior in both scripts:
- If `./sshKey` exists, it is used first (`-i ./sshKey`)
- If key auth fails or no key is present, SSH falls back to password prompt
### Script options
| Purpose | Bash | PowerShell |
|---|---|---|
| VM user | `--vm-user dune` | `-VmUser dune` |
| VM host | `--vm-host 192.168.0.72` | `-VmHost 192.168.0.72` |
| SSH key path | `--ssh-key ./sshKey` | `-SshKeyPath .\sshKey` |
| Kubeconfig path | `--kubeconfig ~/.kube/dune-external.yaml` | `-KubeconfigPath "$HOME/.kube/dune-external.yaml"` |
| Image tag | `--image dune-admin:local` | `-Image dune-admin:local` |
| Namespace | `--namespace dune-admin` | `-Namespace dune-admin` |
| Manifest path | `--manifest deploy/k8s/dune-admin.rendered.yaml` | `-Manifest deploy/k8s/dune-admin.rendered.yaml` |
| Skip kubeconfig pull | `--skip-kubeconfig` | `-SkipKubeconfig` |
| Skip image build | `--skip-build` | `-SkipBuild` |
| Skip VM image import | `--skip-image-import` | `-SkipImageImport` |
| Skip port-forward | `--no-port-forward` | `-NoPortForward` |
### First deploy vs quick redeploy
First deploy:
```bash
./deploy.sh
```
Quick redeploy (reuse kubeconfig, no auto port-forward):
```bash
./deploy.sh --skip-kubeconfig --no-port-forward
```
Quick redeploy with the exact same image tag (advanced):
```bash
./deploy.sh --image dune-admin:local --skip-kubeconfig --no-port-forward
```
Override defaults with flags (same names on both scripts), e.g.:
```bash
./deploy.sh --vm-user dune --vm-host 192.168.0.72 --image dune-admin:local --no-port-forward
```
```powershell
./deploy.ps1 -VmUser dune -VmHost 192.168.0.72 -Image dune-admin:local -NoPortForward
```
## Manual config (`~/.dune-admin/config.yaml`)
```yaml
control: kubectl
ssh_host: 192.168.0.72:22 # VM host:port
ssh_user: dune # SSH user
ssh_key: /home/you/.ssh/key # absolute path; omit to use auto-detection
db_host: 127.0.0.1 # unused for kubectl — pod IP is discovered automatically
db_port: 15432
db_user: postgres
db_pass: yourpassword
db_name: dune
db_schema: dune
# Optional — discovered automatically if omitted:
control_namespace: funcom-seabass-mybattlegroup
# Optional broker command path:
broker_game_addr: 10.43.48.246:5672
broker_admin_addr: 10.43.189.193:5672
broker_tls: true
# Optional:
backup_dir: /funcom/artifacts/database-dumps/mybattlegroup
listen_addr: :8080
scrip_currency: 1
```
## What works
| Feature | Supported |
|---------|-----------|
| Battlegroup status (phase, servers) | Yes |
| Start / stop / restart | Yes — `kubectl patch battlegroup` |
| Update / backup | Yes — `battlegroup.sh` |
| Pod list | Yes |
| Log streaming | Yes — `kubectl logs -f` |
| DB access | Yes — tunnelled through SSH to pod IP |
| RabbitMQ broker commands | Yes — `kubectl exec` into broker pod |
| Backup download / upload / restore | Yes |
## Backward compatibility
Existing `.env` files with just `SSH_HOST`, `SSH_USER`, `DB_PASS`, etc. continue to work unchanged. The control plane defaults to `kubectl` whenever `SSH_HOST` is set, and the namespace is auto-discovered at startup.
## Troubleshooting
**Battlegroup tab shows nothing** — the namespace was not discovered. Check that the SSH user can run `sudo kubectl get pods -A`. You can also pin it explicitly with `control_namespace` in config.yaml.
**DB connection fails** — pod discovery succeeded but DB password is wrong. Delete `~/.dune-admin/config.yaml` and re-run `make setup`.
**"sudo: kubectl: command not found"** — kubectl is not in the sudo-safe PATH on the VM. Add `/usr/local/bin` (or wherever kubectl lives) to `/etc/sudoers` `secure_path`.
**Port-forward works but `http://127.0.0.1:8080` returns 404** — you are running an old image that does not contain the built frontend (`/app/dist`). Rebuild and redeploy with the deploy script.
**Market bot panel shows inactive after deploy** — verify:
1. `market_bot_enabled: true` in `~/.dune-admin/config.yaml`
2. `market_bot_item_data: /app/item-data.json`
3. `market_bot_cache_db: /data/market-bot-cache.db`
Then redeploy and check:
```bash
kubectl -n dune-admin logs deploy/dune-admin --tail=120
kubectl -n dune-admin run curl --rm -it --restart=Never --image=curlimages/curl -- \
sh -c "curl -s http://dune-admin:8080/api/v1/market-bot/status"
```

View File

@@ -0,0 +1,119 @@
# Provider: local
Use this provider when dune-admin runs on the same machine as the Dune server and there is no Kubernetes or Docker involved — e.g. LGSM, bare-metal, or any setup where the game server is managed by shell commands.
> If you run CubeCoders AMP, prefer `control: amp` and follow [SETUP_AMP.md](SETUP_AMP.md).
```
dune-admin (same machine)
├─ shell commands → server lifecycle (start/stop/restart/status)
└─ TCP (127.0.0.1) → PostgreSQL (localhost:15432)
```
## Prerequisites
| Requirement | Notes |
|-------------|-------|
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
| **PostgreSQL reachable** | `db_host` must be accessible from where dune-admin runs |
| **Shell commands** | Optional — only needed for start/stop/restart/status in the Battlegroup tab |
## Quick start (wizard)
```bash
make setup
# Select: local
# Enter DB host, port, credentials
# Enter optional shell commands for server control
make build # builds frontend + dune-admin binary
./dune-admin
```
## Manual config (`~/.dune-admin/config.yaml`)
```yaml
control: local
db_host: 127.0.0.1
db_port: 15432
db_user: dune
db_pass: yourpassword
db_name: dune
db_schema: dune
# Shell commands for Battlegroup tab (all optional):
cmd_start: "amp start DuneAwakening"
cmd_stop: "amp stop DuneAwakening"
cmd_restart: "amp restart DuneAwakening"
cmd_status: "amp status DuneAwakening"
# If RabbitMQ runs inside a container (e.g. AMP uses Podman internally),
# set this prefix and it will be prepended to all rabbitmqctl calls:
# broker_exec_prefix: "podman exec AMP_MehDune01"
# broker_exec_prefix: "docker exec my-broker"
# Optional:
backup_dir: /home/dune/backups
listen_addr: :8080
scrip_currency: 1
```
### AMP example
```yaml
control: local
db_host: 127.0.0.1
db_port: 15432
db_user: postgres
db_pass: yourpassword
db_name: dune
db_schema: dune
cmd_start: "ampinstmgr start DuneAwakening01"
cmd_stop: "ampinstmgr stop DuneAwakening01"
cmd_restart: "ampinstmgr restart DuneAwakening01"
cmd_status: "ampinstmgr status DuneAwakening01"
```
### LGSM example
```yaml
control: local
db_host: 127.0.0.1
db_port: 15432
db_user: dune
db_pass: yourpassword
db_name: dune
db_schema: dune
cmd_start: "/home/dune/duneserver start"
cmd_stop: "/home/dune/duneserver stop"
cmd_restart: "/home/dune/duneserver restart"
cmd_status: "/home/dune/duneserver status"
```
### DB only (no server control)
Leave all `cmd_*` fields empty. The Battlegroup tab will show an error for start/stop/restart but everything else — players, inventory, DB browser, etc. — works normally.
> **Note:** `cmd_*` fields are only read from `~/.dune-admin/config.yaml` — they have no env var equivalents. Use `make setup` or edit the file directly.
## What works
| Feature | Supported |
|---------|-----------|
| Battlegroup status | Partial — runs `cmd_status` and shows raw output |
| Start / stop / restart | Yes — runs the configured shell commands |
| Update / backup | Not supported |
| Pod/process list | Not supported |
| Log streaming | Not supported — tail your own log files |
| DB access | Yes — direct TCP to `db_host:db_port` |
| RabbitMQ broker commands | Yes — runs `rabbitmqctl` directly if available in `$PATH` |
| Backup download / upload | Yes — through local file I/O |
| Backup restore | Yes — `pg_restore` run locally |
## Troubleshooting
**Start/stop does nothing** — the shell commands run as the same user that launched dune-admin. Make sure that user has permission to run the AMP/LGSM commands. Test manually first: `ampinstmgr start DuneAwakening01`.
**DB connection fails** — verify PostgreSQL is listening on the configured `db_host:db_port`. For AMP, the DB is usually on `127.0.0.1:5432` or a custom port; check your AMP instance settings.
**RabbitMQ broker command fails**`rabbitmqctl` must be in `$PATH` for the user running dune-admin. Run `which rabbitmqctl` to verify.

View File

@@ -0,0 +1 @@
0.33.0

View File

@@ -0,0 +1,8 @@
[
{ "name": "LeaveMeAlone", "danger": false },
{ "name": "AwardPlayerXP", "danger": false },
{ "name": "UnlockAllSkills", "danger": false },
{ "name": "UnlockAllAbilities", "danger": false },
{ "name": "PlaytestSetup", "danger": true },
{ "name": "PlaytestSetupAdmin", "danger": true }
]

View File

@@ -0,0 +1,255 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// defaultAmpAPIPort is the AMP instance ADS Web API port, reachable from inside
// the AMP container at http://127.0.0.1:8081/API/.
const defaultAmpAPIPort = 8081
// ampAPIClient talks to a CubeCoders AMP instance's Web API. Under the AMP
// control plane, gameplay/server settings are owned by AMP: it regenerates
// UserEngine.ini / UserGame.ini from its own config (GenericModule.kvp →
// App.AppSettings) on every start, so a direct INI edit gets clobbered. Writing
// through the AMP API persists cleanly and survives restarts.
//
// Requests are issued by building a curl command, wrapping it for in-container
// execution via wrap (ampControl.wrapInContainer), and running it through the
// host Executor. The AMP ADS port is not exposed on the host, but the executor
// already execs into the container for logs and rabbitmqctl, so the same path
// reaches the loopback API with no extra port plumbing.
type ampAPIClient struct {
exec Executor
wrap func(string) string // wraps an in-container shell command
user string
pass string
port int
sessionID string // cached after the first successful login
}
func newAMPAPIClient(exec Executor, wrap func(string) string, user, pass string, port int) *ampAPIClient {
return &ampAPIClient{exec: exec, wrap: wrap, user: user, pass: pass, port: port}
}
func (c *ampAPIClient) apiPort() int {
if c.port == 0 {
return defaultAmpAPIPort
}
return c.port
}
func (c *ampAPIClient) endpoint(path string) string {
return fmt.Sprintf("http://127.0.0.1:%d/API/%s", c.apiPort(), path)
}
// buildCurl returns an in-container shell command that POSTs payload as JSON to
// the named AMP API endpoint. The JSON body is base64-piped to curl so
// operator-supplied values (passwords, server names) never touch the shell
// command line — eliminating both quoting bugs and shell-injection risk.
func (c *ampAPIClient) buildCurl(path string, payload any) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal %s payload: %w", path, err)
}
b64 := base64.StdEncoding.EncodeToString(body)
return fmt.Sprintf(
"echo %s | base64 -d | curl -s -m 20 -X POST "+
"-H 'Content-Type: application/json' -H 'Accept: application/json' "+
"--data-binary @- %s",
b64, c.endpoint(path)), nil
}
// post runs an AMP API call and returns the trimmed response body. Executor
// failures are wrapped and surface curl's stderr for diagnosis.
func (c *ampAPIClient) post(path string, payload any) (string, error) {
cmd, err := c.buildCurl(path, payload)
if err != nil {
return "", err
}
out, err := c.exec.Exec(c.wrap(cmd))
if err != nil {
return "", fmt.Errorf("amp api %s: %w (output: %s)", path, err, strings.TrimSpace(out))
}
return strings.TrimSpace(out), nil
}
// login authenticates against Core/Login and caches the session ID. AMP returns
// a LoginResult; success is gated on both the success flag and a non-empty
// sessionID.
func (c *ampAPIClient) login() (string, error) {
resp, err := c.post("Core/Login", map[string]any{
"username": c.user,
"password": c.pass,
"token": "",
"rememberMe": false,
})
if err != nil {
return "", err
}
var result struct {
Success bool `json:"success"`
ResultReason string `json:"resultReason"`
SessionID string `json:"sessionID"`
}
if err := json.Unmarshal([]byte(extractJSONObject(resp)), &result); err != nil {
return "", fmt.Errorf("amp api Core/Login: decode response: %w (output: %s)", err, resp)
}
if !result.Success || result.SessionID == "" {
reason := result.ResultReason
if reason == "" {
reason = "login failed"
}
return "", fmt.Errorf("amp api login rejected: %s", reason)
}
c.sessionID = result.SessionID
return c.sessionID, nil
}
// ensureSession returns the cached session ID, logging in on first use.
func (c *ampAPIClient) ensureSession() (string, error) {
if c.sessionID != "" {
return c.sessionID, nil
}
return c.login()
}
// isSessionError reports whether an AMP API error looks like a session
// rejection (expired, invalid, or unknown session ID). Used to trigger a
// one-shot re-login rather than surfacing a confusing auth error to the
// operator — AMP sessions can expire if the server is idle for a long time.
func isSessionError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "session")
}
// setConfig writes a single AMP config node (e.g.
// "Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier").
// AMP persists it to GenericModule.kvp and regenerates the game INIs on the
// next start.
//
// If AMP rejects the call with a session error (expired or invalid session),
// setConfig clears the cached session ID, re-logs in once, and retries the
// write. This handles the case where the in-process session goes stale between
// a successful login and a subsequent SetConfig within the same batch.
func (c *ampAPIClient) setConfig(node, value string) error {
sid, err := c.ensureSession()
if err != nil {
return err
}
resp, err := c.post("Core/SetConfig", map[string]any{
"node": node,
"value": value,
"SESSIONID": sid,
})
if err != nil {
return err
}
if err := parseActionResult(node, resp); err != nil {
if !isSessionError(err) {
return err
}
// Session expired — force re-login and retry once.
c.sessionID = ""
sid, err = c.login()
if err != nil {
return err
}
resp, err = c.post("Core/SetConfig", map[string]any{
"node": node,
"value": value,
"SESSIONID": sid,
})
if err != nil {
return err
}
return parseActionResult(node, resp)
}
return nil
}
// getConfig reads a single AMP config node's current value.
func (c *ampAPIClient) getConfig(node string) (string, error) {
sid, err := c.ensureSession()
if err != nil {
return "", err
}
resp, err := c.post("Core/GetConfig", map[string]any{
"node": node,
"SESSIONID": sid,
})
if err != nil {
return "", err
}
var result struct {
CurrentValue json.RawMessage `json:"CurrentValue"`
}
if err := json.Unmarshal([]byte(extractJSONObject(resp)), &result); err != nil {
return "", fmt.Errorf("amp api GetConfig %s: decode response: %w (output: %s)", node, err, resp)
}
return jsonScalarToString(result.CurrentValue), nil
}
// parseActionResult interprets an AMP SetConfig response, which is either an
// ActionResult object ({"Status":bool,"Reason":string}) or — on some AMP
// versions — a bare JSON bool. A missing Status is treated as success (older
// builds return {} when the write succeeds).
func parseActionResult(node, resp string) error {
trimmed := strings.TrimSpace(resp)
switch trimmed {
case "true":
return nil
case "false":
return fmt.Errorf("amp api SetConfig %s: rejected", node)
}
var result struct {
Status *bool `json:"Status"`
Reason string `json:"Reason"`
}
if err := json.Unmarshal([]byte(extractJSONObject(trimmed)), &result); err != nil {
return fmt.Errorf("amp api SetConfig %s: decode response: %w (output: %s)", node, err, trimmed)
}
if result.Status != nil && !*result.Status {
reason := result.Reason
if reason == "" {
reason = "rejected"
}
return fmt.Errorf("amp api SetConfig %s: %s", node, reason)
}
return nil
}
// extractJSONObject returns the substring spanning the first '{' to the last
// '}', so a stray sudo banner or curl notice ahead of the JSON body doesn't
// break decoding. Returns s unchanged when no object braces are present (the
// caller's decode then fails with a clear error).
func extractJSONObject(s string) string {
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start < 0 || end < start {
return s
}
return s[start : end+1]
}
// jsonScalarToString renders a JSON scalar (string/number/bool/null) as a plain
// string: quoted strings are unquoted; numbers and bools are returned verbatim;
// null/empty become "".
func jsonScalarToString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
if len(s) >= 2 && s[0] == '"' {
var unquoted string
if err := json.Unmarshal(raw, &unquoted); err == nil {
return unquoted
}
}
return s
}

View File

@@ -0,0 +1,72 @@
package main
import (
"errors"
"testing"
)
// TestNewControlPlane_AMPWiresAPICredentials verifies the factory threads the
// AMP Web API credentials from config into the ampControl so the settings-write
// path can authenticate.
func TestNewControlPlane_AMPWiresAPICredentials(t *testing.T) {
t.Parallel()
cp := newControlPlane("amp", appConfig{
AmpInstance: "DuneTest01",
AmpAPIUser: "admin",
AmpAPIPass: "test123!",
AmpAPIPort: 9090,
})
amp, ok := cp.(*ampControl)
if !ok {
t.Fatalf("expected *ampControl, got %T", cp)
}
if amp.apiUser != "admin" || amp.apiPass != "test123!" || amp.apiPort != 9090 {
t.Errorf("api creds = (%q,%q,%d), want (admin, test123!, 9090)", amp.apiUser, amp.apiPass, amp.apiPort)
}
}
// TestMaskSecrets_MasksAmpAPIPass ensures the AMP API password is never exposed
// through the /api/v1/config GET endpoint.
func TestMaskSecrets_MasksAmpAPIPass(t *testing.T) {
t.Parallel()
cfg := appConfig{AmpAPIPass: "secret"}
maskSecrets(&cfg)
if cfg.AmpAPIPass != masked {
t.Errorf("AmpAPIPass = %q, want masked", cfg.AmpAPIPass)
}
// An empty password stays empty (not masked) so the UI shows "unset".
empty := appConfig{}
maskSecrets(&empty)
if empty.AmpAPIPass != "" {
t.Errorf("empty AmpAPIPass = %q, want empty", empty.AmpAPIPass)
}
}
// TestPreserveMaskedSecrets_RestoresAmpAPIPass verifies that when the client
// posts back the masked placeholder, the stored AMP API password is restored
// (here from the in-memory loadedConfig fallback when the file is unreadable).
func TestPreserveMaskedSecrets_RestoresAmpAPIPass(t *testing.T) {
orig := loadedConfig
t.Cleanup(func() { loadedConfig = orig })
loadedConfig = appConfig{AmpAPIPass: "stored-amp-pass"}
cfg := appConfig{AmpAPIPass: masked}
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) { return nil, errors.New("no file") }, "ignored")
if cfg.AmpAPIPass != "stored-amp-pass" {
t.Errorf("AmpAPIPass = %q, want restored stored-amp-pass", cfg.AmpAPIPass)
}
}
// TestPreserveMaskedSecrets_KeepsExplicitAmpAPIPass verifies an explicitly-set
// (non-masked) password is written through unchanged.
func TestPreserveMaskedSecrets_KeepsExplicitAmpAPIPass(t *testing.T) {
orig := loadedConfig
t.Cleanup(func() { loadedConfig = orig })
loadedConfig = appConfig{AmpAPIPass: "stored"}
cfg := appConfig{AmpAPIPass: "new-pass"}
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) { return nil, errors.New("no file") }, "ignored")
if cfg.AmpAPIPass != "new-pass" {
t.Errorf("AmpAPIPass = %q, want new-pass (explicit value preserved)", cfg.AmpAPIPass)
}
}

View File

@@ -0,0 +1,379 @@
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
)
// identityWrap is a no-op container wrapper so tests inspect the exact curl
// command the AMP API client builds, without the sudo/exec envelope.
func identityWrap(s string) string { return s }
// decodePipedPayload extracts the base64 blob from an `echo <b64> | base64 -d |
// curl ...` command and unmarshals the decoded JSON into out. The API client
// base64-pipes request bodies so operator-supplied values (passwords, names)
// never need shell escaping; tests assert on the decoded payload rather than on
// brittle string formatting.
func decodePipedPayload(t *testing.T, cmd string, out any) {
t.Helper()
// The payload rides as `echo <b64> | base64 -d | curl …`. Locate that segment
// whether the command is bare (identity wrap) or wrapped for in-container
// exec (`sudo … sh -c 'echo <b64> | …'`). The base64 token has no spaces or
// quotes, so the field after "echo " is the payload in both forms.
const marker = "echo "
i := strings.Index(cmd, marker)
if i < 0 {
t.Fatalf("command has no `echo <payload>` segment: %q", cmd)
}
b64 := strings.Fields(cmd[i+len(marker):])[0]
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("payload is not valid base64 (%v) in cmd: %q", err, cmd)
}
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("decoded payload is not valid JSON (%v): %s", err, raw)
}
}
// ── login ───────────────────────────────────────────────────────────────────
func TestAMPAPILogin_BuildsRequestAndReturnsSession(t *testing.T) {
t.Parallel()
var gotCmd string
exec := &fnExecutor{fn: func(cmd string) (string, error) {
gotCmd = cmd
return `{"success":true,"resultReason":"","sessionID":"abc-123"}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "s3cr3t!", 0)
sid, err := c.login()
if err != nil {
t.Fatalf("login: %v", err)
}
if sid != "abc-123" {
t.Errorf("sessionID = %q, want abc-123", sid)
}
// Endpoint + default port 8081 when port is 0.
if !strings.Contains(gotCmd, "http://127.0.0.1:8081/API/Core/Login") {
t.Errorf("missing Login endpoint with default port in cmd: %q", gotCmd)
}
// JSON is base64-piped, not inlined, and posted as the request body.
for _, want := range []string{"base64 -d", "--data-binary @-", "-H 'Content-Type: application/json'", "-H 'Accept: application/json'"} {
if !strings.Contains(gotCmd, want) {
t.Errorf("cmd missing %q: %q", want, gotCmd)
}
}
// Operator credentials, including the special-char password, ride in the
// decoded payload — never on the shell command line.
if strings.Contains(gotCmd, "s3cr3t!") {
t.Errorf("password leaked onto the command line: %q", gotCmd)
}
var payload struct {
Username string `json:"username"`
Password string `json:"password"`
Token string `json:"token"`
RememberMe bool `json:"rememberMe"`
}
decodePipedPayload(t, gotCmd, &payload)
if payload.Username != "admin" || payload.Password != "s3cr3t!" {
t.Errorf("login payload creds = %+v, want admin/s3cr3t!", payload)
}
if payload.Token != "" || payload.RememberMe {
t.Errorf("login payload token/rememberMe = %q/%v, want empty/false", payload.Token, payload.RememberMe)
}
}
func TestAMPAPILogin_HonoursConfiguredPort(t *testing.T) {
t.Parallel()
var gotCmd string
exec := &fnExecutor{fn: func(cmd string) (string, error) {
gotCmd = cmd
return `{"success":true,"sessionID":"x"}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "u", "p", 9999)
if _, err := c.login(); err != nil {
t.Fatalf("login: %v", err)
}
if !strings.Contains(gotCmd, "http://127.0.0.1:9999/API/Core/Login") {
t.Errorf("expected configured port 9999 in endpoint: %q", gotCmd)
}
}
func TestAMPAPILogin_FailedAuthIsError(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(string) (string, error) {
return `{"success":false,"resultReason":"Invalid username or password.","sessionID":""}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "wrong", 8081)
_, err := c.login()
if err == nil {
t.Fatal("expected error on failed auth")
}
if !strings.Contains(err.Error(), "Invalid username or password") {
t.Errorf("error should surface the AMP reason, got: %v", err)
}
}
func TestAMPAPILogin_ExecErrorIsWrapped(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(string) (string, error) {
return "curl: (7) Failed to connect", errors.New("exit status 7")
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
if _, err := c.login(); err == nil {
t.Fatal("expected error when exec fails")
}
}
func TestAMPAPILogin_GarbageResponseIsError(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(string) (string, error) { return "not json at all", nil }}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
if _, err := c.login(); err == nil {
t.Fatal("expected error on non-JSON response")
}
}
// ── setConfig ────────────────────────────────────────────────────────────────
func TestAMPAPISetConfig_LogsInThenSetsAndReusesSession(t *testing.T) {
t.Parallel()
var loginCalls, setCalls int
var setCmd string
exec := &fnExecutor{fn: func(cmd string) (string, error) {
switch {
case strings.Contains(cmd, "Core/Login"):
loginCalls++
return `{"success":true,"sessionID":"sess-9"}`, nil
case strings.Contains(cmd, "Core/SetConfig"):
setCalls++
setCmd = cmd
return `{"Status":true,"Reason":""}`, nil
default:
t.Fatalf("unexpected endpoint in cmd: %q", cmd)
return "", nil
}
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
node := "Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier"
if err := c.setConfig(node, "3.0"); err != nil {
t.Fatalf("first setConfig: %v", err)
}
if err := c.setConfig("Meta.GenericModule.WorldTitle", "My Sietch's Server"); err != nil {
t.Fatalf("second setConfig: %v", err)
}
if loginCalls != 1 {
t.Errorf("login called %d times, want 1 (session must be cached)", loginCalls)
}
if setCalls != 2 {
t.Errorf("setConfig issued %d POSTs, want 2", setCalls)
}
if !strings.Contains(setCmd, "/API/Core/SetConfig") {
t.Errorf("missing SetConfig endpoint: %q", setCmd)
}
var payload struct {
Node string `json:"node"`
Value string `json:"value"`
SessionID string `json:"SESSIONID"`
}
decodePipedPayload(t, setCmd, &payload)
if payload.Node != "Meta.GenericModule.WorldTitle" {
t.Errorf("node = %q, want Meta.GenericModule.WorldTitle", payload.Node)
}
if payload.Value != "My Sietch's Server" {
t.Errorf("value = %q, want the quote-containing title verbatim", payload.Value)
}
if payload.SessionID != "sess-9" {
t.Errorf("SESSIONID = %q, want sess-9", payload.SessionID)
}
}
func TestAMPAPISetConfig_StatusFalseIsError(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":true,"sessionID":"s"}`, nil
}
return `{"Status":false,"Reason":"No such node."}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
err := c.setConfig("Meta.GenericModule.Nope", "1")
if err == nil {
t.Fatal("expected error when Status is false")
}
if !strings.Contains(err.Error(), "No such node") {
t.Errorf("error should surface AMP reason, got: %v", err)
}
}
func TestAMPAPISetConfig_AcceptsBareBoolResult(t *testing.T) {
t.Parallel()
// Some AMP versions return a bare `true` from SetConfig rather than an
// ActionResult object.
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":true,"sessionID":"s"}`, nil
}
return `true`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
if err := c.setConfig("Meta.GenericModule.X", "1"); err != nil {
t.Errorf("bare true should be success, got: %v", err)
}
}
func TestAMPAPISetConfig_LoginFailureAborts(t *testing.T) {
t.Parallel()
setReached := false
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":false,"resultReason":"locked"}`, nil
}
setReached = true
return `{"Status":true}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
if err := c.setConfig("Meta.GenericModule.X", "1"); err == nil {
t.Fatal("expected error when login fails")
}
if setReached {
t.Error("setConfig must not POST when login fails")
}
}
// ── getConfig ────────────────────────────────────────────────────────────────
func TestAMPAPIGetConfig_ReturnsCurrentValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resp string
want string
}{
{"string value", `{"CurrentValue":"3.000000","Node":"x"}`, "3.000000"},
{"numeric value", `{"CurrentValue":42}`, "42"},
{"bool value", `{"CurrentValue":true}`, "true"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var getCmd string
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":true,"sessionID":"s"}`, nil
}
getCmd = cmd
return tt.resp, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
got, err := c.getConfig("Meta.GenericModule.X")
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if got != tt.want {
t.Errorf("CurrentValue = %q, want %q", got, tt.want)
}
if !strings.Contains(getCmd, "/API/Core/GetConfig") {
t.Errorf("missing GetConfig endpoint: %q", getCmd)
}
var payload struct {
Node string `json:"node"`
SessionID string `json:"SESSIONID"`
}
decodePipedPayload(t, getCmd, &payload)
if payload.Node != "Meta.GenericModule.X" || payload.SessionID != "s" {
t.Errorf("getConfig payload = %+v, want node X + session s", payload)
}
})
}
}
// ── setConfig session-expiry retry ───────────────────────────────────────────
// TestAMPAPISetConfig_RetriesOnSessionExpiry verifies that when SetConfig
// returns a session-expired rejection, the client clears its session, re-logs
// in, and retries the call — succeeding on the second attempt.
func TestAMPAPISetConfig_RetriesOnSessionExpiry(t *testing.T) {
t.Parallel()
firstSet := true
var loginCalls int
exec := &fnExecutor{fn: func(cmd string) (string, error) {
switch {
case strings.Contains(cmd, "Core/Login"):
loginCalls++
return `{"success":true,"sessionID":"fresh-sess"}`, nil
case strings.Contains(cmd, "Core/SetConfig"):
if firstSet {
firstSet = false
return `{"Status":false,"Reason":"Session has expired."}`, nil
}
return `{"Status":true}`, nil
default:
return "", nil
}
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
if err := c.setConfig("Meta.GenericModule.X", "1"); err != nil {
t.Errorf("expected retry to succeed on session expiry, got: %v", err)
}
if loginCalls != 2 {
t.Errorf("login called %d times, want 2 (initial + re-login on expiry)", loginCalls)
}
}
// TestAMPAPISetConfig_DoesNotRetryNonSessionError verifies that a SetConfig
// rejection unrelated to session expiry is returned immediately without retry.
func TestAMPAPISetConfig_DoesNotRetryNonSessionError(t *testing.T) {
t.Parallel()
setCalls := 0
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":true,"sessionID":"s"}`, nil
}
setCalls++
return `{"Status":false,"Reason":"No such node."}`, nil
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
err := c.setConfig("Meta.GenericModule.Bogus", "1")
if err == nil {
t.Fatal("expected error for no-such-node rejection")
}
if setCalls != 1 {
t.Errorf("non-session error must not trigger retry: SetConfig called %d times, want 1", setCalls)
}
}
// TestAMPAPISetConfig_ReloginFailurePropagates verifies that when re-login
// fails after a session expiry, the login error is returned rather than a
// silent success.
func TestAMPAPISetConfig_ReloginFailurePropagates(t *testing.T) {
t.Parallel()
var loginCalls int
exec := &fnExecutor{fn: func(cmd string) (string, error) {
switch {
case strings.Contains(cmd, "Core/Login"):
loginCalls++
if loginCalls > 1 {
return `{"success":false,"resultReason":"account locked"}`, nil
}
return `{"success":true,"sessionID":"s"}`, nil
case strings.Contains(cmd, "Core/SetConfig"):
return `{"Status":false,"Reason":"Session has expired."}`, nil
default:
return "", nil
}
}}
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
err := c.setConfig("Meta.GenericModule.X", "1")
if err == nil {
t.Fatal("expected error when re-login fails after session expiry")
}
if !strings.Contains(err.Error(), "account locked") {
t.Errorf("error should surface re-login reason, got: %v", err)
}
}

View File

@@ -0,0 +1,195 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"os/exec"
"os/user"
"strings"
"time"
)
// ampInstance describes a single AMP-managed game-server instance discovered
// via `ampinstmgr -l`. Used by the setup wizard to pre-fill prompts.
type ampInstance struct {
Name string // "DuneTest01"
Module string // "GenericModule", "DuneAwakening", etc.
Running bool
InContainer bool
DataPath string // "/home/amp/.ampdata/instances/DuneTest01"
}
// candidate AMP user accounts checked in order. First one that exists wins.
// Sites that use a custom AMP user will still get a manual fallback.
var ampUserCandidates = []string{"amp", "ampuser"}
// detectAmpInstances runs `sudo -u <amp_user> ampinstmgr -l`, parses the
// output, and returns the discovered instances along with the AMP user it
// found. Filters out the ADS module (that's AMP itself, not a game).
//
// Returns an empty slice (not an error) when ampinstmgr is not on PATH or
// the probe times out — the caller is expected to fall back to manual
// prompts in that case. Genuine parse errors are returned as errors so the
// operator sees them.
func detectAmpInstances() (instances []ampInstance, ampUser string, err error) {
if _, lookErr := exec.LookPath("ampinstmgr"); lookErr != nil {
return nil, "", nil
}
for _, candidate := range ampUserCandidates {
if _, lookupErr := user.Lookup(candidate); lookupErr == nil {
ampUser = candidate
break
}
}
if ampUser == "" {
return nil, "", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sudo", "-n", "-u", ampUser, "ampinstmgr", "-l")
out, runErr := cmd.CombinedOutput()
if runErr != nil {
// Non-fatal: probably needs interactive sudo or ampinstmgr crashed.
// Caller falls back to manual prompts.
return nil, ampUser, nil
}
instances = parseAmpInstmgrOutput(out)
return instances, ampUser, nil
}
// parseAmpInstmgrOutput parses the human-formatted output of `ampinstmgr -l`.
// Pure function — exported via package-internal callers and unit-tested with
// a golden fixture so changes to the output format are caught early.
//
// Output blocks look like:
//
// Instance Name │ DuneTest01
// Module │ GenericModule
// Running │ Yes
// Runs in Container │ Yes
// Data Path │ /home/amp/.ampdata/instances/DuneTest01
//
// The separator is the Unicode box-drawing character │ (U+2502) which AMP
// emits regardless of locale. We also accept "|" as a fallback in case a
// future ampinstmgr release drops Unicode in batch mode.
//
// ADS instances (AMP itself) are filtered out — the wizard targets game
// servers.
func parseAmpInstmgrOutput(out []byte) []ampInstance {
var instances []ampInstance
scanner := bufio.NewScanner(strings.NewReader(string(out)))
scanner.Buffer(make([]byte, 0, 1024), 1024*1024)
current := ampInstance{}
flush := func() {
if current.Name != "" && !strings.EqualFold(current.Module, "ADS") {
instances = append(instances, current)
}
current = ampInstance{}
}
for scanner.Scan() {
raw := scanner.Text()
line := strings.TrimSpace(raw)
if line == "" {
flush()
continue
}
key, val, ok := splitAmpKV(line)
if !ok {
continue
}
switch key {
case "Instance Name":
current.Name = val
case "Module":
current.Module = val
case "Running":
current.Running = strings.EqualFold(val, "Yes")
case "Runs in Container":
current.InContainer = strings.EqualFold(val, "Yes")
case "Data Path":
current.DataPath = val
}
}
flush()
return instances
}
// splitAmpKV splits a "Key │ Value" line on the Unicode box character (or
// ASCII pipe as a fallback). Returns key, value, and whether the split
// succeeded.
func splitAmpKV(line string) (string, string, bool) {
for _, sep := range []string{"│", "|"} {
if i := strings.Index(line, sep); i >= 0 {
key := strings.TrimSpace(line[:i])
val := strings.TrimSpace(line[i+len(sep):])
return key, val, true
}
}
return "", "", false
}
// probeGameRoot inspects a running container to discover the game install
// path under /AMP/. Most AMP modules put the game at /AMP/<game-name>/ but
// the exact <game-name> depends on the module (e.g. "duneawakening" for the
// official CubeCoders Dune Awakening module). Rather than hardcoding that
// suffix in the wizard, we list /AMP/ inside the container with -F to mark
// directories with a trailing slash, then pick the first directory entry.
// Returns "" + nil when the probe cannot answer authoritatively (container
// not running, sudo prompts, non-standard layout) — caller falls back to
// the historical default.
func probeGameRoot(ctx context.Context, ampUser, container string) (string, error) {
if ampUser == "" || container == "" {
return "", errors.New("ampUser and container are required")
}
// Use `sudo -n -i -u <ampUser>` so sudo enters amp's login shell and
// chdirs to amp's home before exec'ing — otherwise the calling user's
// cwd typically isn't readable by amp ("cannot chdir to /home/X: …").
// -F appends "/" to directory entries; -1 forces one-per-line output.
// Use Output() (not CombinedOutput) so any residual stderr doesn't
// poison the directory list.
cmd := exec.CommandContext(ctx, "sudo", "-n", "-i", "-u", ampUser, "podman", "exec", container, "ls", "-1F", "/AMP/")
out, err := cmd.Output()
if err != nil {
// Probe failure (sudo prompt, container down, exec denied, etc.) —
// caller falls back to defaults.
return "", nil
}
for _, entry := range strings.Split(strings.TrimSpace(string(out)), "\n") {
entry = strings.TrimSpace(entry)
// Skip blanks, the lost+found dir, and anything ls -F didn't tag as
// a directory (file entries don't end in "/").
if entry == "" || !strings.HasSuffix(entry, "/") {
continue
}
dirName := strings.TrimSuffix(entry, "/")
if dirName == "" || strings.HasPrefix(dirName, "lost+found") ||
strings.HasPrefix(dirName, "AMP_Logs") || dirName == "Backups" {
// Skip known AMP-meta directories — we want the game folder.
continue
}
return "/AMP/" + dirName, nil
}
return "", nil
}
// summarizeInstance returns a single-line description suitable for the
// instance picker in the setup wizard.
func summarizeInstance(inst ampInstance) string {
topology := "native"
if inst.InContainer {
topology = "container"
}
status := "stopped"
if inst.Running {
status = "running"
}
return fmt.Sprintf("%s (module=%s, %s, %s)", inst.Name, inst.Module, topology, status)
}

View File

@@ -0,0 +1,151 @@
package main
import (
"reflect"
"strings"
"testing"
)
// Golden fixture captured from a real `sudo -u amp ampinstmgr -l` run on an
// AMP host running both an ADS instance (the AMP control panel itself) and a
// GenericModule Dune instance. The parser should filter out ADS and surface
// only the game instance.
const ampInstmgrSampleOutput = `[Info/1] AMP Instance Manager v2.7.2.8 built 20/05/2026 06:54
[Info/1] Stream: Mainline / Release - built by CUBECODERS/buildbot on CCL-DEV
cannot chdir to /home/test: Permission denied
Instance ID │ 88fe1020-71ed-4789-b390-c03a165f5630
Module │ ADS
Instance Name │ ADS01
Friendly Name │ ADS01
URL │ http://127.0.0.1:8080/
Running │ Yes
Runs in Container │ No
Runs as Shared │ No
Start on Boot │ Yes
AMP Version │ 2.7.2.8
Release Stream │ Mainline
Data Path │ /home/amp/.ampdata/instances/ADS01
Instance ID │ 0f8247da-f1c9-4898-a806-8017beeb15e7
Module │ GenericModule
Instance Name │ DuneTest01
Friendly Name │ DuneTest
URL │ http://127.0.0.1:8081/
Running │ No
Runs in Container │ Yes
Runs as Shared │ No
Start on Boot │ Yes
AMP Version │ 2.7.2.8
Release Stream │ Mainline
Data Path │ /home/amp/.ampdata/instances/DuneTest01
`
func TestParseAmpInstmgrOutput_FiltersADSAndKeepsGame(t *testing.T) {
got := parseAmpInstmgrOutput([]byte(ampInstmgrSampleOutput))
want := []ampInstance{
{
Name: "DuneTest01",
Module: "GenericModule",
Running: false,
InContainer: true,
DataPath: "/home/amp/.ampdata/instances/DuneTest01",
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseAmpInstmgrOutput: got %#v, want %#v", got, want)
}
}
func TestParseAmpInstmgrOutput_MultipleGameInstances(t *testing.T) {
in := `Instance Name │ DuneLive01
Module │ DuneAwakening
Running │ Yes
Runs in Container │ No
Data Path │ /home/amp/.ampdata/instances/DuneLive01
Instance Name │ DunePTS
Module │ DuneAwakening
Running │ No
Runs in Container │ Yes
Data Path │ /home/amp/.ampdata/instances/DunePTS
`
got := parseAmpInstmgrOutput([]byte(in))
if len(got) != 2 {
t.Fatalf("expected 2 instances, got %d: %#v", len(got), got)
}
if got[0].Name != "DuneLive01" || !got[0].Running || got[0].InContainer {
t.Errorf("first instance wrong: %#v", got[0])
}
if got[1].Name != "DunePTS" || got[1].Running || !got[1].InContainer {
t.Errorf("second instance wrong: %#v", got[1])
}
}
func TestParseAmpInstmgrOutput_EmptyOrGarbage(t *testing.T) {
cases := []struct {
name string
in string
}{
{"empty", ""},
{"banner only", "[Info/1] AMP Instance Manager v2.7.2.8\n"},
{"unrelated lines", "hello\nworld\nno separators here\n"},
{"missing instance name", "Module │ DuneAwakening\nRunning │ Yes\n"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseAmpInstmgrOutput([]byte(c.in))
if len(got) != 0 {
t.Errorf("expected 0 instances, got %d: %#v", len(got), got)
}
})
}
}
func TestParseAmpInstmgrOutput_AsciiPipeFallback(t *testing.T) {
// Future-proofing: if ampinstmgr ever drops Unicode in scripted mode and
// switches to plain ASCII pipes, we should still parse it.
in := `Instance Name | DuneFallback
Module | DuneAwakening
Running | Yes
Runs in Container | Yes
Data Path | /home/amp/.ampdata/instances/DuneFallback
`
got := parseAmpInstmgrOutput([]byte(in))
if len(got) != 1 || got[0].Name != "DuneFallback" {
t.Errorf("ASCII-pipe parsing failed: %#v", got)
}
}
func TestSplitAmpKV(t *testing.T) {
cases := []struct {
line string
key string
val string
ok bool
}{
{"Instance Name │ DuneTest01", "Instance Name", "DuneTest01", true},
{"Module | DuneAwakening", "Module", "DuneAwakening", true},
{"no separator here", "", "", false},
{" │ ", "", "", true}, // edge case: empty key/val but valid split
}
for _, c := range cases {
t.Run(c.line, func(t *testing.T) {
k, v, ok := splitAmpKV(c.line)
if ok != c.ok || k != c.key || v != c.val {
t.Errorf("splitAmpKV(%q) = (%q, %q, %v); want (%q, %q, %v)",
c.line, k, v, ok, c.key, c.val, c.ok)
}
})
}
}
func TestSummarizeInstance(t *testing.T) {
got := summarizeInstance(ampInstance{
Name: "DuneTest01", Module: "GenericModule", Running: true, InContainer: true,
})
for _, want := range []string{"DuneTest01", "GenericModule", "container", "running"} {
if !strings.Contains(got, want) {
t.Errorf("summary %q missing %q", got, want)
}
}
}

View File

@@ -0,0 +1,48 @@
package main
import (
"crypto/tls"
"fmt"
"net"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// brokerCredentials returns the configured AMQP username and password.
// Both BROKER_USER and BROKER_PASS (or config equivalents) are required.
func brokerCredentials() (user, pass string, err error) {
user = brokerUser
pass = brokerPass
if user == "" || pass == "" {
return "", "", fmt.Errorf("broker credentials are required: set BROKER_USER and BROKER_PASS")
}
return user, pass, nil
}
// dialAMQP connects to an AMQP broker at addr. TCP is routed through the
// global executor so it works for both direct and SSH-tunnelled connections.
func dialAMQP(addr, user, pass string, useTLS bool) (*amqp.Connection, error) {
cfg := amqp.Config{
SASL: []amqp.Authentication{
&amqp.PlainAuth{Username: user, Password: pass},
},
Vhost: "/",
Locale: "en_US",
Heartbeat: 10 * time.Second,
Dial: func(_, _ string) (net.Conn, error) {
if globalExecutor != nil {
return globalExecutor.Dial("tcp", addr)
}
if globalSSH != nil {
return globalSSH.Dial("tcp", addr)
}
return net.Dial("tcp", addr)
},
}
if useTLS {
cfg.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
return amqp.DialConfig("amqps://"+addr+"/", cfg)
}
return amqp.DialConfig("amqp://"+addr+"/", cfg)
}

View File

@@ -0,0 +1,7 @@
package main
// Msg and Cmd replace charm.land/bubbletea/v2's tea.Msg and tea.Cmd so that
// db.go and ssh.go can drop the bubbletea dependency while keeping their
// existing return-type signatures.
type Msg = any
type Cmd = func() Msg

View File

@@ -0,0 +1,203 @@
package main
import (
"context"
"fmt"
"log"
"net"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/ssh"
)
var (
// Legacy globals kept for K8s path (globalSSH/globalPod*) and for the
// shared DB pool (globalDB). New code should use globalExecutor/globalControl.
globalSSH *ssh.Client
globalDB *pgxpool.Pool
globalPodIP string
globalPodNS string
globalPod string
globalExecutor Executor
globalControl ControlPlane
)
// resolveControl returns the effective control plane name based on config,
// defaulting to "kubectl" when SSH is configured and "local" otherwise.
func resolveControl() string {
if controlPlane != "" {
return controlPlane
}
if sshHost != "" {
return "kubectl"
}
return "local"
}
// connectAll creates the executor, control plane, and DB connection, then sets
// all globals. Called from main() and handleReconnect.
func connectAll() error {
ctrl := resolveControl()
// Start from the full loaded config so provider-specific fields
// (docker_*, cmd_*) that have no flag/env equivalent are preserved.
cfg := loadedConfig
cfg.SSHHost = sshHost
cfg.SSHUser = sshUser
cfg.SSHKey = resolveKeyPath()
cfg.DBHost = dbHost
cfg.DBPort = dbPort
cfg.DBUser = dbUser
cfg.DBPass = dbPass
cfg.DBName = dbName
cfg.DBSchema = dbSchema
cfg.Control = ctrl
cfg.ControlNamespace = controlNS
cfg.BrokerGameAddr = brokerGameAddr
cfg.BrokerAdminAddr = brokerAdminAddr
cfg.BrokerTLS = brokerTLS
cfg.BackupDir = backupDir
cfg.ServerIniDir = serverIniDir
exec, err := newExecutor(cfg.SSHHost, cfg.SSHUser, cfg.SSHKey)
if err != nil {
return fmt.Errorf("executor: %w", err)
}
// AMP mode wraps the executor to elevate WriteFile through sudo.
// Applies regardless of whether the inner executor is local or SSH.
if ctrl == "amp" {
user := cfg.AmpUser
if user == "" {
user = "amp"
}
exec = &ampExecutor{Executor: exec, ampUser: user}
}
globalExecutor = exec
// kubectl needs DB-pod discovery (via the executor, not the DB) to learn the
// namespace before the control plane and DB connect. A discovery failure is
// fatal — without a namespace there is nothing to drive the control plane.
if ctrl == "kubectl" {
ns, pod, podIP, err := discoverDBPod(exec)
if err != nil {
exec.Close()
globalExecutor = nil
return fmt.Errorf("DB pod discovery: %w", err)
}
globalPodNS = ns
globalPod = pod
globalPodIP = podIP
// Propagate discovered namespace so kubectlControl can use it.
if cfg.ControlNamespace == "" {
cfg.ControlNamespace = ns
controlNS = ns
}
if s, ok := exec.(*sshExecutor); ok {
globalSSH = s.client
}
}
// The control plane (logs, battlegroup, server control) does not depend on
// the database. Establish it before connecting the DB so a DB outage never
// disables it — the DB can be re-established later via /api/v1/reconnect
// without losing control-plane functionality.
globalControl = newControlPlane(ctrl, cfg)
// DB connect is best-effort: on failure keep the executor + control plane
// intact and return the error so the caller can surface it (main starts the
// server anyway; the systemd watchdog or a manual reconnect retries the DB).
var pool *pgxpool.Pool
if ctrl == "kubectl" {
pool, err = connectDB(context.Background(), cfg.DBUser, cfg.DBPass)
} else {
pool, err = connectDBDirect(context.Background(), cfg)
}
if err != nil {
globalDB = nil
return fmt.Errorf("DB connect: %w", err)
}
globalDB = pool
// Best-effort: ensure the GM/Server chat persona exists for admin messaging
// (whisper, map announce). Idempotent (ON CONFLICT DO NOTHING); a failure here
// must never block startup or reconnect, so it is logged and swallowed.
if err := cmdEnsureGMIdentity(context.Background()); err != nil {
log.Printf("connectAll: ensure GM identity: %v", err)
}
return nil
}
// cmdConnect wraps connectAll in the legacy Msg return type.
func cmdConnect() Msg {
if err := connectAll(); err != nil {
return msgConnect{err: err}
}
return msgConnect{}
}
// connectDBDirect opens a pgxpool without SSH tunnelling, routing TCP through
// the executor's Dial (which is net.Dial for local, SSH tunnel for SSH).
func connectDBDirect(ctx context.Context, cfg appConfig) (*pgxpool.Pool, error) {
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPass, cfg.DBName)
poolCfg, err := pgxpool.ParseConfig(connStr)
if err != nil {
return nil, err
}
poolCfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, fmt.Sprintf(`SET search_path TO %s, public`, pgx.Identifier{cfg.DBSchema}.Sanitize()))
return err
}
if globalExecutor != nil {
addr := fmt.Sprintf("%s:%d", cfg.DBHost, cfg.DBPort)
poolCfg.ConnConfig.DialFunc = func(ctx context.Context, _, _ string) (net.Conn, error) {
return globalExecutor.Dial("tcp", addr)
}
}
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
dbUser = cfg.DBUser
dbPass = cfg.DBPass
return pool, nil
}
func connectDB(ctx context.Context, user, pass string) (*pgxpool.Pool, error) {
connStr := fmt.Sprintf(
"host=127.0.0.1 port=%d user=%s password=%s dbname=%s sslmode=disable",
dbPort, user, pass, dbName)
poolCfg, err := pgxpool.ParseConfig(connStr)
if err != nil {
return nil, err
}
poolCfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, fmt.Sprintf(`SET search_path TO %s, public`, pgx.Identifier{dbSchema}.Sanitize()))
return err
}
poolCfg.ConnConfig.LookupFunc = func(_ context.Context, _ string) ([]string, error) {
return []string{globalPodIP}, nil
}
poolCfg.ConnConfig.DialFunc = func(_ context.Context, _, _ string) (net.Conn, error) {
return globalSSH.Dial("tcp", fmt.Sprintf("%s:%d", globalPodIP, dbPort))
}
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
dbUser = user
dbPass = pass
return pool, nil
}

View File

@@ -0,0 +1,73 @@
package main
import "testing"
// TestConnectAll_ControlPlaneSurvivesDBFailure verifies that a DB connection
// failure leaves the control plane and executor established. The control plane
// (logs / battlegroup / server control) does not depend on the database, so a
// DB outage must not disable it — the DB can be re-established later via
// /api/v1/reconnect without losing control-plane functionality.
func TestConnectAll_ControlPlaneSurvivesDBFailure(t *testing.T) {
// connectAll mutates package-level globals — must not run in parallel.
origCP, origSSH := controlPlane, sshHost
origDBHost, origDBPort := dbHost, dbPort
origDBUser, origDBPass, origDBName, origDBSchema := dbUser, dbPass, dbName, dbSchema
origCfg := loadedConfig
origDB, origExec, origCtl := globalDB, globalExecutor, globalControl
t.Cleanup(func() {
controlPlane, sshHost = origCP, origSSH
dbHost, dbPort = origDBHost, origDBPort
dbUser, dbPass, dbName, dbSchema = origDBUser, origDBPass, origDBName, origDBSchema
loadedConfig = origCfg
globalDB, globalExecutor, globalControl = origDB, origExec, origCtl
})
controlPlane = "local" // local executor needs no network; control plane is pure
sshHost = ""
dbHost, dbPort = "127.0.0.1", 1 // nothing listens on :1 -> immediate connection refused
dbUser, dbPass, dbName, dbSchema = "t", "t", "t", "t"
loadedConfig = appConfig{}
globalDB, globalExecutor, globalControl = nil, nil, nil
err := connectAll()
if err == nil {
t.Fatal("expected connectAll to report the DB failure (closed port)")
}
if globalControl == nil {
t.Error("control plane must be established despite DB failure (it does not depend on the DB)")
}
if globalExecutor == nil {
t.Error("executor must remain established despite DB failure")
}
if globalDB != nil {
t.Error("globalDB must be nil when the DB connect failed")
}
}
func TestResolveControl(t *testing.T) {
origControlPlane := controlPlane
origSSHHost := sshHost
t.Cleanup(func() {
controlPlane = origControlPlane
sshHost = origSSHHost
})
controlPlane = "amp"
sshHost = ""
if got := resolveControl(); got != "amp" {
t.Fatalf("expected explicit control plane to win, got %q", got)
}
controlPlane = ""
sshHost = "vm.example:22"
if got := resolveControl(); got != "kubectl" {
t.Fatalf("expected ssh host to default control to kubectl, got %q", got)
}
controlPlane = ""
sshHost = ""
if got := resolveControl(); got != "local" {
t.Fatalf("expected local default without ssh/control flags, got %q", got)
}
}

View File

@@ -0,0 +1,151 @@
package main
import (
"context"
"fmt"
)
// ControlPlane abstracts the server management layer. It determines WHAT
// commands to run (kubectl, docker, local shell) while the Executor determines
// WHERE they run (locally or over SSH).
type ControlPlane interface {
// Name returns the control plane identifier for status reporting.
Name() string
// GetStatus returns the battlegroup status and per-server runtime stats.
GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error)
// ExecCommand runs a lifecycle command: start, stop, restart, update, backup.
ExecCommand(ctx context.Context, exec Executor, cmd string) (string, error)
// ListProcesses returns running processes/pods/containers and a context label.
ListProcesses(ctx context.Context, exec Executor) ([]ProcessInfo, string, error)
// ListLogSources returns available log sources (pods, containers, services).
ListLogSources(ctx context.Context, exec Executor) ([]LogSource, error)
// StreamLog opens a log stream for the named source. The caller must invoke
// cancel when done to release the underlying session/process.
StreamLog(ctx context.Context, exec Executor, ns, name string) (<-chan string, func(), error)
// CaptureJWT extracts the ServiceAuthToken from the game daemon and returns
// a HostId and freshly-signed JWT for broker authentication.
CaptureJWT(ctx context.Context, exec Executor) (hostID, token string, err error)
// EvalOnGameBroker runs an Erlang expression via rabbitmqctl eval inside the
// mq-game broker. Used for publishing server commands with user_id="fls",
// which AMQP connections cannot set (broker validates UserId against auth'd user).
EvalOnGameBroker(ctx context.Context, exec Executor, expr string) (string, error)
// DiscoverIniDir returns the directory containing UserGame.ini and
// UserOverrides.ini. kubectl auto-discovers this from k3s storage;
// docker and local require server_ini_dir to be set in config.
DiscoverIniDir(ctx context.Context, exec Executor) (string, error)
// ReadDefaultINI reads DefaultGame.ini or DefaultEngine.ini from inside the
// game container/pod, where the file lives as part of the image. Returns the
// file contents or "" if unavailable. The local control plane returns "" and
// lets the host-path fallback handle it.
ReadDefaultINI(ctx context.Context, exec Executor, filename string) string
}
// ── Types shared across control plane implementations ─────────────────────────
type BattlegroupStatus struct {
Name string `json:"name"`
Title string `json:"title"`
Phase string `json:"phase"`
Database string `json:"database"`
Servers []ServerRow `json:"servers"`
}
type ServerRow struct {
Map string `json:"map"`
Sietch string `json:"sietch"`
Dimension int `json:"dimension"`
Partition int `json:"partition"`
Phase string `json:"phase"`
Ready bool `json:"ready"`
Players int `json:"players"`
PlayerHardCap int `json:"playerHardCap"`
Queue int `json:"queue"`
// Port is the game-server UDP port parsed from the process args (0 if
// unknown). AgeSeconds is how long the process has been running, sourced
// best-effort from `ps -o etimes=` (0 when unavailable, e.g. non-AMP planes).
Port int `json:"port,omitempty"`
AgeSeconds int `json:"ageSeconds,omitempty"`
}
type ProcessInfo struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Status string `json:"status,omitempty"`
}
type LogSource struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
}
// ── Factory ───────────────────────────────────────────────────────────────────
// newControlPlane returns the appropriate ControlPlane based on the control
// name ("kubectl", "docker", "local", "amp"). Unrecognised names fall back to local.
func newControlPlane(name string, cfg appConfig) ControlPlane {
switch name {
case "kubectl":
return &kubectlControl{namespace: cfg.ControlNamespace}
case "docker":
return &dockerControl{
gameserver: cfg.DockerGameserver,
brokerGame: cfg.DockerBrokerGame,
brokerAdmin: cfg.DockerBrokerAdmin,
}
case "amp":
user := cfg.AmpUser
if user == "" {
user = "amp"
}
container := cfg.AmpContainer
if container == "" && cfg.AmpInstance != "" {
container = "AMP_" + cfg.AmpInstance
}
// Default to container mode (CubeCoders' standard template) unless the
// admin explicitly opts out.
useContainer := true
if cfg.AmpUseContainer != nil {
useContainer = *cfg.AmpUseContainer
}
return &ampControl{
instance: cfg.AmpInstance,
container: container,
ampUser: user,
logPath: cfg.AmpLogPath,
directorURL: cfg.DirectorURL,
iniDir: cfg.ServerIniDir,
useContainer: useContainer,
containerRuntime: cfg.AmpContainerRuntime,
dataRoot: cfg.AmpDataRoot,
apiUser: cfg.AmpAPIUser,
apiPass: cfg.AmpAPIPass,
apiPort: cfg.AmpAPIPort,
pgBin: cfg.AmpPgBin,
pgLib: cfg.AmpPgLib,
}
default:
return &localControl{
cmdStart: cfg.CmdStart,
cmdStop: cfg.CmdStop,
cmdRestart: cfg.CmdRestart,
cmdStatus: cfg.CmdStatus,
controlNamespace: cfg.ControlNamespace,
brokerExecPrefix: cfg.BrokerExecPrefix,
}
}
}
// errNotSupported returns a consistent "not supported" error for control plane
// methods that are not available in a given implementation.
func errNotSupported(control, method string) error {
return fmt.Errorf("%s control plane does not support %s", control, method)
}

View File

@@ -0,0 +1,836 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// ampControl implements ControlPlane for CubeCoders AMP installations. AMP can
// run the game server in two modes:
//
// - containerised: game processes run inside a container (podman or docker).
// Log/INI access and broker control require `<runtime> exec`; choose the
// runtime with containerRuntime. Set useContainer = true.
// - native: game processes run directly on the host as the AMP user. Logs and
// INI files are on the host filesystem; rabbitmqctl is on the host PATH. Set
// useContainer = false.
//
// Process discovery (GetStatus/ListProcesses/CaptureJWT) is identical in both
// modes — game-server processes appear in the host's `ps` output regardless.
//
// All instance- and container-specific names come from config; this provider
// is not specialised to any particular AMP install.
type ampControl struct {
instance string // ampinstmgr instance name (e.g. "MehDune01")
container string // container name (only used when useContainer=true)
ampUser string // OS user that owns the AMP instance (default "amp")
logPath string // log directory — in-container path if containerised, host path if native
directorURL string // optional Battlegroup Director URL for status/exchange discovery
iniDir string // host path to UserGame.ini directory (configured)
useContainer bool // true: wrap in-container ops in `<runtime> exec`; false: run on host directly
containerRuntime string // "podman" (default) or "docker"; CLI for `<rt> exec` in container mode
dataRoot string // per-game data root (default /AMP/duneawakening)
// AMP Web API credentials — used to write server settings through AMP's own
// config (Core/SetConfig) so they survive AMP regenerating the game INIs.
apiUser string
apiPass string
apiPort int // 0 → defaultAmpAPIPort (8081)
// Postgres client tooling inside the container, for #150 DB backups. The
// game's PG17 ships a musl pg_dump under pgBin, but its libpq dir lacks the
// compression/SSL libs — those live in the sibling db-utils tree, so pgLib is
// a colon-joined path spanning both. Empty → validated AMP defaults.
pgBin string // dir containing pg_dump/pg_restore
pgLib string // LD_LIBRARY_PATH for the above
}
const (
defaultAmpPgBin = "/AMP/duneawakening/extracted/postgres/usr/local/bin"
defaultAmpPgLib = "/AMP/duneawakening/extracted/postgres/usr/local/lib:" +
"/AMP/duneawakening/extracted/db-utils/usr/lib"
)
func (c *ampControl) pgBinDir() string {
if c.pgBin != "" {
return c.pgBin
}
return defaultAmpPgBin
}
func (c *ampControl) pgLibPath() string {
if c.pgLib != "" {
return c.pgLib
}
return defaultAmpPgLib
}
func (c *ampControl) Name() string { return "amp" }
// ── status & lifecycle ────────────────────────────────────────────────────────
var (
ampPortRe = regexp.MustCompile(`-Port=(\d+)`)
ampPartRe = regexp.MustCompile(`-PartitionIndex=(\d+)`)
)
func (c *ampControl) GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error) {
procs, err := c.listGameProcesses(exec)
if err != nil {
return nil, err
}
// The host process args only carry -PartitionIndex, never a dimension. The
// Battlegroup Director knows each partition's dimensionIndex and label, so
// enrich rows from there. Best-effort: a missing/unreachable director just
// leaves Dimension at zero.
dirMeta, err := c.fetchDirectorPartitions(ctx, exec)
if err != nil {
log.Printf("ampControl.GetStatus: director enrichment unavailable: %v", err)
}
pids := make([]int, 0, len(procs))
for _, p := range procs {
pids = append(pids, p.pid)
}
ages := c.fetchProcessAges(exec, pids)
servers := make([]ServerRow, 0, len(procs))
for _, p := range procs {
row := ServerRow{
Map: p.mapName,
Partition: p.partition,
Phase: "Running",
Ready: true,
Players: 0,
Port: p.port,
AgeSeconds: ages[p.pid],
}
if meta, ok := dirMeta[p.partition]; ok {
row.Dimension = meta.dimension
row.Players = meta.players
row.PlayerHardCap = meta.playerHardCap
row.Queue = meta.queue
if meta.label != "" {
row.Sietch = meta.label
}
}
servers = append(servers, row)
}
dbPhase := "Disconnected"
if globalDB != nil {
dbPhase = "Connected"
}
return &BattlegroupStatus{
Name: c.container,
Title: "AMP Managed",
Phase: "Running",
Database: dbPhase,
Servers: servers,
}, nil
}
// partitionMeta is director-sourced metadata for one game-server partition.
type partitionMeta struct {
dimension int
label string
players int
playerHardCap int
queue int
}
// fetchDirectorPartitions queries the Battlegroup Director's /v0/battlegroup
// endpoint and returns a map of partitionId → metadata. It returns nil (no
// error) when no director URL is configured; transport, status, and decode
// failures are returned as errors so the caller can log them and continue.
func (c *ampControl) fetchDirectorPartitions(ctx context.Context, exec Executor) (map[int]partitionMeta, error) {
if c.directorURL == "" {
return nil, nil
}
endpoint := strings.TrimRight(c.directorURL, "/") + "/v0/battlegroup"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build director request: %w", err)
}
// Route through the executor so the director is reachable from wherever the
// executor runs (e.g. the AMP box over SSH), not the dune-admin host. Status
// polling must stay snappy, so a short timeout falls back fast.
client := &http.Client{Timeout: 3 * time.Second, Transport: httpTransportVia(exec.Dial)}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("query director: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("director returned status %d", resp.StatusCode)
}
var raw map[string]any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode director response: %w", err)
}
meta := map[int]partitionMeta{}
collectPartitions(raw, meta)
return meta, nil
}
// collectPartitions recursively walks a decoded director response, recording
// the dimensionIndex and label of every "partition" object it finds keyed by
// partitionId. This is structure-agnostic: it picks up single-server,
// dimension (serversByDimension), and instanced (instances) maps alike.
func collectPartitions(v any, out map[int]partitionMeta) {
switch t := v.(type) {
case map[string]any:
if p, ok := t["partition"].(map[string]any); ok {
if id, ok := jsonPartitionID(p["partitionId"]); ok {
// Player count, queue, and caps are siblings of "partition" on
// the server node.
out[id] = partitionMeta{
dimension: jsonInt(p["dimensionIndex"]),
label: jsonString(p["label"]),
players: jsonInt(t["numPlayersInGame"]),
playerHardCap: effectivePlayerHardCap(t),
queue: jsonInt(t["numPlayersInQueue"]),
}
}
}
for _, child := range t {
collectPartitions(child, out)
}
case []any:
for _, child := range t {
collectPartitions(child, out)
}
}
}
// jsonPartitionID extracts a partition ID from a decoded JSON number, reporting
// whether the value was present and numeric (a partition ID may legitimately
// be 0, so absence must be distinguished from zero).
func jsonPartitionID(v any) (int, bool) {
f, ok := v.(float64)
if !ok {
return 0, false
}
return int(f), true
}
// jsonInt coerces a decoded JSON number to int, returning 0 for non-numbers.
func jsonInt(v any) int {
f, _ := v.(float64)
return int(f)
}
// jsonString coerces a decoded JSON value to string, returning "" otherwise.
func jsonString(v any) string {
s, _ := v.(string)
return s
}
// effectivePlayerHardCap resolves a server node's player cap: the per-server
// override (serverPlayerHardCap) wins when positive, otherwise the configured
// cap (cfg.playerHardCap). The director uses -1 for "no override".
func effectivePlayerHardCap(node map[string]any) int {
if override := jsonInt(node["serverPlayerHardCap"]); override > 0 {
return override
}
if cfg, ok := node["cfg"].(map[string]any); ok {
return jsonInt(cfg["playerHardCap"])
}
return 0
}
func (c *ampControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
if c.instance == "" {
return "", fmt.Errorf("amp control plane requires amp_instance to be set")
}
switch cmd {
case "start":
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -s %s 2>&1", c.ampUser, c.instance))
case "stop":
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -q %s 2>&1", c.ampUser, c.instance))
case "restart":
return c.restartGame(exec)
default:
return "", fmt.Errorf("amp control does not support %q", cmd)
}
}
// restartGame cycles the game server so config changes (CVars / UPROPERTYs)
// actually take effect.
//
// In container mode it restarts the whole AMP container. This is deliberate:
// `ampinstmgr -q` does NOT reap the DuneSandboxServer processes — confirmed
// in-game, where the game kept 4d+ uptime through both dune-admin's old restart
// AND AMP's own Stop, so any setting needing a game restart never applied. A
// `<runtime> restart` is the proven action that actually recycles the game, and
// it preserves the container filesystem so AMP regenerates the game INIs from
// its config on the way back up. Blast radius: this briefly cycles the
// in-container Postgres and broker too — dune-admin reconnects to the DB after.
//
// In native mode (no container) the game runs as host processes ampinstmgr
// manages directly, so the stop/start cycle is retained.
func (c *ampControl) restartGame(exec Executor) (string, error) {
if c.useContainer {
if c.container == "" {
return "", fmt.Errorf("amp control in container mode requires amp_container to be set")
}
return exec.Exec(fmt.Sprintf("sudo -i -u %s %s restart %s 2>&1",
c.ampUser, c.runtimeCLI(), c.container))
}
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -q %s 2>&1 && sudo -i -u %s ampinstmgr -s %s 2>&1",
c.ampUser, c.instance, c.ampUser, c.instance))
}
// ── database backup/restore (#150) ──────────────────────────────────────────
// pgDumpCommand builds the host shell command that runs pg_dump (-Fc) inside the
// container, redirecting its stdout to a host file. The '>' redirect is handled
// by the outer host shell (run by the dune-admin service user), so the dump
// lands host-side and service-user-owned.
func (c *ampControl) pgDumpCommand(conn dbConn, destPath string) string {
inner := fmt.Sprintf(
"%s exec -e PGPASSWORD=%s -e LD_LIBRARY_PATH=%s %s %s -Fc -h %s -p %d -U %s -d %s",
c.runtimeCLI(),
shellQuote(conn.Pass),
shellQuote(c.pgLibPath()),
shellQuote(c.container),
shellQuote(c.pgBinDir()+"/pg_dump"),
shellQuote(conn.Host), conn.Port, shellQuote(conn.User), shellQuote(conn.Name),
)
return fmt.Sprintf("sudo -i -u %s %s > %s", c.ampUser, inner, shellQuote(destPath))
}
// pgRestoreCommand builds the host shell command that pipes a host dump file into
// pg_restore (--clean --if-exists) running inside the container. DESTRUCTIVE:
// the caller must ensure the game is stopped.
func (c *ampControl) pgRestoreCommand(conn dbConn, srcPath string) string {
inner := fmt.Sprintf(
"%s exec -i -e PGPASSWORD=%s -e LD_LIBRARY_PATH=%s %s %s --clean --if-exists --no-owner -h %s -p %d -U %s -d %s",
c.runtimeCLI(),
shellQuote(conn.Pass),
shellQuote(c.pgLibPath()),
shellQuote(c.container),
shellQuote(c.pgBinDir()+"/pg_restore"),
shellQuote(conn.Host), conn.Port, shellQuote(conn.User), shellQuote(conn.Name),
)
return fmt.Sprintf("sudo -i -u %s %s < %s", c.ampUser, inner, shellQuote(srcPath))
}
// BackupDatabase runs pg_dump in-container and writes the archive to destPath on
// the host. Implements dbBackupProvider.
func (c *ampControl) BackupDatabase(exec Executor, conn dbConn, destPath string) (string, error) {
if !c.useContainer || c.container == "" {
return "", fmt.Errorf("AMP database backup requires container mode (amp_container)")
}
out, err := exec.Exec(c.pgDumpCommand(conn, destPath))
if err != nil {
return out, fmt.Errorf("pg_dump: %w", err)
}
return out, nil
}
// RestoreDatabase pipes a host dump into pg_restore in-container. DESTRUCTIVE.
// Implements dbBackupProvider.
func (c *ampControl) RestoreDatabase(exec Executor, conn dbConn, srcPath string) (string, error) {
if !c.useContainer || c.container == "" {
return "", fmt.Errorf("AMP database restore requires container mode (amp_container)")
}
out, err := exec.Exec(c.pgRestoreCommand(conn, srcPath))
if err != nil {
return out, fmt.Errorf("pg_restore: %w", err)
}
return out, nil
}
// ── process & log discovery ───────────────────────────────────────────────────
type ampGameProcess struct {
pid int
mapName string
port int
partition int
}
func parseAMPMapName(argsFields []string) string {
for i, field := range argsFields {
if field == "DuneSandbox" && i+1 < len(argsFields) {
return argsFields[i+1]
}
}
return ""
}
func parseAMPArgInt(re *regexp.Regexp, args string) int {
m := re.FindStringSubmatch(args)
if len(m) <= 1 {
return 0
}
value, _ := strconv.Atoi(m[1])
return value
}
func parseAMPGameProcess(line string) (ampGameProcess, bool) {
fields := strings.Fields(line)
if len(fields) < 2 {
return ampGameProcess{}, false
}
pid, _ := strconv.Atoi(fields[0])
argsFields := fields[1:]
args := strings.Join(argsFields, " ")
return ampGameProcess{
pid: pid,
mapName: parseAMPMapName(argsFields),
port: parseAMPArgInt(ampPortRe, args),
partition: parseAMPArgInt(ampPartRe, args),
}, true
}
// parseProcessAges parses the output of `ps -o pid=,etimes=` into a pid→elapsed
// seconds map. Each non-empty line has two whitespace-separated columns; lines
// that don't parse cleanly are skipped rather than failing the whole map.
func parseProcessAges(out string) map[int]int {
ages := map[int]int{}
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
pid, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
age, err := strconv.Atoi(fields[1])
if err != nil {
continue
}
ages[pid] = age
}
return ages
}
// fetchProcessAges returns a best-effort pid→uptime-seconds map for the given
// pids. It is deliberately separate from listGameProcesses so a `ps` that lacks
// the etimes field (or any error) degrades to "no ages" rather than breaking the
// core process listing that the status table and lifecycle commands depend on.
func (c *ampControl) fetchProcessAges(exec Executor, pids []int) map[int]int {
if len(pids) == 0 {
return map[int]int{}
}
ids := make([]string, len(pids))
for i, p := range pids {
ids[i] = strconv.Itoa(p)
}
cmd := "ps -o pid=,etimes= -p " + strings.Join(ids, ",") + " 2>/dev/null"
if c.useContainer {
if c.container == "" {
return map[int]int{}
}
cmd = c.wrapInContainer(cmd)
}
out, err := exec.Exec(cmd)
if err != nil && strings.TrimSpace(out) == "" {
return map[int]int{}
}
return parseProcessAges(out)
}
func (c *ampControl) listGameProcesses(exec Executor) ([]ampGameProcess, error) {
cmd := `ps -eo pid,args --no-headers 2>/dev/null | grep 'DuneSandboxServer-Linux-Shipping' | grep -v grep`
if c.useContainer {
if c.container == "" {
return nil, fmt.Errorf("amp_container not configured")
}
cmd = c.wrapInContainer(cmd)
}
out, err := exec.Exec(cmd)
if err != nil && strings.TrimSpace(out) == "" {
return []ampGameProcess{}, nil
}
var procs []ampGameProcess
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
proc, ok := parseAMPGameProcess(line)
if !ok {
continue
}
procs = append(procs, proc)
}
return procs, nil
}
func (c *ampControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
procs, err := c.listGameProcesses(exec)
if err != nil {
return nil, "", err
}
var infos []ProcessInfo
for _, p := range procs {
infos = append(infos, ProcessInfo{
Name: fmt.Sprintf("%s (pid=%d port=%d partition=%d)", p.mapName, p.pid, p.port, p.partition),
Namespace: c.container,
Status: "Running",
})
}
if infos == nil {
infos = []ProcessInfo{}
}
return infos, c.container, nil
}
// runtimeCLI returns the container CLI used to wrap in-container operations as
// `<rt> exec` when useContainer is true. Defaults to podman when unset so
// existing (podman) installs are unaffected.
func (c *ampControl) runtimeCLI() string {
if c.containerRuntime == "" {
return "podman"
}
return c.containerRuntime
}
// wrapInContainer returns a command string that, when executed via the host
// executor, runs the given remote command. In container mode this is wrapped
// in `sudo -i -u <ampUser> <runtime> exec <container> sh -c '<remoteCmd>'`. In
// native mode it's wrapped in `sudo -i -u <ampUser> sh -c '<remoteCmd>'`.
//
// The remote command is single-quoted; the caller MUST NOT embed single quotes
// in the command itself.
func (c *ampControl) wrapInContainer(remoteCmd string) string {
if c.useContainer {
return fmt.Sprintf("sudo -i -u %s %s exec %s sh -c %s",
c.ampUser, c.runtimeCLI(), c.container, shellQuote(remoteCmd))
}
return fmt.Sprintf("sudo -i -u %s sh -c %s", c.ampUser, shellQuote(remoteCmd))
}
func (c *ampControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
if c.logPath == "" {
return nil, fmt.Errorf("amp control requires amp_log_path to be set")
}
if c.useContainer && c.container == "" {
return nil, fmt.Errorf("amp control in container mode requires amp_container to be set")
}
cmd := c.wrapInContainer(fmt.Sprintf("ls -1 %s 2>/dev/null", c.logPath))
out, err := exec.Exec(cmd)
if err != nil {
return nil, fmt.Errorf("list log dir: %w (%s)", err, out)
}
ns := c.container
if !c.useContainer {
ns = "host:" + c.logPath
}
var sources []LogSource
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
name := strings.TrimSpace(line)
if !strings.HasSuffix(name, ".log") {
continue
}
sources = append(sources, LogSource{Namespace: ns, Name: name})
}
if sources == nil {
sources = []LogSource{}
}
return sources, nil
}
var ampLogFileNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.log$`)
func (c *ampControl) StreamLog(_ context.Context, exec Executor, _, name string) (<-chan string, func(), error) {
if !ampLogFileNameRe.MatchString(name) {
return nil, func() {}, fmt.Errorf("invalid log file name %q", name)
}
cmd := c.wrapInContainer(fmt.Sprintf("tail -n 200 -f %s/%s", c.logPath, name))
return exec.Stream(cmd)
}
// ── JWT capture ───────────────────────────────────────────────────────────────
func (c *ampControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
out, err := exec.Exec(`ps aux 2>/dev/null | grep DuneSandboxServer | grep -oP 'ServiceAuthToken=\K[^ ]+' | head -1`)
if err != nil || strings.TrimSpace(out) == "" {
return "", "", fmt.Errorf("could not find ServiceAuthToken in process args (game server not running?)")
}
token := strings.TrimSpace(out)
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", "", fmt.Errorf("malformed JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", "", fmt.Errorf("decode JWT payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return "", "", fmt.Errorf("parse JWT payload: %w", err)
}
hostID := fmt.Sprintf("%v", claims["HostId"])
return hostID, token, nil
}
// ── RabbitMQ admin (exchange listing + capture user provisioning) ─────────────
// defaultAmpDataRoot is the in-container per-game data root that AMP creates
// for the Dune Awakening module.
const defaultAmpDataRoot = "/AMP/duneawakening"
// ampDataRoot returns the AMP per-game data root (defaults to Dune Awakening).
func (c *ampControl) ampDataRoot() string {
if c.dataRoot != "" {
return c.dataRoot
}
return defaultAmpDataRoot
}
// buildRabbitmqctl emits a complete shell command that runs rabbitmqctl
// against one of AMP's brokers. AMP bundles its own musl-linked Erlang
// runtime but only patchelfs the binaries it boots at startup (beam.smp);
// the admin-CLI escript binary is left with the original /lib/ld-musl-* shebang.
// To call it from outside AMP's normal launch path we have to:
// - invoke the bundled musl loader explicitly (works around the missing
// /lib/ld-musl-x86_64.so.1 on Debian-based AMP containers)
// - chain through the bundled escript and the rabbitmqctl escript wrapper
// - set HOME to the broker's runtime dir so the right .erlang.cookie is
// used (each broker has its own cookie under runtime/mq-<broker>-home/)
// - point RABBITMQ_HOME at the AMP-bundled rabbitmq install
// - target the right Erlang node name (rabbit-admin or rabbit-game)
//
// broker = "mq-admin" or "mq-game". args is the rabbitmqctl subcommand
// plus its arguments, already shell-quoted by the caller as needed.
func (c *ampControl) buildRabbitmqctl(broker, args string) string {
root := c.ampDataRoot()
mq := root + "/extracted/mq"
home := root + "/runtime/" + broker + "-home"
node := "rabbit-admin@localhost"
if strings.Contains(broker, "game") {
node = "rabbit-game@localhost"
}
inner := fmt.Sprintf(
"env -i HOME=%s LC_ALL=C "+
"LD_LIBRARY_PATH=%[2]s/lib:%[2]s/usr/lib:%[2]s/opt/openssl/lib "+
"RABBITMQ_HOME=%[2]s/opt/rabbitmq "+
"%[2]s/lib/ld-musl-x86_64.so.1 "+
"%[2]s/opt/erlang/lib/erlang/bin/escript "+
"%[2]s/opt/rabbitmq/escript/rabbitmqctl "+
"--node %s %s",
home, mq, node, args)
if c.useContainer && c.container != "" {
return fmt.Sprintf("sudo -i -u %s %s exec %s sh -c %s",
c.ampUser, c.runtimeCLI(), c.container, shellQuote(inner))
}
return fmt.Sprintf("sudo -i -u %s sh -c %s", c.ampUser, shellQuote(inner))
}
// EvalOnGameBroker runs an Erlang expression via rabbitmqctl eval against the
// game broker. The RMQ server-commands publisher (rmq_commands.go) uses this to
// fetch broker-side data — e.g. the ServerCommandsAuthToken — that must be
// retrieved by an Erlang expression rather than a normal AMQP operation.
func (c *ampControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
cmd := c.buildRabbitmqctl("mq-game", "eval "+shellQuote(expr))
out, err := exec.Exec(cmd + " 2>&1")
if err != nil {
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
}
return strings.TrimSpace(out), nil
}
// ── server settings (AMP Web API) ─────────────────────────────────────────────
// writeServerSettings applies fieldName→value updates through AMP's Web API
// (Core/SetConfig). AMP persists them to its own config (GenericModule.kvp →
// App.AppSettings) and regenerates UserEngine.ini / UserGame.ini with these
// values on the next start. This is the only durable write path under AMP: a
// direct INI edit is clobbered when AMP regenerates the files.
//
// Callers pass raw AMP FieldNames; the "Meta.GenericModule." node prefix is
// added here. The write is fail-fast — a SetConfig error aborts the batch and
// is returned naming the field, so partial application is possible on error.
func (c *ampControl) writeServerSettings(_ context.Context, exec Executor, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
if c.apiUser == "" || c.apiPass == "" {
return fmt.Errorf("amp api credentials not configured — set amp_api_user and amp_api_pass to manage server settings under AMP")
}
client := newAMPAPIClient(exec, c.wrapInContainer, c.apiUser, c.apiPass, c.apiPort)
for field, value := range updates {
if err := client.setConfig("Meta.GenericModule."+field, value); err != nil {
return fmt.Errorf("write server setting %s: %w", field, err)
}
}
return nil
}
// readServerSettings reads the current value of each curated FieldName back from
// AMP's live config (Core/GetConfig on node "Meta.GenericModule.<FieldName>").
// AMP — not the INI files — is the source of truth for these settings, so this
// lets the read path reflect values saved through the AMP API immediately,
// without waiting for AMP to regenerate UserEngine.ini / UserGame.ini on the
// next game restart. Implements serverSettingsReader. The session is reused
// across fields (login happens once on the first GetConfig).
func (c *ampControl) readServerSettings(_ context.Context, exec Executor, fields []string) (map[string]string, error) {
if len(fields) == 0 {
return map[string]string{}, nil
}
if c.apiUser == "" || c.apiPass == "" {
return nil, fmt.Errorf("amp api credentials not configured — set amp_api_user and amp_api_pass to read server settings under AMP")
}
client := newAMPAPIClient(exec, c.wrapInContainer, c.apiUser, c.apiPass, c.apiPort)
out := make(map[string]string, len(fields))
for _, field := range fields {
v, err := client.getConfig("Meta.GenericModule." + field)
if err != nil {
return nil, fmt.Errorf("read server setting %s: %w", field, err)
}
out[field] = v
}
return out, nil
}
// ── INI discovery ─────────────────────────────────────────────────────────────
// gameOverridePath returns the file AMP appends to UserGame.ini at boot:
// UserOverrides.ini in the instance state dir. AMP owns UserGame.ini (written
// from its dashboard), so dune-admin writes game-scoped settings here instead
// of clobbering it. Keys in UserOverrides.ini take precedence at runtime.
//
// dir is the discovered INI directory. In the standard container layout that is
// …/state/ue5-saved/UserSettings; UserOverrides.ini lives two levels up in
// …/state. If dir does not match that layout the override file is placed
// alongside it, so the method always returns a usable path.
func (c *ampControl) gameOverridePath(dir string) string {
d := strings.TrimRight(filepath.ToSlash(dir), "/")
d = strings.TrimSuffix(d, "/ue5-saved/UserSettings")
return d + "/UserOverrides.ini"
}
// defaultINIDir returns the host directory holding the game's stock
// DefaultGame.ini / DefaultEngine.ini so default discovery needs no
// configuration under AMP. The game ships them in the extracted game-server
// tree at <gameRoot>/extracted/game-server/home/dune/server/DuneSandbox/Config,
// where gameRoot is the instance's duneawakening dir. gameRoot is recovered
// from the discovered INI dir, then the configured server_ini_dir (both contain
// "…/server/state"), and finally the conventional ampdata path for the
// instance. Returns "" when none apply (e.g. native layout), letting the other
// discovery strategies take over.
func (c *ampControl) defaultINIDir(iniDir string) string {
for _, base := range []string{iniDir, c.iniDir} {
if i := strings.Index(base, "/server/state"); i > 0 {
return base[:i] + ampDefaultsConfigSuffix
}
}
if c.useContainer && c.instance != "" {
user := c.ampUser
if user == "" {
user = "amp"
}
return fmt.Sprintf("/home/%s/.ampdata/instances/%s/duneawakening%s",
user, c.instance, ampDefaultsConfigSuffix)
}
return ""
}
// ampDefaultsConfigSuffix is the path, relative to the instance's duneawakening
// gameRoot, to the directory containing the stock Default*.ini files.
const ampDefaultsConfigSuffix = "/extracted/game-server/home/dune/server/DuneSandbox/Config"
func (c *ampControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
base := c.iniDir
if base == "" {
if c.instance == "" {
return "", fmt.Errorf("amp control requires server_ini_dir or amp_instance to derive an INI directory")
}
base = filepath.ToSlash(fmt.Sprintf(
"/home/%s/.ampdata/instances/%s/duneawakening/server/state",
c.ampUser, c.instance))
}
// install.sh places UserGame.ini under ue5-saved/UserSettings/ inside the
// state directory. Prefer that subdirectory over the base path — this probe
// runs even when server_ini_dir is explicitly configured so the configured
// path acts as a base directory rather than bypassing auto-detection.
ue5Dir := base + "/ue5-saved/UserSettings"
out, _ := exec.Exec(fmt.Sprintf(
"test -f %s/UserGame.ini && echo yes || echo no",
shellQuote(ue5Dir)))
if strings.TrimSpace(out) == "yes" {
return ue5Dir, nil
}
return base, nil
}
// ReadDefaultINI returns the contents of DefaultGame.ini / DefaultEngine.ini.
// In container mode this `find`s inside the game container; in native mode it
// searches under the AMP install root. Returns "" when nothing matches so the
// host-path traversal in handlers_server_settings.go can take over.
func (c *ampControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
if c.useContainer && c.container == "" {
return ""
}
findRoot := "/"
if !c.useContainer {
// Native AMP installs put the game tree under /AMP/<game>/. Scan that
// instead of /, which is faster and avoids permission noise.
findRoot = "/AMP"
}
out, err := exec.Exec(c.wrapInContainer(fmt.Sprintf(
"find %s -name %s -not -path '*/Saved/*' -not -path '*/saved/*' 2>/dev/null | head -1",
findRoot, filename)))
if err != nil || strings.TrimSpace(out) == "" {
return ""
}
path := strings.TrimSpace(out)
out, err = exec.Exec(c.wrapInContainer(fmt.Sprintf("cat %s 2>/dev/null", path)))
if err != nil {
return ""
}
return out
}
// ── Battlegroup Director config (#147) ──────────────────────────────────────
// director_config.ini is a HOST file ($STATE/director_config.ini, amp-owned
// 0700) — NOT in the game container — so it's read/written on the host as the
// AMP user. prestart.sh copies it into runtime/director-conf.d on every start,
// so edits persist and apply on the next instance restart.
// directorConfigPath derives $STATE/director_config.ini from the resolved server
// INI dir, which is $STATE/ue5-saved/UserSettings (so $STATE is two levels up).
func (c *ampControl) directorConfigPath() (string, error) {
dir, err := iniDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(filepath.Dir(dir)), "director_config.ini"), nil
}
func (c *ampControl) readDirectorConfig(exec Executor) (string, string, error) {
path, err := c.directorConfigPath()
if err != nil {
return "", "", err
}
out, err := exec.Exec(fmt.Sprintf("sudo -i -u %s cat %s 2>/dev/null", shellQuote(c.ampUser), shellQuote(path)))
if err != nil {
return path, "", fmt.Errorf("read %s: %w", path, err)
}
if strings.TrimSpace(out) == "" {
return path, "", fmt.Errorf("director config empty or unreadable at %s", path)
}
return path, out, nil
}
func (c *ampControl) writeDirectorConfig(exec Executor, content string) (string, error) {
path, err := c.directorConfigPath()
if err != nil {
return "", err
}
if err := exec.WriteFile(path, strings.NewReader(content)); err != nil {
return path, fmt.Errorf("write %s: %w", path, err)
}
return path, nil
}

View File

@@ -0,0 +1,108 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
)
// newAmpReadExec routes Core/Login and Core/GetConfig, returning canned
// CurrentValue responses keyed by the requested node, and counts logins.
func newAmpReadExec(t *testing.T, loginOK bool, values map[string]string, logins *int) *fnExecutor {
return &fnExecutor{fn: func(cmd string) (string, error) {
switch {
case strings.Contains(cmd, "Core/Login"):
*logins++
if !loginOK {
return `{"success":false,"resultReason":"bad creds"}`, nil
}
return `{"success":true,"sessionID":"sess"}`, nil
case strings.Contains(cmd, "Core/GetConfig"):
var p struct {
Node string `json:"node"`
}
decodePipedPayload(t, cmd, &p)
b, _ := json.Marshal(values[p.Node])
return `{"CurrentValue":` + string(b) + `}`, nil
default:
t.Fatalf("unexpected AMP API endpoint in cmd: %q", cmd)
return "", nil
}
}}
}
func TestAmpReadServerSettings_LoginOnceThenGetPerField(t *testing.T) {
t.Parallel()
logins := 0
values := map[string]string{
"Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier": "5.000000",
"Meta.GenericModule.WorldTitle": "My Sietch",
}
exec := newAmpReadExec(t, true, values, &logins)
got, err := ampSettingsControl().readServerSettings(context.Background(), exec, []string{
"ConsoleVariables.Dune.GlobalMiningOutputMultiplier",
"WorldTitle",
})
if err != nil {
t.Fatalf("readServerSettings: %v", err)
}
if logins != 1 {
t.Errorf("logins = %d, want 1 (session reused across reads)", logins)
}
if got["ConsoleVariables.Dune.GlobalMiningOutputMultiplier"] != "5.000000" {
t.Errorf("mining = %q, want 5.000000 (got: %v)", got["ConsoleVariables.Dune.GlobalMiningOutputMultiplier"], got)
}
if got["WorldTitle"] != "My Sietch" {
t.Errorf("title = %q, want My Sietch", got["WorldTitle"])
}
}
func TestAmpReadServerSettings_EmptyFieldsIsNoOp(t *testing.T) {
t.Parallel()
logins := 0
exec := newAmpReadExec(t, true, nil, &logins)
got, err := ampSettingsControl().readServerSettings(context.Background(), exec, nil)
if err != nil {
t.Fatalf("readServerSettings: %v", err)
}
if len(got) != 0 || logins != 0 {
t.Errorf("empty fields must not contact AMP: got=%v logins=%d", got, logins)
}
}
func TestAmpReadServerSettings_MissingCredentialsErrors(t *testing.T) {
t.Parallel()
called := false
exec := &fnExecutor{fn: func(string) (string, error) { called = true; return "", nil }}
c := &ampControl{useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"} // no api creds
_, err := c.readServerSettings(context.Background(), exec, []string{"WorldTitle"})
if err == nil {
t.Fatal("expected error when AMP API credentials are not configured")
}
if called {
t.Error("must not contact the AMP API without credentials")
}
}
func TestAmpReadServerSettings_GetConfigFailurePropagates(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
return `{"success":true,"sessionID":"s"}`, nil
}
return "not json", nil // GetConfig garbage → decode error
}}
_, err := ampSettingsControl().readServerSettings(context.Background(), exec, []string{"WorldTitle"})
if err == nil {
t.Fatal("expected a GetConfig decode failure to propagate")
}
}
// Compile-time guard that ampControl satisfies the optional reader interface the
// settings GET handler routes on.
func TestAmpControl_ImplementsServerSettingsReader(t *testing.T) {
t.Parallel()
var _ serverSettingsReader = (*ampControl)(nil)
}

View File

@@ -0,0 +1,84 @@
package main
import (
"context"
"strings"
"testing"
)
// TestAmpExecCommand_RestartContainerModeCyclesContainer verifies that under
// containerised AMP, "restart" recycles the whole container rather than calling
// ampinstmgr — proven in-game to be the only action that reaps the
// DuneSandboxServer processes so settings actually apply.
func TestAmpExecCommand_RestartContainerModeCyclesContainer(t *testing.T) {
t.Parallel()
exec := &fakeAMPExecutor{out: "AMP_X"}
c := &ampControl{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"}
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
t.Fatalf("restart: %v", err)
}
if !strings.Contains(exec.cmd, "docker restart AMP_X") {
t.Errorf("restart cmd = %q, want 'docker restart AMP_X'", exec.cmd)
}
if strings.Contains(exec.cmd, "ampinstmgr") {
t.Errorf("container restart must not use ampinstmgr (does not reap game procs): %q", exec.cmd)
}
}
// TestAmpExecCommand_RestartContainerModeDefaultsPodman verifies the container
// runtime defaults to podman when unset (backward compatible).
func TestAmpExecCommand_RestartContainerModeDefaultsPodman(t *testing.T) {
t.Parallel()
exec := &fakeAMPExecutor{}
c := &ampControl{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp"}
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
t.Fatalf("restart: %v", err)
}
if !strings.Contains(exec.cmd, "podman restart AMP_X") {
t.Errorf("restart cmd = %q, want 'podman restart AMP_X'", exec.cmd)
}
}
// TestAmpExecCommand_RestartNativeModeUsesAmpinstmgr verifies that without a
// container (native AMP), restart keeps the ampinstmgr stop/start cycle.
func TestAmpExecCommand_RestartNativeModeUsesAmpinstmgr(t *testing.T) {
t.Parallel()
exec := &fakeAMPExecutor{}
c := &ampControl{instance: "Dune01", useContainer: false, ampUser: "amp"}
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
t.Fatalf("restart: %v", err)
}
if !strings.Contains(exec.cmd, "ampinstmgr -q Dune01") || !strings.Contains(exec.cmd, "ampinstmgr -s Dune01") {
t.Errorf("native restart cmd = %q, want ampinstmgr -q/-s cycle", exec.cmd)
}
}
// TestAmpExecCommand_RestartContainerModeMissingContainer verifies a clear error
// when container mode is configured without a container name.
func TestAmpExecCommand_RestartContainerModeMissingContainer(t *testing.T) {
t.Parallel()
exec := &fakeAMPExecutor{}
c := &ampControl{instance: "Dune01", useContainer: true, container: "", ampUser: "amp", containerRuntime: "docker"}
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err == nil {
t.Fatal("expected error when container name missing in container mode")
}
}
// TestAmpExecCommand_StartStopUnchanged guards that start/stop still use
// ampinstmgr (only restart was proven to need container recycling).
func TestAmpExecCommand_StartStopUnchanged(t *testing.T) {
t.Parallel()
c := &ampControl{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"}
for _, tc := range []struct{ cmd, want string }{
{"start", "ampinstmgr -s Dune01"},
{"stop", "ampinstmgr -q Dune01"},
} {
exec := &fakeAMPExecutor{}
if _, err := c.ExecCommand(context.Background(), exec, tc.cmd); err != nil {
t.Fatalf("%s: %v", tc.cmd, err)
}
if !strings.Contains(exec.cmd, tc.want) {
t.Errorf("%s cmd = %q, want %q", tc.cmd, exec.cmd, tc.want)
}
}
}

View File

@@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"strings"
"testing"
)
// ampSettingsExec routes Core/Login and Core/SetConfig, recording each
// SetConfig's decoded node→value and counting logins.
type ampSettingsCapture struct {
logins int
setCmds int
nodes map[string]string
loginOK bool
setResp string
setErr error
}
func newAmpSettingsExec(t *testing.T, cap *ampSettingsCapture) *fnExecutor {
cap.nodes = map[string]string{}
return &fnExecutor{fn: func(cmd string) (string, error) {
switch {
case strings.Contains(cmd, "Core/Login"):
cap.logins++
if !cap.loginOK {
return `{"success":false,"resultReason":"bad creds"}`, nil
}
return `{"success":true,"sessionID":"sess"}`, nil
case strings.Contains(cmd, "Core/SetConfig"):
cap.setCmds++
var p struct {
Node string `json:"node"`
Value string `json:"value"`
}
decodePipedPayload(t, cmd, &p)
cap.nodes[p.Node] = p.Value
resp := cap.setResp
if resp == "" {
resp = `{"Status":true}`
}
return resp, cap.setErr
default:
t.Fatalf("unexpected AMP API endpoint in cmd: %q", cmd)
return "", nil
}
}}
}
func ampSettingsControl() *ampControl {
return &ampControl{
useContainer: true,
container: "AMP_X",
ampUser: "amp",
containerRuntime: "docker",
apiUser: "admin",
apiPass: "pw",
}
}
func TestAmpWriteServerSettings_LoginOnceThenSetConfigPerField(t *testing.T) {
t.Parallel()
cap := &ampSettingsCapture{loginOK: true}
exec := newAmpSettingsExec(t, cap)
err := ampSettingsControl().writeServerSettings(context.Background(), exec, map[string]string{
"ConsoleVariables.Dune.GlobalMiningOutputMultiplier": "3.000000",
"/Script/DuneSandbox.BuildingSettings.m_MaxNumLandclaimSegments": "6",
})
if err != nil {
t.Fatalf("writeServerSettings: %v", err)
}
if cap.logins != 1 {
t.Errorf("logins = %d, want 1 (session must be reused across fields)", cap.logins)
}
if cap.setCmds != 2 {
t.Errorf("SetConfig calls = %d, want 2", cap.setCmds)
}
// Node = Meta.GenericModule.<FieldName> verbatim (the proven AMP write path).
if got := cap.nodes["Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier"]; got != "3.000000" {
t.Errorf("mining node value = %q, want 3.000000 (nodes: %v)", got, cap.nodes)
}
if got := cap.nodes["Meta.GenericModule./Script/DuneSandbox.BuildingSettings.m_MaxNumLandclaimSegments"]; got != "6" {
t.Errorf("landclaim node value = %q, want 6", got)
}
}
func TestAmpWriteServerSettings_WrapsAPICallForContainer(t *testing.T) {
t.Parallel()
var loginCmd string
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "Core/Login") {
loginCmd = cmd
return `{"success":true,"sessionID":"s"}`, nil
}
return `{"Status":true}`, nil
}}
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
if err != nil {
t.Fatalf("writeServerSettings: %v", err)
}
if !strings.Contains(loginCmd, "docker exec AMP_X") {
t.Errorf("AMP API call must be wrapped for in-container exec, got: %q", loginCmd)
}
if !strings.Contains(loginCmd, "http://127.0.0.1:8081/API/Core/Login") {
t.Errorf("AMP API call must hit the loopback ADS API, got: %q", loginCmd)
}
}
func TestAmpWriteServerSettings_EmptyUpdatesIsNoOp(t *testing.T) {
t.Parallel()
cap := &ampSettingsCapture{loginOK: true}
exec := newAmpSettingsExec(t, cap)
if err := ampSettingsControl().writeServerSettings(context.Background(), exec, map[string]string{}); err != nil {
t.Fatalf("writeServerSettings: %v", err)
}
if cap.logins != 0 || cap.setCmds != 0 {
t.Errorf("expected no API calls for empty updates, got logins=%d set=%d", cap.logins, cap.setCmds)
}
}
func TestAmpWriteServerSettings_MissingCredentialsErrors(t *testing.T) {
t.Parallel()
called := false
exec := &fnExecutor{fn: func(string) (string, error) { called = true; return "", nil }}
c := &ampControl{useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"} // no apiUser/apiPass
err := c.writeServerSettings(context.Background(), exec,
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
if err == nil {
t.Fatal("expected error when AMP API credentials are not configured")
}
if called {
t.Error("must not contact the AMP API without credentials")
}
}
func TestAmpWriteServerSettings_SetConfigFailurePropagates(t *testing.T) {
t.Parallel()
cap := &ampSettingsCapture{loginOK: true, setResp: `{"Status":false,"Reason":"No such node."}`}
exec := newAmpSettingsExec(t, cap)
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
map[string]string{"ConsoleVariables.Bogus": "1"})
if err == nil {
t.Fatal("expected SetConfig failure to propagate")
}
if !strings.Contains(err.Error(), "No such node") {
t.Errorf("error should surface AMP reason, got: %v", err)
}
}
func TestAmpWriteServerSettings_LoginFailurePropagates(t *testing.T) {
t.Parallel()
cap := &ampSettingsCapture{loginOK: false}
exec := newAmpSettingsExec(t, cap)
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
if err == nil {
t.Fatal("expected login failure to abort the write")
}
if cap.setCmds != 0 {
t.Error("must not SetConfig when login fails")
}
}
// TestAmpControl_ImplementsServerSettingsWriter is a compile-time guard that
// ampControl satisfies the optional interface the handler routes on.
func TestAmpControl_ImplementsServerSettingsWriter(t *testing.T) {
t.Parallel()
var _ serverSettingsWriter = (*ampControl)(nil)
// Sanity: a transport error from the executor is wrapped, not swallowed.
exec := &fnExecutor{fn: func(string) (string, error) { return "", errors.New("boom") }}
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
if err == nil {
t.Fatal("expected executor error to propagate")
}
}

View File

@@ -0,0 +1,574 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeAMPExecutor struct {
out string
err error
cmd string
}
// fnExecutor routes each Exec call through a provided function, allowing
// tests to return different output for different commands.
type fnExecutor struct {
fn func(cmd string) (string, error)
}
func (f *fnExecutor) Exec(cmd string) (string, error) { return f.fn(cmd) }
func (f *fnExecutor) Stream(string) (<-chan string, func(), error) {
return nil, func() {}, nil
}
func (f *fnExecutor) PipeToWriter(string, io.Writer) error { return nil }
func (f *fnExecutor) WriteFile(string, io.Reader) error { return nil }
func (f *fnExecutor) Dial(string, string) (net.Conn, error) {
return nil, nil
}
func (f *fnExecutor) Close() {}
func (f *fnExecutor) Type() string { return "local" }
func (f *fakeAMPExecutor) Exec(cmd string) (string, error) {
f.cmd = cmd
return f.out, f.err
}
func (f *fakeAMPExecutor) Stream(string) (<-chan string, func(), error) {
return nil, func() {}, nil
}
func (f *fakeAMPExecutor) PipeToWriter(string, io.Writer) error { return nil }
func (f *fakeAMPExecutor) WriteFile(string, io.Reader) error { return nil }
// Dial mirrors localExecutor: a real direct dial. The director HTTP client now
// routes through the executor, so GetStatus tests that hit a loopback httptest
// server need a functioning Dial here.
func (f *fakeAMPExecutor) Dial(network, addr string) (net.Conn, error) {
return net.Dial(network, addr)
}
func (f *fakeAMPExecutor) Close() {}
func (f *fakeAMPExecutor) Type() string { return "local" }
func TestParseAMPGameProcess(t *testing.T) {
t.Parallel()
line := "123 /srv/DuneSandboxServer-Linux-Shipping DuneSandbox HaggaBasinS -Port=7777 -PartitionIndex=3"
got, ok := parseAMPGameProcess(line)
if !ok {
t.Fatalf("expected line to parse")
}
if got.pid != 123 || got.mapName != "HaggaBasinS" || got.port != 7777 || got.partition != 3 {
t.Fatalf("unexpected parsed process: %+v", got)
}
if _, ok := parseAMPGameProcess("garbage"); ok {
t.Fatalf("expected malformed line to be rejected")
}
}
func TestAmpPgDumpRestoreCommands(t *testing.T) {
t.Parallel()
c := &ampControl{
container: "AMP_DuneTest01",
ampUser: "amp",
containerRuntime: "docker",
pgBin: "/AMP/duneawakening/extracted/postgres/usr/local/bin",
pgLib: "/pg/lib:/db/lib",
}
conn := dbConn{Host: "127.0.0.1", Port: 15432, User: "dune", Pass: "secret", Name: "dune"}
dump := c.pgDumpCommand(conn, "/home/test/db-backups/x.dump")
for _, want := range []string{
"sudo -i -u amp", "docker exec", "AMP_DuneTest01",
"PGPASSWORD=", "LD_LIBRARY_PATH=", "/pg/lib:/db/lib",
"pg_dump", "-Fc", "-h ", "127.0.0.1", "-p 15432", "-U ", "-d ",
"> ", "/home/test/db-backups/x.dump",
} {
if !strings.Contains(dump, want) {
t.Errorf("pgDumpCommand missing %q in:\n%s", want, dump)
}
}
restore := c.pgRestoreCommand(conn, "/home/test/db-backups/x.dump")
for _, want := range []string{
"docker exec -i", "pg_restore", "--clean", "--if-exists", "-d ",
"< ", "/home/test/db-backups/x.dump",
} {
if !strings.Contains(restore, want) {
t.Errorf("pgRestoreCommand missing %q in:\n%s", want, restore)
}
}
}
func TestParseProcessAges(t *testing.T) {
t.Parallel()
// `ps -o pid=,etimes=` emits two whitespace-separated columns (pid, elapsed
// seconds), typically with leading padding. We only care about a pid→seconds
// map; malformed lines are skipped, not fatal.
out := " 123 3600\n456 90\nbad line here\n 789 0\n"
got := parseProcessAges(out)
want := map[int]int{123: 3600, 456: 90, 789: 0}
if len(got) != len(want) {
t.Fatalf("parseProcessAges len = %d, want %d (%+v)", len(got), len(want), got)
}
for pid, age := range want {
if got[pid] != age {
t.Fatalf("parseProcessAges[%d] = %d, want %d", pid, got[pid], age)
}
}
if len(parseProcessAges("")) != 0 {
t.Fatalf("expected empty input to yield empty map")
}
}
func TestListGameProcesses(t *testing.T) {
t.Parallel()
ctrl := &ampControl{}
exec := &fakeAMPExecutor{
out: "100 one DuneSandbox MapA -Port=7001 -PartitionIndex=1\nbad\n200 two DuneSandbox MapB -Port=7002",
}
procs, err := ctrl.listGameProcesses(exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(procs) != 2 {
t.Fatalf("expected 2 parsed processes, got %d", len(procs))
}
if procs[0].pid != 100 || procs[0].mapName != "MapA" || procs[0].port != 7001 || procs[0].partition != 1 {
t.Fatalf("unexpected first process: %+v", procs[0])
}
if procs[1].pid != 200 || procs[1].mapName != "MapB" || procs[1].port != 7002 || procs[1].partition != 0 {
t.Fatalf("unexpected second process: %+v", procs[1])
}
if exec.cmd == "" {
t.Fatalf("expected process listing command to be executed")
}
}
func TestListGameProcesses_EmptyOnExecErrorWithoutOutput(t *testing.T) {
t.Parallel()
ctrl := &ampControl{}
exec := &fakeAMPExecutor{err: errors.New("ps failed")}
procs, err := ctrl.listGameProcesses(exec)
if err != nil {
t.Fatalf("expected no error when exec fails without output, got %v", err)
}
if len(procs) != 0 {
t.Fatalf("expected empty process list, got %+v", procs)
}
}
func TestListGameProcesses_NoContainer(t *testing.T) {
t.Parallel()
ctrl := &ampControl{useContainer: false}
exec := &fakeAMPExecutor{out: ""}
_, err := ctrl.listGameProcesses(exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(exec.cmd, " exec ") {
t.Fatalf("expected no container wrapping for useContainer=false, got cmd: %q", exec.cmd)
}
if !strings.Contains(exec.cmd, "DuneSandboxServer") {
t.Fatalf("expected ps command for DuneSandboxServer, got: %q", exec.cmd)
}
}
func TestListGameProcesses_WithContainer(t *testing.T) {
t.Parallel()
ctrl := &ampControl{
useContainer: true,
container: "AMP_Dune01",
ampUser: "amp",
containerRuntime: "podman",
}
exec := &fakeAMPExecutor{out: ""}
_, err := ctrl.listGameProcesses(exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(exec.cmd, "podman exec AMP_Dune01") {
t.Fatalf("expected podman exec wrapping, got cmd: %q", exec.cmd)
}
if !strings.Contains(exec.cmd, "DuneSandboxServer") {
t.Fatalf("expected ps command inside wrapper, got: %q", exec.cmd)
}
}
func TestListGameProcesses_WithContainer_MissingContainerName(t *testing.T) {
t.Parallel()
ctrl := &ampControl{useContainer: true, container: "", ampUser: "amp"}
exec := &fakeAMPExecutor{out: ""}
_, err := ctrl.listGameProcesses(exec)
if err == nil {
t.Fatal("expected error when useContainer=true but container name is empty")
}
}
// TestAmpDiscoverIniDir_PrefersUE5SavedPath verifies that when
// ue5-saved/UserSettings/UserGame.ini exists (install.sh layout),
// DiscoverIniDir returns that sub-directory rather than the base state dir.
func TestAmpDiscoverIniDir_PrefersUE5SavedPath(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "ue5-saved/UserSettings") {
return "yes\n", nil
}
return "no\n", nil
}}
ctrl := &ampControl{instance: "TestInst", ampUser: "amp"}
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "/home/amp/.ampdata/instances/TestInst/duneawakening/server/state/ue5-saved/UserSettings"
if dir != want {
t.Errorf("got %q, want %q", dir, want)
}
}
// TestAmpDiscoverIniDir_FallsBackToState verifies that when ue5-saved/UserSettings
// does not have a UserGame.ini, DiscoverIniDir returns the base state directory.
func TestAmpDiscoverIniDir_FallsBackToState(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
return "no\n", nil
}}
ctrl := &ampControl{instance: "TestInst", ampUser: "amp"}
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "/home/amp/.ampdata/instances/TestInst/duneawakening/server/state"
if dir != want {
t.Errorf("got %q, want %q", dir, want)
}
}
// TestAmpDiscoverIniDir_ExplicitConfig_PrefersUE5SubDir verifies that when
// server_ini_dir is set to a base state directory and ue5-saved/UserSettings
// contains UserGame.ini, DiscoverIniDir returns the ue5-saved subdirectory.
func TestAmpDiscoverIniDir_ExplicitConfig_PrefersUE5SubDir(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
if strings.Contains(cmd, "ue5-saved/UserSettings") {
return "yes\n", nil
}
return "no\n", nil
}}
ctrl := &ampControl{iniDir: "/custom/state"}
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "/custom/state/ue5-saved/UserSettings"
if dir != want {
t.Errorf("got %q, want %q", dir, want)
}
}
// TestAmpDiscoverIniDir_ExplicitConfig_FallsBackToConfigured verifies that when
// server_ini_dir is set and ue5-saved/UserSettings has no UserGame.ini, the
// configured path is returned as-is.
func TestAmpDiscoverIniDir_ExplicitConfig_FallsBackToConfigured(t *testing.T) {
t.Parallel()
exec := &fnExecutor{fn: func(cmd string) (string, error) {
return "no\n", nil
}}
ctrl := &ampControl{iniDir: "/custom/ini/dir"}
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dir != "/custom/ini/dir" {
t.Errorf("got %q, want %q", dir, "/custom/ini/dir")
}
}
// TestAmpRuntimeCLI_DefaultsToPodman verifies the container-runtime selector
// defaults to podman (backward compatible) and honours an explicit docker.
func TestAmpRuntimeCLI_DefaultsToPodman(t *testing.T) {
t.Parallel()
if got := (&ampControl{}).runtimeCLI(); got != "podman" {
t.Errorf("empty containerRuntime: got %q, want podman", got)
}
if got := (&ampControl{containerRuntime: "docker"}).runtimeCLI(); got != "docker" {
t.Errorf("explicit docker: got %q, want docker", got)
}
if got := (&ampControl{containerRuntime: "podman"}).runtimeCLI(); got != "podman" {
t.Errorf("explicit podman: got %q, want podman", got)
}
}
// TestAmpWrapInContainer_RuntimeSelection verifies wrapInContainer emits the
// configured container runtime as `<rt> exec` in container mode, defaulting to
// podman when unset.
func TestAmpWrapInContainer_RuntimeSelection(t *testing.T) {
t.Parallel()
tests := []struct {
name string
runtime string
wantSub string
notWantSub string
}{
{"default empty -> podman", "", "podman exec AMP_X", "docker"},
{"explicit podman", "podman", "podman exec AMP_X", "docker"},
{"docker", "docker", "docker exec AMP_X", "podman"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &ampControl{ampUser: "amp", container: "AMP_X", useContainer: true, containerRuntime: tt.runtime}
got := c.wrapInContainer("ls /tmp")
if !strings.Contains(got, tt.wantSub) {
t.Errorf("wrapInContainer = %q, want substring %q", got, tt.wantSub)
}
if tt.notWantSub != "" && strings.Contains(got, tt.notWantSub) {
t.Errorf("wrapInContainer = %q, must not contain %q", got, tt.notWantSub)
}
})
}
}
// TestAmpWrapInContainer_NativeIgnoresRuntime verifies native mode never wraps
// in a container runtime even when one is configured.
func TestAmpWrapInContainer_NativeIgnoresRuntime(t *testing.T) {
t.Parallel()
c := &ampControl{ampUser: "amp", useContainer: false, containerRuntime: "docker"}
got := c.wrapInContainer("ls")
if strings.Contains(got, "docker") || strings.Contains(got, "podman") || strings.Contains(got, "exec") {
t.Errorf("native wrapInContainer must not reference a container runtime: %q", got)
}
}
// TestAmpBuildRabbitmqctl_RuntimeSelection verifies the rabbitmqctl trampoline
// is wrapped in the configured container runtime.
func TestAmpBuildRabbitmqctl_RuntimeSelection(t *testing.T) {
t.Parallel()
c := &ampControl{ampUser: "amp", container: "AMP_X", useContainer: true, containerRuntime: "docker"}
cmd := c.buildRabbitmqctl("mq-admin", "status")
if !strings.Contains(cmd, "docker exec AMP_X") {
t.Errorf("buildRabbitmqctl = %q, want 'docker exec AMP_X'", cmd)
}
if strings.Contains(cmd, "podman") {
t.Errorf("buildRabbitmqctl must not reference podman when runtime=docker: %q", cmd)
}
}
// TestAmpListLogSources_UsesConfiguredRuntime is an end-to-end check that the
// runtime selection flows through a real ControlPlane method to the executor.
func TestAmpListLogSources_UsesConfiguredRuntime(t *testing.T) {
t.Parallel()
exec := &fakeAMPExecutor{out: "game.log\nserver.log\n"}
c := &ampControl{container: "AMP_X", ampUser: "amp", logPath: "/AMP/duneawakening/logs", useContainer: true, containerRuntime: "docker"}
if _, err := c.ListLogSources(context.Background(), exec); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(exec.cmd, "docker exec AMP_X") {
t.Errorf("ListLogSources cmd = %q, want 'docker exec AMP_X'", exec.cmd)
}
}
// directorBattlegroupJSON mirrors the structurally-relevant subset of the
// Battlegroup Director's /v0/battlegroup response: a single-server map, a
// dimension map (sharded under serversByDimension), and an instanced map.
// Each leaf server carries a "partition" object with partitionId,
// dimensionIndex and label — the fields GetStatus enriches rows with.
const directorBattlegroupJSON = `{
"bgTitle": "Test BG",
"singleServerMaps": {
"Overmap": {
"cfg": {"playerHardCap": 60},
"gamePort": 7794,
"numPlayersInGame": 5,
"numPlayersInQueue": 2,
"serverPlayerHardCap": -1,
"partition": {"partitionId": 2, "dimensionIndex": 0, "label": "Overland"}
}
},
"dimensionMaps": {
"DeepDesert_1": {
"cfg": null,
"webOverrideCfg": null,
"serversByDimension": {
"0": {"gamePort": 7799, "numPlayersInGame": 2, "numPlayersInQueue": 0, "serverPlayerHardCap": -1, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 8, "dimensionIndex": 0, "label": "DeepDesert_0"}},
"1": {"gamePort": 7800, "numPlayersInGame": 0, "numPlayersInQueue": 1, "serverPlayerHardCap": 40, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 143, "dimensionIndex": 1, "label": "DeepDesert_1"}}
}
}
},
"instancedMaps": {
"SH_Arrakeen": {
"instances": {
"inst-a": {"gamePort": 7792, "numPlayersInGame": 7, "numPlayersInQueue": null, "serverPlayerHardCap": -1, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 3, "dimensionIndex": 0, "label": "Arrakeen_0"}}
}
}
}
}`
// psLineFor builds a synthetic `ps`-style game-server line for a map/port/partition.
func psLineFor(pid int, mapName string, port, partition int) string {
return fmt.Sprintf(
"%d /x/DuneSandboxServer-Linux-Shipping DuneSandbox %s -Port=%d -PartitionIndex=%d",
pid, mapName, port, partition)
}
// TestAmpGetStatus_EnrichesDimensionFromDirector verifies that GetStatus joins
// each ps-derived partition to the director's dimensionIndex and label, walking
// single-server, dimension, and instanced map categories alike.
func TestAmpGetStatus_EnrichesDimensionFromDirector(t *testing.T) {
// Not parallel: GetStatus reads the globalDB package global, which other
// parallel tests mutate.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v0/battlegroup" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, directorBattlegroupJSON)
}))
defer srv.Close()
psOut := strings.Join([]string{
psLineFor(1001, "Overmap", 7794, 2),
psLineFor(1002, "DeepDesert_1", 7799, 8),
psLineFor(1003, "DeepDesert_1", 7800, 143),
psLineFor(1004, "SH_Arrakeen", 7792, 3),
}, "\n")
c := &ampControl{container: "AMP_X", useContainer: false, directorURL: srv.URL}
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
want := map[int]struct {
dim int
sietch string
players int
cap int
queue int
}{
2: {0, "Overland", 5, 60, 2}, // serverPlayerHardCap -1 → cfg cap 60
8: {0, "DeepDesert_0", 2, 80, 0}, // cfg cap 80
143: {1, "DeepDesert_1", 0, 40, 1}, // serverPlayerHardCap 40 overrides cfg 80
3: {0, "Arrakeen_0", 7, 80, 0}, // queue null → 0
}
if len(status.Servers) != len(want) {
t.Fatalf("got %d servers, want %d", len(status.Servers), len(want))
}
for _, row := range status.Servers {
exp, ok := want[row.Partition]
if !ok {
t.Fatalf("unexpected partition %d", row.Partition)
}
if row.Dimension != exp.dim {
t.Errorf("partition %d: dimension = %d, want %d", row.Partition, row.Dimension, exp.dim)
}
if row.Sietch != exp.sietch {
t.Errorf("partition %d: sietch = %q, want %q", row.Partition, row.Sietch, exp.sietch)
}
if row.Players != exp.players {
t.Errorf("partition %d: players = %d, want %d", row.Partition, row.Players, exp.players)
}
if row.PlayerHardCap != exp.cap {
t.Errorf("partition %d: playerHardCap = %d, want %d", row.Partition, row.PlayerHardCap, exp.cap)
}
if row.Queue != exp.queue {
t.Errorf("partition %d: queue = %d, want %d", row.Partition, row.Queue, exp.queue)
}
}
}
// TestAmpGetStatus_NoDirectorURL verifies that with no director configured,
// GetStatus still returns rows from ps with dimension left at zero (current
// behaviour) and makes no HTTP call.
func TestAmpGetStatus_NoDirectorURL(t *testing.T) {
// Not parallel: GetStatus reads the globalDB package global.
psOut := psLineFor(2001, "Overmap", 7794, 2)
c := &ampControl{container: "AMP_X", useContainer: false} // directorURL empty
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if len(status.Servers) != 1 {
t.Fatalf("got %d servers, want 1", len(status.Servers))
}
if status.Servers[0].Partition != 2 || status.Servers[0].Dimension != 0 {
t.Errorf("row = %+v, want partition 2 dimension 0", status.Servers[0])
}
}
// TestAmpGetStatus_DirectorUnreachable_FallsBack verifies that a transport
// failure to the director is non-fatal: rows are still returned from ps with
// dimension left at zero.
func TestAmpGetStatus_DirectorUnreachable_FallsBack(t *testing.T) {
// Not parallel: GetStatus reads the globalDB package global.
// Closed server: take a listener address then immediately close it.
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := srv.URL
srv.Close()
psOut := psLineFor(3001, "Overmap", 7794, 2)
c := &ampControl{container: "AMP_X", useContainer: false, directorURL: deadURL}
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
if err != nil {
t.Fatalf("GetStatus should not fail on director error: %v", err)
}
if len(status.Servers) != 1 || status.Servers[0].Dimension != 0 {
t.Fatalf("expected 1 row with dimension 0, got %+v", status.Servers)
}
}
// TestCollectPartitions_WalksNestedAndIgnoresNull verifies the recursive walker
// records partitions from arbitrary nesting and ignores null/non-object
// "partition" values.
func TestCollectPartitions_WalksNestedAndIgnoresNull(t *testing.T) {
t.Parallel()
var raw map[string]any
if err := json.Unmarshal([]byte(directorBattlegroupJSON), &raw); err != nil {
t.Fatalf("unmarshal sample: %v", err)
}
out := map[int]partitionMeta{}
collectPartitions(raw, out)
for id, want := range map[int]partitionMeta{
2: {dimension: 0, label: "Overland", players: 5, playerHardCap: 60, queue: 2},
8: {dimension: 0, label: "DeepDesert_0", players: 2, playerHardCap: 80, queue: 0},
143: {dimension: 1, label: "DeepDesert_1", players: 0, playerHardCap: 40, queue: 1},
3: {dimension: 0, label: "Arrakeen_0", players: 7, playerHardCap: 80, queue: 0},
} {
got, ok := out[id]
if !ok {
t.Errorf("partition %d missing", id)
continue
}
if got != want {
t.Errorf("partition %d = %+v, want %+v", id, got, want)
}
}
if len(out) != 4 {
t.Errorf("collected %d partitions, want 4: %+v", len(out), out)
}
}

View File

@@ -0,0 +1,148 @@
package main
import (
"context"
"fmt"
"strings"
)
// dockerControl implements ControlPlane using the Docker CLI.
// It requires configured container names and expects the Docker socket to be
// accessible by the executor (locally or via SSH to a Docker host).
type dockerControl struct {
gameserver string // container name for the game server
brokerGame string // container name for mq-game broker
brokerAdmin string // container name for mq-admin broker
}
func (c *dockerControl) Name() string { return "docker" }
func (c *dockerControl) GetStatus(_ context.Context, exec Executor) (*BattlegroupStatus, error) {
if c.gameserver == "" {
return nil, errNotSupported("docker", "GetStatus (docker_gameserver not configured)")
}
out, err := exec.Exec(fmt.Sprintf(
"docker inspect --format '{{.State.Status}}' %s 2>&1", c.gameserver))
if err != nil {
return nil, fmt.Errorf("docker inspect: %w", err)
}
status := strings.TrimSpace(out)
return &BattlegroupStatus{
Name: c.gameserver,
Title: c.gameserver,
Phase: status,
Servers: []ServerRow{},
}, nil
}
func (c *dockerControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
if c.gameserver == "" {
return "", errNotSupported("docker", "ExecCommand (docker_gameserver not configured)")
}
var dockerCmd string
switch cmd {
case "start":
dockerCmd = fmt.Sprintf("docker start %s 2>&1", c.gameserver)
case "stop":
dockerCmd = fmt.Sprintf("docker stop %s 2>&1", c.gameserver)
case "restart":
dockerCmd = fmt.Sprintf("docker restart %s 2>&1", c.gameserver)
default:
return "", fmt.Errorf("docker control does not support %q", cmd)
}
out, err := exec.Exec(dockerCmd)
if err != nil {
return out, fmt.Errorf("docker %s: %w — %s", cmd, err, out)
}
return out, nil
}
func (c *dockerControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
out, err := exec.Exec("docker ps --format '{{.Names}}\\t{{.Status}}' 2>&1")
if err != nil {
return nil, "", fmt.Errorf("docker ps: %w", err)
}
var procs []ProcessInfo
for _, line := range splitLines(out) {
parts := strings.SplitN(line, "\t", 2)
if len(parts) < 1 || parts[0] == "" {
continue
}
status := ""
if len(parts) == 2 {
status = parts[1]
}
procs = append(procs, ProcessInfo{Name: parts[0], Status: status})
}
return procs, "docker", nil
}
func (c *dockerControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
out, err := exec.Exec("docker ps --format '{{.Names}}' 2>&1")
if err != nil {
return nil, fmt.Errorf("docker ps: %w", err)
}
var sources []LogSource
for _, line := range splitLines(out) {
name := strings.TrimSpace(line)
if name != "" {
sources = append(sources, LogSource{Namespace: "docker", Name: name})
}
}
return sources, nil
}
func (c *dockerControl) StreamLog(_ context.Context, exec Executor, _, name string) (<-chan string, func(), error) {
return exec.Stream(fmt.Sprintf("docker logs -f %s 2>&1", name))
}
func (c *dockerControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
if c.gameserver == "" {
return "", "", errNotSupported("docker", "CaptureJWT (docker_gameserver not configured)")
}
existingToken, err := exec.Exec(fmt.Sprintf(
"docker exec %s env 2>/dev/null | grep FuncomLiveServices__ServiceAuthToken | cut -d= -f2-",
c.gameserver))
if err != nil || strings.TrimSpace(existingToken) == "" {
return "", "", fmt.Errorf("read ServiceAuthToken from container: %w", err)
}
return buildCaptureJWT(strings.TrimSpace(existingToken))
}
func (c *dockerControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
if c.brokerGame == "" {
return "", errNotSupported("docker", "EvalOnGameBroker (docker_broker_game not configured)")
}
out, err := exec.Exec(fmt.Sprintf(
"docker exec %s rabbitmqctl eval %s 2>&1",
c.brokerGame, shellQuote(expr)))
if err != nil {
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
}
return strings.TrimSpace(out), nil
}
func (c *dockerControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
if c.gameserver == "" {
return ""
}
pathOut, err := exec.Exec(fmt.Sprintf(
"docker exec %s find / -name %s -not -path '*/Saved/*' -not -path '*/proc/*' -not -path '*/sys/*' -not -path '*/dev/*' 2>/dev/null | head -1",
c.gameserver, shellQuote(filename)))
if err != nil {
return ""
}
p := strings.TrimSpace(pathOut)
if p == "" {
return ""
}
content, err := exec.Exec(fmt.Sprintf("docker exec %s cat %s 2>/dev/null", c.gameserver, shellQuote(p)))
if err != nil {
return ""
}
return content
}
func (c *dockerControl) DiscoverIniDir(_ context.Context, _ Executor) (string, error) {
return "", fmt.Errorf("docker control plane requires server_ini_dir to be set in config")
}

View File

@@ -0,0 +1,345 @@
package main
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
)
// kubectlControl implements ControlPlane using kubectl commands.
// Commands run through the provided Executor (local or SSH-tunneled).
type kubectlControl struct {
namespace string // e.g. "funcom-seabass-mybg"
}
func (c *kubectlControl) Name() string { return "kubectl" }
func kubectlCLI(exec Executor) string {
if exec != nil && exec.Type() == "local" {
return "kubectl"
}
return "sudo kubectl"
}
func (c *kubectlControl) bgName() string {
return strings.TrimPrefix(c.namespace, "funcom-seabass-")
}
func (c *kubectlControl) GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error) {
bgName := c.bgName()
kctl := kubectlCLI(exec)
bgOut, _ := exec.Exec(fmt.Sprintf(
`%s get battlegroups -n %s -o jsonpath="{.items[0].spec.title}|{.items[0].status.phase}|{.items[0].status.database.phase}" 2>/dev/null`,
kctl, c.namespace))
bgParts := strings.SplitN(strings.TrimSpace(bgOut), "|", 3)
ssOut, _ := exec.Exec(fmt.Sprintf(
"%s get serverstats -n %s -o jsonpath='{range .items[*]}{.spec.area.map}|{.spec.area.sietch}|{.spec.area.dimension}|{.spec.area.partition}|{.status.runtime.gamePhase}|{.status.runtime.ready}|{.status.runtime.players}{\"\\n\"}{end}' 2>/dev/null",
kctl, c.namespace))
var servers []ServerRow
for _, line := range strings.Split(strings.TrimSpace(ssOut), "\n") {
if line == "" {
continue
}
p := strings.SplitN(line, "|", 7)
if len(p) < 7 {
continue
}
dim, _ := strconv.Atoi(p[2])
part, _ := strconv.Atoi(p[3])
players, _ := strconv.Atoi(p[6])
servers = append(servers, ServerRow{
Map: p[0],
Sietch: p[1],
Dimension: dim,
Partition: part,
Phase: p[4],
Ready: p[5] == "true",
Players: players,
})
}
sort.Slice(servers, func(i, j int) bool { return servers[i].Map < servers[j].Map })
if servers == nil {
servers = []ServerRow{}
}
return &BattlegroupStatus{
Name: bgName,
Title: safeIdx(bgParts, 0),
Phase: safeIdx(bgParts, 1),
Database: safeIdx(bgParts, 2),
Servers: servers,
}, nil
}
func (c *kubectlControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
bgName := c.bgName()
ns := c.namespace
kctl := kubectlCLI(exec)
switch cmd {
case "start":
return exec.Exec(fmt.Sprintf(
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":false}}' 2>&1 && echo "Battlegroup starting"`,
kctl, bgName, ns))
case "stop":
return exec.Exec(fmt.Sprintf(
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":true}}' 2>&1 && echo "Battlegroup stopping"`,
kctl, bgName, ns))
case "restart":
return exec.Exec(fmt.Sprintf(
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":true}}' 2>/dev/null && sleep 5 && %s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":false}}' 2>/dev/null && echo "Battlegroup restarting"`,
kctl, bgName, ns, kctl, bgName, ns))
default:
// TODO: NEVER run battlegroup.sh with sudo. The script manages files under
// /home/dune/.dune/ and runs as the dune user. Using sudo corrupts ownership
// of those files (bin/, symlinks, etc.) and breaks all subsequent battlegroup
// commands until permissions are manually repaired. kubectl commands above
// legitimately need sudo; battlegroup.sh does NOT.
return exec.Exec(fmt.Sprintf("~/.dune/download/scripts/battlegroup.sh %s 2>&1", cmd))
}
}
func (c *kubectlControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
kctl := kubectlCLI(exec)
out, err := exec.Exec(fmt.Sprintf("%s get pods -n %s --no-headers 2>&1", kctl, c.namespace))
if err != nil {
return nil, "", fmt.Errorf("kubectl: %w", err)
}
var procs []ProcessInfo
for _, line := range splitLines(out) {
if line != "" {
procs = append(procs, ProcessInfo{Name: line, Namespace: c.namespace})
}
}
return procs, c.namespace, nil
}
func (c *kubectlControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
kctl := kubectlCLI(exec)
out, err := exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>&1", kctl, c.namespace))
if err != nil {
return nil, fmt.Errorf("kubectl: %w", err)
}
out2, _ := exec.Exec(
fmt.Sprintf("%s get pods -n funcom-operators --no-headers -o custom-columns=NAME:.metadata.name 2>&1", kctl))
var sources []LogSource
for _, line := range splitLines(out) {
name := strings.TrimSpace(line)
if name != "" && !strings.Contains(name, "db-dbdepl") {
sources = append(sources, LogSource{Namespace: c.namespace, Name: name})
}
}
for _, line := range splitLines(out2) {
name := strings.TrimSpace(line)
if name != "" {
sources = append(sources, LogSource{Namespace: "funcom-operators", Name: name})
}
}
return sources, nil
}
func (c *kubectlControl) StreamLog(_ context.Context, exec Executor, ns, name string) (<-chan string, func(), error) {
kctl := kubectlCLI(exec)
cmd := fmt.Sprintf("%s logs -f -n %s %s 2>&1", kctl, ns, name)
return exec.Stream(cmd)
}
func (c *kubectlControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
kctl := kubectlCLI(exec)
pod, err := exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
kctl, c.namespace))
if err != nil || strings.TrimSpace(pod) == "" {
return "", "", fmt.Errorf("find bgd pod: %w", err)
}
pod = strings.TrimSpace(pod)
existingToken, err := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- env 2>/dev/null | grep FuncomLiveServices__ServiceAuthToken | cut -d= -f2-",
kctl, c.namespace, pod))
if err != nil || strings.TrimSpace(existingToken) == "" {
return "", "", fmt.Errorf("read ServiceAuthToken: %w", err)
}
return buildCaptureJWT(strings.TrimSpace(existingToken))
}
func (c *kubectlControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
if c.namespace == "" {
return "", errNotSupported("kubectl", "EvalOnGameBroker (namespace not configured)")
}
kctl := kubectlCLI(exec)
pod, err := exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep mq-game | head -1",
kctl, c.namespace))
if err != nil || strings.TrimSpace(pod) == "" {
return "", fmt.Errorf("could not find mq-game pod in namespace %s", c.namespace)
}
pod = strings.TrimSpace(pod)
out, err := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- rabbitmqctl eval %s 2>&1",
kctl, c.namespace, pod, shellQuote(expr)))
if err != nil {
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
}
return strings.TrimSpace(out), nil
}
// ── kubectl-specific discovery helpers (used by setup wizard) ─────────────────
// discoverDBPod uses kubectl to find the DB pod, returning namespace, name, and pod IP.
func discoverDBPod(exec Executor) (ns, pod, podIP string, err error) {
kctl := kubectlCLI(exec)
out, err := exec.Exec(
fmt.Sprintf(`%s get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.podIP}{"\n"}{end}' 2>/dev/null | grep db-dbdepl-sts | head -1`, kctl))
if err != nil {
return "", "", "", fmt.Errorf("kubectl: %w", err)
}
parts := strings.Fields(strings.TrimSpace(out))
if len(parts) < 3 {
return "", "", "", fmt.Errorf("database pod not found in cluster")
}
return parts[0], parts[1], parts[2], nil
}
// battlegroupFromPod extracts the battlegroup name from a pod name.
// Pod naming pattern: <battlegroup>-db-dbdepl-sts-<N>
func battlegroupFromPod(pod string) string {
const suffix = "-db-dbdepl-sts-"
if idx := strings.LastIndex(pod, suffix); idx != -1 {
return pod[:idx]
}
return ""
}
// listBattlegroups returns battlegroup names via the battlegroup CLI.
func listBattlegroups(exec Executor) []string {
out, err := exec.Exec("bash -lc 'battlegroup list' 2>/dev/null")
if err != nil || strings.TrimSpace(out) == "" {
return nil
}
var names []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "- ") {
if name := strings.TrimSpace(line[2:]); name != "" {
names = append(names, name)
}
}
}
return names
}
// extractPasswordFromYAML reads DB credentials from a battlegroup YAML on the executor.
func extractPasswordFromYAML(exec Executor, filePath string) (user, pass string) {
out, err := exec.Exec(fmt.Sprintf("cat %s 2>/dev/null", shellQuote(filePath)))
if err != nil || len(out) == 0 {
out, err = exec.Exec(fmt.Sprintf("bash -c 'cat %s'", filePath))
if err != nil || len(out) == 0 {
return "", ""
}
}
return parseDeploymentCredentials([]byte(out))
}
// tryReadINIFromPod attempts to read filename from a specific pod by trying
// well-known Config paths first, then falling back to a find-based search.
func tryReadINIFromPod(exec Executor, kctl, namespace, pod, filename string) string {
candidates := []string{
"/DuneSandbox/Config/" + filename,
"/home/dune/server/DuneSandbox/Config/" + filename,
"/home/dune/DuneSandbox/Config/" + filename,
"/game/DuneSandbox/Config/" + filename,
}
for _, p := range candidates {
content, err := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- cat %s 2>/dev/null",
kctl, namespace, pod, shellQuote(p)))
if err == nil && len(strings.TrimSpace(content)) > 0 {
log.Printf("[default-ini] kubectl: read %s (%d bytes) from pod %s", p, len(content), pod)
return content
}
}
pathOut, _ := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- find -L /DuneSandbox /home /app /game -name %s -not -path '*/Saved/*' 2>/dev/null | head -1",
kctl, namespace, pod, shellQuote(filename)))
if p := strings.TrimSpace(pathOut); p != "" {
content, err := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- cat %s 2>/dev/null",
kctl, namespace, pod, shellQuote(p)))
if err == nil && len(strings.TrimSpace(content)) > 0 {
log.Printf("[default-ini] kubectl: read %s (%d bytes) from pod %s", p, len(content), pod)
return content
}
}
return ""
}
func (c *kubectlControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
if c.namespace == "" {
return ""
}
kctl := kubectlCLI(exec)
podOut, err := exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null",
kctl, c.namespace))
if err != nil {
return ""
}
var sgPods, bgdPods, otherPods []string
for _, line := range strings.Split(podOut, "\n") {
name := strings.TrimSpace(line)
if name == "" {
continue
}
switch {
case strings.Contains(name, "-sg-"):
sgPods = append(sgPods, name)
case strings.Contains(name, "bgd"):
bgdPods = append(bgdPods, name)
default:
otherPods = append(otherPods, name)
}
}
sort.Strings(sgPods)
sort.Strings(bgdPods)
sort.Strings(otherPods)
pods := append(append(sgPods, bgdPods...), otherPods...)
if len(pods) == 0 {
return ""
}
for _, pod := range pods {
if content := tryReadINIFromPod(exec, kctl, c.namespace, pod, filename); content != "" {
return content
}
}
log.Printf("[default-ini] kubectl: %s not found in namespace %s", filename, c.namespace)
return ""
}
func (c *kubectlControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
if c.namespace == "" {
return "", fmt.Errorf("namespace not discovered yet; reconnect or set server_ini_dir in config")
}
// k3s local-path storage: /var/lib/rancher/k3s/storage/<vol>_<ns>_<pvc>/Saved/UserSettings
out, err := exec.Exec(fmt.Sprintf(
`sudo ls /var/lib/rancher/k3s/storage/ 2>/dev/null | grep -F %s | grep -v -- '-db-pvc' | head -1`,
shellQuote(c.namespace)))
if err != nil || strings.TrimSpace(out) == "" {
return "", fmt.Errorf("could not auto-discover ini dir for namespace %s; set server_ini_dir in config", c.namespace)
}
dir := "/var/lib/rancher/k3s/storage/" + strings.TrimSpace(out) + "/Saved/UserSettings"
return dir, nil
}

View File

@@ -0,0 +1,163 @@
package main
import (
"context"
"fmt"
"strings"
)
// localControl implements ControlPlane using configurable shell commands.
// Intended for AMP, LGSM, bare-metal, or any environment where the user
// manages the game server through their own tooling.
type localControl struct {
cmdStart string // e.g. "amp start dune"
cmdStop string
cmdRestart string
cmdStatus string
controlNamespace string
brokerExecPrefix string // e.g. "podman exec AMP_MehDune01" — prepended to rabbitmqctl calls
}
func (c *localControl) Name() string { return "local" }
func (c *localControl) kubectlEnabled(exec Executor) bool {
if c.controlNamespace == "" || exec == nil {
return false
}
_, err := exec.Exec(kubectlCLI(exec) + " version --client >/dev/null 2>&1")
return err == nil
}
func (c *localControl) kubectlDelegate() *kubectlControl {
return &kubectlControl{namespace: c.controlNamespace}
}
func (c *localControl) GetStatus(_ context.Context, exec Executor) (*BattlegroupStatus, error) {
if c.cmdStatus == "" {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().GetStatus(context.Background(), exec)
}
return nil, errNotSupported("local", "GetStatus (cmd_status not configured)")
}
out, err := exec.Exec(c.cmdStatus)
if err != nil {
return nil, fmt.Errorf("status command: %w — %s", err, out)
}
return &BattlegroupStatus{
Name: "local",
Title: "Local Server",
Phase: strings.TrimSpace(out),
Servers: []ServerRow{},
}, nil
}
func (c *localControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
var shellCmd string
switch cmd {
case "start":
shellCmd = c.cmdStart
case "stop":
shellCmd = c.cmdStop
case "restart":
shellCmd = c.cmdRestart
default:
return "", fmt.Errorf("local control does not support %q", cmd)
}
if shellCmd == "" {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().ExecCommand(context.Background(), exec, cmd)
}
return "", errNotSupported("local", fmt.Sprintf("ExecCommand %q (cmd_%s not configured)", cmd, cmd))
}
out, err := exec.Exec(shellCmd)
if err != nil {
return out, fmt.Errorf("%s command: %w — %s", cmd, err, out)
}
return out, nil
}
func (c *localControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().ListProcesses(context.Background(), exec)
}
return nil, "", errNotSupported("local", "ListProcesses")
}
func (c *localControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().ListLogSources(context.Background(), exec)
}
return nil, errNotSupported("local", "ListLogSources")
}
func (c *localControl) StreamLog(_ context.Context, exec Executor, ns, name string) (<-chan string, func(), error) {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().StreamLog(context.Background(), exec, ns, name)
}
return nil, func() {}, errNotSupported("local", "StreamLog")
}
func (c *localControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().CaptureJWT(context.Background(), exec)
}
return "", "", errNotSupported("local", "CaptureJWT")
}
func (c *localControl) brokerBase() string {
if c.brokerExecPrefix != "" {
return c.brokerExecPrefix + " rabbitmqctl"
}
return "rabbitmqctl"
}
func (c *localControl) EvalOnGameBroker(ctx context.Context, exec Executor, expr string) (string, error) {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().EvalOnGameBroker(ctx, exec, expr)
}
out, err := exec.Exec(fmt.Sprintf("%s eval %s 2>&1", c.brokerBase(), shellQuote(expr)))
if err != nil {
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
}
return strings.TrimSpace(out), nil
}
func (c *localControl) ReadDefaultINI(ctx context.Context, exec Executor, filename string) string {
if c.kubectlEnabled(exec) {
return c.kubectlDelegate().ReadDefaultINI(ctx, exec, filename)
}
return "" // host-path traversal in readDefaultINIContent handles local/Hyper-V
}
func (c *localControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
if c.kubectlEnabled(exec) {
ns := c.controlNamespace
kctl := kubectlCLI(exec)
// UserSettings live on game-server pods (-sg-), not the bgd deploy pod.
podOut, err := exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- '-sg-' | head -1",
kctl, ns,
))
if err != nil || strings.TrimSpace(podOut) == "" {
podOut, err = exec.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
kctl, ns,
))
}
if err != nil || strings.TrimSpace(podOut) == "" {
return "", fmt.Errorf("could not find game or bgd pod in namespace %s", ns)
}
pod := strings.TrimSpace(podOut)
findCmd := `for d in /home/dune/server/DuneSandbox/Saved/UserSettings /DuneSandbox/Saved/UserSettings /game/DuneSandbox/Saved/UserSettings; do if [ -d "$d" ]; then echo "$d"; exit 0; fi; done; find / -type d -path "*/Saved/UserSettings" 2>/dev/null | head -1`
dirOut, err := exec.Exec(fmt.Sprintf(
"%s exec -n %s %s -- sh -lc %s 2>/dev/null",
kctl, ns, pod, shellQuote(findCmd),
))
if err != nil || strings.TrimSpace(dirOut) == "" {
return "", fmt.Errorf("could not auto-discover ini dir in pod %s", pod)
}
dir := strings.TrimSpace(dirOut)
return fmt.Sprintf("k8s://%s/%s%s", ns, pod, dir), nil
}
return "", fmt.Errorf("local control plane requires server_ini_dir to be set in config")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// ── Database backups (#150) ─────────────────────────────────────────────────
// AMP-native Postgres backups. The existing handleBGBackup* family targets
// kubectl/k8s pod paths and does nothing on AMP, so this is a separate,
// control-plane-aware path: pg_dump (-Fc) runs inside the AMP container and its
// stdout is redirected to a host file the dune-admin service user owns, so the
// list/download/delete operations are plain os.* calls on the host. Restore
// (pg_restore --clean) is destructive and guarded at the handler layer.
// dbConn is the Postgres connection target for backup/restore.
type dbConn struct {
Host string
Port int
User string
Pass string
Name string
}
type dbBackupFile struct {
Name string `json:"name"`
SizeB int64 `json:"size_bytes"`
Modified string `json:"modified"`
}
// dbBackupProvider is the optional control-plane capability for native database
// backup/restore. Only the AMP control plane implements it; other planes get a
// 501 via the handler's type assertion.
type dbBackupProvider interface {
BackupDatabase(exec Executor, conn dbConn, destPath string) (string, error)
RestoreDatabase(exec Executor, conn dbConn, srcPath string) (string, error)
}
var backupNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+\.dump$`)
// validateBackupName guards against path traversal and shell metacharacters: a
// backup filename must be a bare name (no separators or "..") matching a strict
// charset and ending in .dump.
func validateBackupName(name string) error {
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("invalid backup name")
}
if !backupNameRe.MatchString(name) {
return fmt.Errorf("invalid backup name")
}
return nil
}
// backupsToPrune returns the names to delete to satisfy a keep-N retention
// policy, given names sorted newest-first. keepN <= 0 disables pruning.
func backupsToPrune(newestFirst []string, keepN int) []string {
if keepN <= 0 || len(newestFirst) <= keepN {
return nil
}
return append([]string(nil), newestFirst[keepN:]...)
}
// dbBackupFilename is the canonical timestamped name for a new backup.
func dbBackupFilename(t time.Time) string {
return "dune-" + t.UTC().Format("20060102-150405") + ".dump"
}
// dbBackupDir resolves (and creates) the host directory where dumps live.
func dbBackupDir() (string, error) {
dir := loadedConfig.AmpBackupDir
if dir == "" {
dir = filepath.Join(configDir(), "db-backups")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
return dir, nil
}
// dbBackupConn builds the Postgres connection target from config. The AMP
// Postgres listens on 127.0.0.1:<db_port> both inside and outside the container.
func dbBackupConn() dbConn {
port := loadedConfig.DBPort
if port == 0 {
port = 5432
}
name := loadedConfig.DBName
if name == "" {
name = "dune"
}
user := loadedConfig.DBUser
if user == "" {
user = "dune"
}
return dbConn{Host: "127.0.0.1", Port: port, User: user, Pass: loadedConfig.DBPass, Name: name}
}
// listDBBackups lists the .dump files in the backup dir, newest first.
func listDBBackups() ([]dbBackupFile, error) {
dir, err := dbBackupDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read backup dir: %w", err)
}
files := make([]dbBackupFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".dump") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
files = append(files, dbBackupFile{
Name: e.Name(),
SizeB: info.Size(),
Modified: info.ModTime().UTC().Format(time.RFC3339),
})
}
// RFC3339 UTC strings sort lexicographically in chronological order.
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
return files, nil
}
// deleteDBBackup removes a backup file (and its sibling, if any) from the dir,
// after validating the name. Used by manual delete and retention pruning.
func deleteDBBackup(name string) error {
if err := validateBackupName(name); err != nil {
return err
}
dir, err := dbBackupDir()
if err != nil {
return err
}
path := filepath.Join(dir, name)
// #nosec G304 G703 -- name validated by validateBackupName above (no separators/..)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete backup %q: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,76 @@
package main
import (
"testing"
"time"
)
func TestValidateBackupName(t *testing.T) {
t.Parallel()
good := []string{"dune-20260608-221700.dump", "a.dump", "BG_1.backup.dump"}
for _, n := range good {
if err := validateBackupName(n); err != nil {
t.Errorf("validateBackupName(%q) = %v, want nil", n, err)
}
}
bad := []string{
"", // empty
"foo.txt", // wrong ext
"foo.dump.exe", // wrong ext
"../etc/passwd.dump", // traversal
"a/b.dump", // path sep
"a\\b.dump", // win path sep
"foo .dump", // space
"foo;rm.dump", // shell metachar
".dump", // no stem
}
for _, n := range bad {
if err := validateBackupName(n); err == nil {
t.Errorf("validateBackupName(%q) = nil, want error", n)
}
}
}
func TestBackupsToPrune(t *testing.T) {
t.Parallel()
names := []string{"d5.dump", "d4.dump", "d3.dump", "d2.dump", "d1.dump"} // newest-first
tests := []struct {
name string
keepN int
want []string
}{
{"keep 3 prunes oldest 2", 3, []string{"d2.dump", "d1.dump"}},
{"keep more than present prunes none", 10, nil},
{"keep exactly present prunes none", 5, nil},
{"keep 0 disables pruning", 0, nil},
{"negative disables pruning", -1, nil},
{"keep 1 prunes rest", 1, []string{"d4.dump", "d3.dump", "d2.dump", "d1.dump"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := backupsToPrune(names, tt.keepN)
if len(got) != len(tt.want) {
t.Fatalf("backupsToPrune(keepN=%d) = %v, want %v", tt.keepN, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("backupsToPrune(keepN=%d) = %v, want %v", tt.keepN, got, tt.want)
}
}
})
}
}
func TestDBBackupFilename(t *testing.T) {
t.Parallel()
ts := time.Date(2026, 6, 8, 22, 17, 5, 0, time.UTC)
got := dbBackupFilename(ts)
want := "dune-20260608-221705.dump"
if got != want {
t.Fatalf("dbBackupFilename = %q, want %q", got, want)
}
if err := validateBackupName(got); err != nil {
t.Fatalf("generated name failed validation: %v", err)
}
}

View File

@@ -0,0 +1,67 @@
package main
import (
"strings"
"testing"
)
func TestComputeAwardCharXPOutcome(t *testing.T) {
t.Parallel()
t.Run("caps xp and marks capped", func(t *testing.T) {
t.Parallel()
outcome := computeAwardCharXPOutcome(maxCharXP-10, 5, 3, 99)
if outcome.newXP != maxCharXP {
t.Fatalf("expected capped xp %d, got %d", maxCharXP, outcome.newXP)
}
if !outcome.capped {
t.Fatalf("expected capped=true")
}
if outcome.newTotalSP != outcome.newLevel+3 {
t.Fatalf("expected total SP to include keystone bonus, got level=%d total=%d", outcome.newLevel, outcome.newTotalSP)
}
})
t.Run("clamps unspent to zero", func(t *testing.T) {
t.Parallel()
outcome := computeAwardCharXPOutcome(0, 999, 0, 0)
if outcome.newUnspentSP != 0 {
t.Fatalf("expected unspent SP clamp to 0, got %d", outcome.newUnspentSP)
}
if outcome.capped {
t.Fatalf("expected capped=false")
}
})
}
func TestFormatAwardCharXPSuccess(t *testing.T) {
t.Parallel()
outcome := charXPOutcome{
newXP: 1234,
newLevel: 42,
newUnspentSP: 7,
newIntel: 99,
capped: true,
}
msg := formatAwardCharXPSuccess(777, outcome, 11)
if !strings.Contains(msg, "Player 777") ||
!strings.Contains(msg, "level 42 (capped at level 200)") ||
!strings.Contains(msg, "XP 1234") ||
!strings.Contains(msg, "SP 7 unspent (11 spent)") ||
!strings.Contains(msg, "Intel 99") {
t.Fatalf("unexpected message: %q", msg)
}
}
func TestLoadControllerKeystoneIDs_NoController(t *testing.T) {
t.Parallel()
ids, err := loadControllerKeystoneIDs(t.Context(), 0)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if ids != nil {
t.Fatalf("expected nil ids, got %#v", ids)
}
}

View File

@@ -0,0 +1,158 @@
package main
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
)
// buildFactionDataArray is the pure heart of the in-game rank fix: it produces
// the FactionPlayerComponent.m_FactionDataArray cache the game reads for rank
// and per-territory vendor gating. It must always emit BOTH great houses
// (Atreides=1, Harkonnen=2), defaulting a missing house to 0, and ignore any
// non-great-house faction ids (None=3, Smuggler=4).
func TestBuildFactionDataArray(t *testing.T) {
t.Parallel()
const ts = 1780198964.0
repOf := func(entries []factionDataEntry, name string) (int32, bool) {
for _, e := range entries {
if e.Faction.Name == name {
return e.ReputationAmount, true
}
}
return 0, false
}
tests := []struct {
name string
reps map[int16]int32
wantAtre int32
wantHark int32
}{
{name: "both houses present", reps: map[int16]int32{1: 1500, 2: 2000}, wantAtre: 1500, wantHark: 2000},
{name: "only harkonnen → atreides defaults 0", reps: map[int16]int32{2: 2000}, wantAtre: 0, wantHark: 2000},
{name: "only atreides → harkonnen defaults 0", reps: map[int16]int32{1: 1500}, wantAtre: 1500, wantHark: 0},
{name: "empty → both 0", reps: map[int16]int32{}, wantAtre: 0, wantHark: 0},
{name: "ignores none and smuggler", reps: map[int16]int32{1: 100, 3: 50, 4: 999}, wantAtre: 100, wantHark: 0},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := buildFactionDataArray(tt.reps, ts)
// Exactly the two great houses, always.
if len(got) != 2 {
t.Fatalf("expected 2 entries (both great houses), got %d: %+v", len(got), got)
}
atre, okA := repOf(got, "Atreides")
hark, okH := repOf(got, "Harkonnen")
if !okA || !okH {
t.Fatalf("expected both Atreides and Harkonnen entries, got %+v", got)
}
if atre != tt.wantAtre {
t.Fatalf("Atreides rep: want %d, got %d", tt.wantAtre, atre)
}
if hark != tt.wantHark {
t.Fatalf("Harkonnen rep: want %d, got %d", tt.wantHark, hark)
}
// timestamp propagated to every entry.
for _, e := range got {
if e.Timestamp != ts {
t.Fatalf("entry %s timestamp: want %v, got %v", e.Faction.Name, ts, e.Timestamp)
}
}
})
}
}
// The marshaled shape must match what the game reads exactly:
// {"Faction":{"Name":"Harkonnen"},"timestamp":<float>,"ReputationAmount":<int>}
func TestFactionDataEntryJSONShape(t *testing.T) {
t.Parallel()
arr := buildFactionDataArray(map[int16]int32{2: 2000}, 1780198964.5)
raw, err := json.Marshal(arr)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back []map[string]any
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
var hark map[string]any
for _, e := range back {
if f, ok := e["Faction"].(map[string]any); ok && f["Name"] == "Harkonnen" {
hark = e
}
}
if hark == nil {
t.Fatalf("no Harkonnen entry in %s", raw)
}
if _, ok := hark["Faction"].(map[string]any)["Name"]; !ok {
t.Fatalf("missing Faction.Name in %s", raw)
}
if _, ok := hark["timestamp"]; !ok {
t.Fatalf("missing timestamp key in %s", raw)
}
if rep, ok := hark["ReputationAmount"]; !ok || rep.(float64) != 2000 {
t.Fatalf("ReputationAmount wrong in %s", raw)
}
}
// stubExecer lets us test writeFactionComponent's row-count guard without a real DB.
type stubExecer struct {
tag pgconn.CommandTag
err error
gotSQL string
gotArgs []any
}
func (s *stubExecer) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
s.gotSQL = sql
s.gotArgs = args
return s.tag, s.err
}
func TestWriteFactionComponent(t *testing.T) {
t.Parallel()
arr := buildFactionDataArray(map[int16]int32{2: 2000}, 1.0)
t.Run("one row affected → success", func(t *testing.T) {
t.Parallel()
s := &stubExecer{tag: pgconn.NewCommandTag("UPDATE 1")}
if err := writeFactionComponent(context.Background(), s, 17, arr); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(s.gotArgs) != 2 {
t.Fatalf("expected 2 args (payload, controllerID), got %d", len(s.gotArgs))
}
})
t.Run("zero rows → error (kills the silent no-op)", func(t *testing.T) {
t.Parallel()
s := &stubExecer{tag: pgconn.NewCommandTag("UPDATE 0")}
err := writeFactionComponent(context.Background(), s, 999, arr)
if err == nil {
t.Fatalf("expected error when no row updated, got nil")
}
})
t.Run("exec error is wrapped", func(t *testing.T) {
t.Parallel()
sentinel := errors.New("connection refused")
s := &stubExecer{err: sentinel}
err := writeFactionComponent(context.Background(), s, 17, arr)
if err == nil || !errors.Is(err, sentinel) {
t.Fatalf("expected wrapped exec error, got %v", err)
}
})
}

View File

@@ -0,0 +1,217 @@
package main
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestValidateGiveItemInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
playerID int64
template string
qty int64
wantTpl string
wantError string
}{
{name: "valid", playerID: 123, template: " Dune.Item ", qty: 2, wantTpl: "Dune.Item"},
{name: "missing-player", playerID: 0, template: "x", qty: 1, wantError: "player ID required"},
{name: "missing-template", playerID: 1, template: " ", qty: 1, wantError: "item template required"},
{name: "invalid-qty", playerID: 1, template: "x", qty: 0, wantError: "quantity must be > 0"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := validateGiveItemInput(tt.playerID, tt.template, tt.qty)
if tt.wantError != "" {
if err == nil || err.Error() != tt.wantError {
t.Fatalf("expected error %q, got %v", tt.wantError, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.wantTpl {
t.Fatalf("expected template %q, got %q", tt.wantTpl, got)
}
})
}
}
func TestPlanGiveItemStacks(t *testing.T) {
t.Parallel()
stacks := []giveItemStackSlot{
{id: 1, size: 8},
{id: 2, size: 10},
{id: 3, size: 2},
}
updates, created := planGiveItemStacks(17, 10, stacks)
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
if updates[0].id != 1 || updates[0].add != 2 {
t.Fatalf("unexpected first update: %+v", updates[0])
}
if updates[1].id != 3 || updates[1].add != 8 {
t.Fatalf("unexpected second update: %+v", updates[1])
}
if len(created) != 1 || created[0] != 7 {
t.Fatalf("unexpected created stacks: %#v", created)
}
}
func TestPlanGiveItemStacks_NoStacking(t *testing.T) {
t.Parallel()
updates, created := planGiveItemStacks(3, 1, []giveItemStackSlot{{id: 1, size: 1}})
if len(updates) != 0 {
t.Fatalf("expected no updates, got %#v", updates)
}
if len(created) != 3 || created[0] != 1 || created[1] != 1 || created[2] != 1 {
t.Fatalf("unexpected created stacks: %#v", created)
}
}
func TestEnsureGiveItemSlotCapacity(t *testing.T) {
t.Parallel()
inv := giveItemInventory{maxSlots: 5, hasSlotCap: true}
state := giveItemInventoryState{usedSlots: 3}
if err := ensureGiveItemSlotCapacity(inv, state, 2); err != nil {
t.Fatalf("expected capacity to fit, got %v", err)
}
if err := ensureGiveItemSlotCapacity(inv, state, 3); err == nil {
t.Fatalf("expected slot-capacity error")
}
}
func TestInventoryItemVolume(t *testing.T) {
oldItemData := itemData
itemData = itemDataFile{
DefaultVolume: 2.5,
Items: map[string]itemRule{
"dune.item.known": {Volume: 1.25},
"dune.item.zero": {Volume: 0},
},
}
t.Cleanup(func() { itemData = oldItemData })
override := pgtype.Float8{Float64: 3.0, Valid: true}
if got := inventoryItemVolume("any", override); got != 3.0 {
t.Fatalf("expected override volume 3.0, got %v", got)
}
if got := inventoryItemVolume("Dune.Item.Known", pgtype.Float8{}); got != 1.25 {
t.Fatalf("expected known-rule volume 1.25, got %v", got)
}
if got := inventoryItemVolume("Dune.Item.Zero", pgtype.Float8{}); got != 0 {
t.Fatalf("expected zero volume from rule, got %v", got)
}
if got := inventoryItemVolume("Dune.Item.Unknown", pgtype.Float8{}); got != 2.5 {
t.Fatalf("expected default volume 2.5, got %v", got)
}
}
func TestFormatGiveItemResult(t *testing.T) {
t.Parallel()
got := formatGiveItemResult(42, "Dune.Item", 3, 1, 2)
want := "Added 3 × Dune.Item to player 42 (1 stack(s) topped up, 2 new stack(s))"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestEnsureGiveItemVolumeCapacity(t *testing.T) {
oldItemData := itemData
itemData = itemDataFile{
Items: map[string]itemRule{
"dune.item": {Volume: 2},
},
}
t.Cleanup(func() { itemData = oldItemData })
inv := giveItemInventory{hasVolumeCap: true, maxVolume: 10}
state := giveItemInventoryState{usedVolume: 4}
if err := ensureGiveItemVolumeCapacity(t.Context(), inv, state, "Dune.Item", 3); err != nil {
t.Fatalf("expected capacity to fit, got %v", err)
}
err := ensureGiveItemVolumeCapacity(t.Context(), inv, state, "Dune.Item", 4)
if err == nil {
t.Fatalf("expected volume-capacity error")
}
}
func TestMaxItemsByVolume(t *testing.T) {
t.Parallel()
if got := maxItemsByVolume(100, 40, 15); got != 4 {
t.Fatalf("expected 4 items by volume, got %d", got)
}
if got := maxItemsByVolume(100, 140, 10); got != 0 {
t.Fatalf("expected clamped 0 items by volume, got %d", got)
}
}
func TestRequiredStackCount(t *testing.T) {
t.Parallel()
if got := requiredStackCount(10, 3); got != 4 {
t.Fatalf("expected 4 required stacks, got %d", got)
}
if got := requiredStackCount(1, 1); got != 1 {
t.Fatalf("expected 1 required stack, got %d", got)
}
}
func TestEffectiveStackMax(t *testing.T) {
t.Parallel()
tests := []struct {
name string
stackMax int64
known bool
qty int64
want int64
}{
{
// Known stackable (e.g. from item-data): use its real cap.
name: "known stackable", stackMax: 100, known: true, qty: 5000, want: 100,
},
{
// Known non-stackable (armour/weapon): one per slot.
name: "known non-stackable", stackMax: 1, known: true, qty: 5000, want: 1,
},
{
// The bug: unknown stack max (ammo not in item-data, no existing
// stacks) must NOT be treated as one-per-slot, or 5000 rounds
// demand 5000 slots. Assume it stacks into the requested quantity.
name: "unknown assumes fully stackable", stackMax: 1, known: false, qty: 5000, want: 5000,
},
{
name: "unknown qty=1 stays 1", stackMax: 1, known: false, qty: 1, want: 1,
},
{
// Defensive: a known result below 1 is meaningless → treat as qty.
name: "known but zero falls back to qty", stackMax: 0, known: true, qty: 42, want: 42,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := effectiveStackMax(tt.stackMax, tt.known, tt.qty); got != tt.want {
t.Errorf("effectiveStackMax(%d, %v, %d) = %d, want %d",
tt.stackMax, tt.known, tt.qty, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,41 @@
package main
import "testing"
func TestAllKeystoneIDs(t *testing.T) {
t.Parallel()
ids := allKeystoneIDs()
if len(ids) != 205 {
t.Fatalf("expected 205 keystone ids, got %d", len(ids))
}
if ids[0] != 1 || ids[len(ids)-1] != 205 {
t.Fatalf("unexpected ID bounds: first=%d last=%d", ids[0], ids[len(ids)-1])
}
for i, id := range ids {
if int(id) != i+1 {
t.Fatalf("unexpected id sequence at index %d: got %d", i, id)
}
}
}
func TestGrantAllKeystoneTargets(t *testing.T) {
t.Parallel()
bonus := keystoneSPBonus(allKeystoneIDs())
expectedTotal, expectedUnspent, gotBonus := grantAllKeystoneTargets(0, 0)
if gotBonus != bonus {
t.Fatalf("expected bonus %d, got %d", bonus, gotBonus)
}
if expectedTotal != bonus {
t.Fatalf("expected total skill points %d at xp=0, got %d", bonus, expectedTotal)
}
if expectedUnspent != expectedTotal-1 {
t.Fatalf("expected unspent=%d, got %d", expectedTotal-1, expectedUnspent)
}
_, expectedUnspent, _ = grantAllKeystoneTargets(0, 99999)
if expectedUnspent != 0 {
t.Fatalf("expected negative unspent to clamp to 0, got %d", expectedUnspent)
}
}

View File

@@ -0,0 +1,39 @@
package main
import "testing"
// Phase 1 (Live Map): the map-key allow-list is the pure, testable unit of the
// markers query. v1 scope is open-world only (Hagga Basin + Deep Desert); the
// city instances (Arrakeen/HarkoVillage) are deliberately out of scope. The key
// is also the one piece of caller-supplied input that reaches the query, so the
// allow-list doubles as an injection guard even though the query parameterises it.
func TestValidateMapKey(t *testing.T) {
t.Parallel()
tests := []struct {
name string
key string
wantErr bool
}{
{name: "hagga basin", key: "HaggaBasin", wantErr: false},
{name: "deep desert", key: "DeepDesert", wantErr: false},
{name: "city out of v1 scope", key: "Arrakeen", wantErr: true},
{name: "unknown map", key: "Atlantis", wantErr: true},
{name: "empty", key: "", wantErr: true},
{name: "injection attempt", key: "HaggaBasin'; DROP TABLE dune.actors; --", wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateMapKey(tt.key)
if tt.wantErr && err == nil {
t.Fatalf("validateMapKey(%q): expected error, got nil", tt.key)
}
if !tt.wantErr && err != nil {
t.Fatalf("validateMapKey(%q): unexpected error: %v", tt.key, err)
}
})
}
}

View File

@@ -0,0 +1,111 @@
package main
import (
"strings"
"testing"
)
func TestProgressionFactionConfigFor(t *testing.T) {
t.Parallel()
atreides, err := progressionFactionConfigFor("atreides")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if atreides.factionID != 1 || atreides.alignedFlag != "DialogueFlags.Factions.AlignedAtreides" {
t.Fatalf("unexpected atreides config: %+v", atreides)
}
if _, err := progressionFactionConfigFor("unknown"); err == nil {
t.Fatalf("expected error for unknown faction")
}
}
func TestProgressionTargetTierForPreset(t *testing.T) {
t.Parallel()
if tier, err := progressionTargetTierForPreset("ch3_start"); err != nil || tier != 5 {
t.Fatalf("expected ch3_start => 5, got tier=%d err=%v", tier, err)
}
if tier, err := progressionTargetTierForPreset("rank19_eligible"); err != nil || tier != 19 {
t.Fatalf("expected rank19_eligible => 19, got tier=%d err=%v", tier, err)
}
if _, err := progressionTargetTierForPreset("bad"); err == nil {
t.Fatalf("expected error for bad preset")
}
}
func TestProgressionUnlockTags(t *testing.T) {
t.Parallel()
cfg, _ := progressionFactionConfigFor("atreides")
tags := progressionUnlockTags(cfg, 19)
required := []string{
"DialogueFlags.Factions.SentToMeetHawat",
"DialogueFlags.Factions.AlignedAtreides",
"Journey.LandsraadContractsUnlocked",
"Faction.Atreides.Tier0",
"Faction.Atreides.Tier5",
}
for _, tag := range required {
if !containsString(tags, tag) {
t.Fatalf("expected tag %q in output: %#v", tag, tags)
}
}
if containsString(tags, "Faction.Atreides.Tier6") {
t.Fatalf("did not expect Tier6 tag in output")
}
}
func TestFormatProgressionUnlockSuccess(t *testing.T) {
t.Parallel()
msg := formatProgressionUnlockSuccess("ch3_start", "atreides", 12, "Atreides", 5, 777)
expectParts := []string{
"Progression unlock (ch3_start/atreides)",
"12 journey nodes completed",
"Atreides tier tags 05",
"rep tier 5 on controller 777",
}
for _, part := range expectParts {
if !strings.Contains(msg, part) {
t.Fatalf("expected message to contain %q, got %q", part, msg)
}
}
}
func TestProgressionReverseTags(t *testing.T) {
t.Parallel()
base := []string{"A", "B"}
got := progressionReverseTags(base, []string{"unknown.node"})
if len(got) != 2 || got[0] != "A" || got[1] != "B" {
t.Fatalf("expected base tags unchanged for unknown node, got %#v", got)
}
}
func TestFormatProgressionReverseSuccess(t *testing.T) {
t.Parallel()
msg := formatProgressionReverseSuccess("rank19_eligible", "harkonnen", 7, 19)
expectParts := []string{
"Reversed progression unlock (rank19_eligible/harkonnen)",
"reset 7 node(s)",
"removed 19 tag(s)",
}
for _, part := range expectParts {
if !strings.Contains(msg, part) {
t.Fatalf("expected message to contain %q, got %q", part, msg)
}
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,32 @@
package main
import "testing"
func TestRepairItemNoChangeMessage(t *testing.T) {
t.Parallel()
msg := repairItemNoChangeMessage(42, false)
if msg.err == nil || msg.err.Error() != "item 42 has no durability field" {
t.Fatalf("expected no-durability error, got %+v", msg)
}
msg = repairItemNoChangeMessage(42, true)
if msg.err != nil {
t.Fatalf("expected success message, got error %v", msg.err)
}
if msg.ok != "Item 42 already at full durability" {
t.Fatalf("unexpected success message: %q", msg.ok)
}
}
func TestRepairItemSuccessMessage(t *testing.T) {
t.Parallel()
msg := repairItemSuccessMessage(99)
if msg.err != nil {
t.Fatalf("expected nil error, got %v", msg.err)
}
if msg.ok != "Repaired item 99 — relog to see in-game" {
t.Fatalf("unexpected success message: %q", msg.ok)
}
}

View File

@@ -0,0 +1,152 @@
package main
import (
"math"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestParseDurabilityText(t *testing.T) {
t.Parallel()
if got := parseDurabilityText(pgtype.Text{}); got != 0 {
t.Fatalf("expected invalid text to parse as 0, got %v", got)
}
if got := parseDurabilityText(pgtype.Text{String: "12.5", Valid: true}); got != 12.5 {
t.Fatalf("expected 12.5, got %v", got)
}
if got := parseDurabilityText(pgtype.Text{String: "not-a-number", Valid: true}); got != 0 {
t.Fatalf("expected parse failure to fall back to 0, got %v", got)
}
}
func TestRepairTargetForItem(t *testing.T) {
t.Parallel()
tests := []struct {
name string
maxText pgtype.Text
want float64
}{
{
name: "in-row MaxDurability is the source of truth (200-scale item)",
maxText: pgtype.Text{String: "200", Valid: true},
want: 200,
},
{
name: "plain gear without MaxDurability defaults to 100",
maxText: pgtype.Text{},
want: 100,
},
{
name: "unparseable MaxDurability defaults to 100",
maxText: pgtype.Text{String: "oops", Valid: true},
want: 100,
},
{
name: "zero MaxDurability defaults to 100",
maxText: pgtype.Text{String: "0", Valid: true},
want: 100,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := repairTargetForItem(tt.maxText); got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestBuildRepairCandidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
itemID int64
maxDurability pgtype.Text
current pgtype.Text
decayed pgtype.Text
wantNeedsFix bool
wantTarget float64
}{
{
name: "already at target",
itemID: 1,
maxDurability: pgtype.Text{String: "100", Valid: true},
current: pgtype.Text{String: "100", Valid: true},
decayed: pgtype.Text{String: "100", Valid: true},
wantNeedsFix: false,
},
{
name: "plain 0-100 gear restores to 100",
itemID: 2,
maxDurability: pgtype.Text{},
current: pgtype.Text{String: "75", Valid: true},
decayed: pgtype.Text{String: "100", Valid: true},
wantNeedsFix: true,
wantTarget: 100,
},
{
name: "200-scale item restores to 200, not capped at 100",
itemID: 3,
maxDurability: pgtype.Text{String: "200", Valid: true},
current: pgtype.Text{String: "50", Valid: true},
decayed: pgtype.Text{String: "200", Valid: true},
wantNeedsFix: true,
wantTarget: 200,
},
{
name: "never lowers below an existing value when MaxDurability absent",
itemID: 4,
maxDurability: pgtype.Text{},
current: pgtype.Text{String: "150", Valid: true},
decayed: pgtype.Text{String: "120", Valid: true},
wantNeedsFix: true,
wantTarget: 150,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
candidate, needsFix := buildRepairCandidate(tt.itemID, tt.maxDurability, tt.current, tt.decayed)
if needsFix != tt.wantNeedsFix {
t.Fatalf("expected needsFix=%v, got %v", tt.wantNeedsFix, needsFix)
}
if !needsFix {
return
}
if candidate.id != tt.itemID {
t.Fatalf("expected item ID %d, got %d", tt.itemID, candidate.id)
}
if math.Abs(candidate.target-tt.wantTarget) > 0.0001 {
t.Fatalf("expected target %.4f, got %.4f", tt.wantTarget, candidate.target)
}
})
}
}
func TestValidateRepairPlayerGearInput(t *testing.T) {
originalDB := globalDB
t.Cleanup(func() { globalDB = originalDB })
globalDB = nil
if err := validateRepairPlayerGearInput(42); err == nil || err.Error() != "not connected" {
t.Fatalf("expected not connected error, got %v", err)
}
globalDB = &pgxpool.Pool{}
if err := validateRepairPlayerGearInput(0); err == nil || err.Error() != "player ID required" {
t.Fatalf("expected player ID required error, got %v", err)
}
if err := validateRepairPlayerGearInput(42); err != nil {
t.Fatalf("expected valid input, got %v", err)
}
}

View File

@@ -0,0 +1,155 @@
package main
import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
// durText builds a valid pgtype.Text holding a numeric durability string.
func durText(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: true}
}
func TestValidateRepairVehicleInput(t *testing.T) {
originalDB := globalDB
t.Cleanup(func() { globalDB = originalDB })
globalDB = nil
if err := validateRepairVehicleInput(42); err == nil || err.Error() != "not connected" {
t.Fatalf("expected not connected error, got %v", err)
}
globalDB = &pgxpool.Pool{}
if err := validateRepairVehicleInput(0); err == nil || err.Error() != "player ID required" {
t.Fatalf("expected player ID required error, got %v", err)
}
if err := validateRepairVehicleInput(42); err != nil {
t.Fatalf("expected valid input, got %v", err)
}
}
func TestVehicleModuleRepairTarget(t *testing.T) {
t.Parallel()
tests := []struct {
name string
module vehicleModule
wantTgt float64
wantOK bool
}{
{
name: "in-row MaxDurability is the source of truth (regression: was halved by catalog)",
module: vehicleModule{maxDurability: durText("12000"), currentDurability: durText("6000"), decayedDurability: durText("6000")},
wantTgt: 12000,
wantOK: true,
},
{
name: "no in-row MaxDurability is skipped, never guessed from catalog",
module: vehicleModule{maxDurability: pgtype.Text{}, currentDurability: durText("6000")},
wantOK: false,
},
{
name: "unparseable MaxDurability is skipped",
module: vehicleModule{maxDurability: durText("oops")},
wantOK: false,
},
{
name: "zero MaxDurability is skipped",
module: vehicleModule{maxDurability: durText("0")},
wantOK: false,
},
{
name: "never lowers an existing higher value",
module: vehicleModule{maxDurability: durText("100"), currentDurability: durText("80"), decayedDurability: durText("150")},
wantTgt: 150,
wantOK: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
target, ok := vehicleModuleRepairTarget(tt.module)
if ok != tt.wantOK {
t.Fatalf("expected ok=%v, got ok=%v (target=%v)", tt.wantOK, ok, target)
}
if ok && target != tt.wantTgt {
t.Fatalf("expected target=%v, got %v", tt.wantTgt, target)
}
})
}
}
func TestVehicleModuleAtTarget(t *testing.T) {
t.Parallel()
full := vehicleModule{currentDurability: durText("12000"), decayedDurability: durText("12000")}
if !vehicleModuleAtTarget(full, 12000) {
t.Fatalf("expected full module to be at target")
}
damaged := vehicleModule{currentDurability: durText("6000"), decayedDurability: durText("6000")}
if vehicleModuleAtTarget(damaged, 12000) {
t.Fatalf("expected damaged module to not be at target")
}
}
func TestRunVehicleModuleRepairs(t *testing.T) {
t.Parallel()
modules := []vehicleModule{
{id: 1, maxDurability: durText("12000"), currentDurability: durText("6000"), decayedDurability: durText("6000")}, // repair → 12000
{id: 2, maxDurability: pgtype.Text{}, currentDurability: durText("6000")}, // skip (no in-row max)
{id: 3, maxDurability: durText("9000"), currentDurability: durText("9000"), decayedDurability: durText("9000")}, // full → no-op
{id: 4, maxDurability: durText("12000"), currentDurability: durText("0"), decayedDurability: durText("12000")}, // repair → 12000
}
var repairedIDs []int64
summary := runVehicleModuleRepairs(modules, func(module vehicleModule, target float64) error {
if target != 12000 {
t.Fatalf("expected target 12000, got %v for module %d", target, module.id)
}
repairedIDs = append(repairedIDs, module.id)
return nil
})
if summary.err != nil {
t.Fatalf("unexpected error: %v", summary.err)
}
if summary.total != 4 || summary.repaired != 2 || summary.skipped != 1 {
t.Fatalf("unexpected summary: %+v", summary)
}
if len(repairedIDs) != 2 || repairedIDs[0] != 1 || repairedIDs[1] != 4 {
t.Fatalf("unexpected repaired IDs: %v", repairedIDs)
}
}
func TestRunVehicleModuleRepairs_StopsOnError(t *testing.T) {
t.Parallel()
modules := []vehicleModule{
{id: 10, maxDurability: durText("125"), currentDurability: durText("0"), decayedDurability: durText("0")},
{id: 11, maxDurability: durText("125"), currentDurability: durText("0"), decayedDurability: durText("0")},
{id: 12, maxDurability: pgtype.Text{}},
}
failErr := errors.New("boom")
summary := runVehicleModuleRepairs(modules, func(module vehicleModule, _ float64) error {
if module.id == 11 {
return failErr
}
return nil
})
if summary.err == nil {
t.Fatalf("expected repair error")
}
if summary.err.Error() != "repair module 11: boom" {
t.Fatalf("unexpected error: %v", summary.err)
}
if summary.total != 3 || summary.repaired != 1 || summary.skipped != 0 {
t.Fatalf("unexpected summary after failure: %+v", summary)
}
}

View File

@@ -0,0 +1,143 @@
package main
import (
"context"
"reflect"
"testing"
)
func TestValidateContractMutationInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accountID int64
contracts []string
wantError string
}{
{name: "valid", accountID: 10, contracts: []string{"DA_CT_A"}},
{name: "missing-account", accountID: 0, contracts: []string{"DA_CT_A"}, wantError: "account ID required"},
{name: "missing-contracts", accountID: 10, contracts: nil, wantError: "at least one contract required"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateContractMutationInput(tt.accountID, tt.contracts)
if tt.wantError == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || err.Error() != tt.wantError {
t.Fatalf("expected error %q, got %v", tt.wantError, err)
}
})
}
}
func TestBuildContractRemovalSet(t *testing.T) {
originalTagsData := tagsData
tagsData = tagsDataFile{
ContractAliases: map[string]string{
"shortA": "DA_CT_A",
},
ContractTags: map[string][]string{
"DA_CT_A": {"Tag.A", "Tag.B"},
"DA_CT_B": {"Tag.B", "Tag.C"},
},
ContractSkillGrants: map[string][]string{
"DA_CT_A": {"Skills.Key.A", "Skills.Key.B"},
"DA_CT_B": {"Skills.Key.B", "Skills.Key.C"},
},
}
t.Cleanup(func() { tagsData = originalTagsData })
set, err := buildContractRemovalSet([]string{"shortA", "DA_CT_B", "shortA"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantResolved := []string{"DA_CT_A", "DA_CT_B", "DA_CT_A"}
if !reflect.DeepEqual(set.resolvedNames, wantResolved) {
t.Fatalf("unexpected resolved names: %#v", set.resolvedNames)
}
wantTags := []string{"Tag.A", "Tag.B", "Tag.C"}
if !reflect.DeepEqual(set.removeTags, wantTags) {
t.Fatalf("unexpected remove tags: %#v", set.removeTags)
}
wantSkills := []string{"Skills.Key.A", "Skills.Key.B", "Skills.Key.C"}
if !reflect.DeepEqual(set.removeSkills, wantSkills) {
t.Fatalf("unexpected remove skills: %#v", set.removeSkills)
}
}
func TestBuildContractRemovalSet_UnknownContract(t *testing.T) {
originalTagsData := tagsData
tagsData = tagsDataFile{
ContractAliases: map[string]string{},
ContractTags: map[string][]string{},
}
t.Cleanup(func() { tagsData = originalTagsData })
_, err := buildContractRemovalSet([]string{"missing"})
if err == nil {
t.Fatal("expected unknown contract error")
}
}
func TestContractBatchSummary(t *testing.T) {
t.Parallel()
if got := contractBatchSummary([]string{"DA_CT_SINGLE"}); got != "DA_CT_SINGLE" {
t.Fatalf("unexpected single summary: %q", got)
}
if got := contractBatchSummary([]string{"A", "B"}); got != "2 contracts" {
t.Fatalf("unexpected multi summary: %q", got)
}
}
func TestRemoveContractTags_NoTagsIsNoop(t *testing.T) {
t.Parallel()
if err := removeContractTags(context.Background(), 123, nil); err != nil {
t.Fatalf("expected no-op nil tags, got %v", err)
}
}
func TestStripContractSkillBlocks_NoopCases(t *testing.T) {
t.Parallel()
if stripped, err := stripContractSkillBlocks(context.Background(), 0, []string{"Skills.Key.A"}); err != nil || stripped != 0 {
t.Fatalf("expected pawn 0 no-op, stripped=%d err=%v", stripped, err)
}
if stripped, err := stripContractSkillBlocks(context.Background(), 10, nil); err != nil || stripped != 0 {
t.Fatalf("expected empty skills no-op, stripped=%d err=%v", stripped, err)
}
}
func TestApplyContractSkillGrants_NoSkillsIsNoop(t *testing.T) {
t.Parallel()
extra, err := applyContractSkillGrants(context.Background(), 123, nil)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if extra != "" {
t.Fatalf("expected empty extra string, got %q", extra)
}
}
func TestContractShortNames(t *testing.T) {
t.Parallel()
got := contractShortNames([]string{"DA_CT_Trainer", "NoPrefix"})
want := []string{"Trainer", "NoPrefix"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected short names: %#v", got)
}
}

View File

@@ -0,0 +1,87 @@
package main
import (
"strings"
"testing"
)
func TestFormatSQLRow(t *testing.T) {
t.Parallel()
row := formatSQLRow([]any{int64(7), "name", nil})
if row != "7 │ name │ <nil>" {
t.Fatalf("unexpected row format: %q", row)
}
}
func TestFormatSQLStringRows(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in [][]any
want [][]string
}{
{
name: "integers and strings convert to string representation",
in: [][]any{{int64(1), "alpha"}, {int64(2), "beta"}},
want: [][]string{{"1", "alpha"}, {"2", "beta"}},
},
{
name: "nil becomes <nil>",
in: [][]any{{nil, "x"}},
want: [][]string{{"<nil>", "x"}},
},
{
name: "empty input returns empty slice",
in: [][]any{},
want: [][]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatSQLStringRows(tt.in)
if len(got) != len(tt.want) {
t.Fatalf("len=%d want=%d", len(got), len(tt.want))
}
for i, row := range got {
if len(row) != len(tt.want[i]) {
t.Fatalf("row %d: len=%d want=%d", i, len(row), len(tt.want[i]))
}
for j, cell := range row {
if cell != tt.want[i][j] {
t.Fatalf("[%d][%d]: got %q want %q", i, j, cell, tt.want[i][j])
}
}
}
})
}
}
func TestBuildSQLResult(t *testing.T) {
t.Parallel()
result := buildSQLResult(
[]string{"id", "name"},
[][]any{
{int64(1), "alpha"},
{int64(2), "beta"},
},
false,
)
if !strings.Contains(result, "id │ name\n") {
t.Fatalf("expected header line in result: %q", result)
}
if !strings.Contains(result, "1 │ alpha\n") || !strings.Contains(result, "2 │ beta\n") {
t.Fatalf("expected row lines in result: %q", result)
}
if strings.Contains(result, "limited to 200 rows") {
t.Fatalf("did not expect truncation marker in non-truncated result")
}
truncated := buildSQLResult([]string{"id"}, [][]any{{1}}, true)
if !strings.Contains(truncated, "… (limited to 200 rows)\n") {
t.Fatalf("expected truncation marker in truncated result: %q", truncated)
}
}

View File

@@ -0,0 +1,32 @@
package main
import "testing"
func TestSampleTableQuery(t *testing.T) {
t.Parallel()
origSchema := dbSchema
t.Cleanup(func() { dbSchema = origSchema })
dbSchema = "dune"
query := sampleTableQuery(`items"; DROP TABLE dune.items; --`, 25)
want := `SELECT * FROM "dune"."items""; DROP TABLE dune.items; --" LIMIT 25`
if query != want {
t.Fatalf("unexpected query sanitization\nwant: %q\ngot: %q", want, query)
}
}
func TestFormatSampleRow(t *testing.T) {
t.Parallel()
row := formatSampleRow([]any{int64(1), "alpha", nil, true})
want := []string{"1", "alpha", "<nil>", "true"}
if len(row) != len(want) {
t.Fatalf("unexpected row length: got %d want %d", len(row), len(want))
}
for i := range want {
if row[i] != want[i] {
t.Fatalf("unexpected row[%d]: got %q want %q", i, row[i], want[i])
}
}
}

View File

@@ -0,0 +1,77 @@
package main
import (
"strings"
"testing"
)
func TestResolveStarterClassAbility(t *testing.T) {
originalTagsData := tagsData
t.Cleanup(func() { tagsData = originalTagsData })
tagsData = tagsDataFile{
JobSkillBlocks: map[string][]string{
"Trooper": {"Skills.Key.Trooper1"},
},
}
ability, err := resolveStarterClassAbility("Trooper")
if err != nil {
t.Fatalf("unexpected error resolving known job: %v", err)
}
if ability != "Skills.Ability.SuspensorGrenade_Reduction" {
t.Fatalf("unexpected starter ability: %q", ability)
}
if _, err := resolveStarterClassAbility("Unknown"); err == nil {
t.Fatalf("expected error for unknown job")
}
}
func TestStarterKeysToRemove(t *testing.T) {
t.Parallel()
if keys := starterKeysToRemove("", "Trooper"); len(keys) != 0 {
t.Fatalf("expected no keys when old starter is empty, got %#v", keys)
}
if keys := starterKeysToRemove("Skills.Key.Trooper1", "Trooper"); len(keys) != 0 {
t.Fatalf("expected no keys when switching to same job, got %#v", keys)
}
keys := starterKeysToRemove("Skills.Key.Mentat1", "Trooper")
if len(keys) != 2 {
t.Fatalf("expected 2 keys to remove, got %#v", keys)
}
if keys[0] != `(TagName="Skills.Key.Mentat1")` {
t.Fatalf("unexpected starter key removal: %q", keys[0])
}
if keys[1] != `(TagName="Skills.Ability.PoisonCapsuleLauncher")` {
t.Fatalf("unexpected ability key removal: %q", keys[1])
}
}
func TestStarterClassTagAndKeys(t *testing.T) {
t.Parallel()
tag, starterKey, abilityKey := starterClassTagAndKeys("Trooper", "Skills.Ability.SuspensorGrenade_Reduction")
if tag != "Skills.Key.Trooper1" {
t.Fatalf("unexpected starter tag: %q", tag)
}
if starterKey != `(TagName="Skills.Key.Trooper1")` {
t.Fatalf("unexpected starter key: %q", starterKey)
}
if abilityKey != `(TagName="Skills.Ability.SuspensorGrenade_Reduction")` {
t.Fatalf("unexpected ability key: %q", abilityKey)
}
}
func TestFormatStarterClassMessage(t *testing.T) {
t.Parallel()
msg := formatStarterClassMessage("Trooper", "Skills.Key.Trooper1", "Skills.Ability.SuspensorGrenade_Reduction", 2)
if !strings.Contains(msg, "Starter class set to Trooper") ||
!strings.Contains(msg, "Skills.Key.Trooper1 + Skills.Ability.SuspensorGrenade_Reduction active") ||
!strings.Contains(msg, "cleared previous starter (2 module(s))") {
t.Fatalf("unexpected message: %q", msg)
}
}

View File

@@ -0,0 +1,59 @@
package main
import "testing"
func TestSpiceVisionNodeIDs(t *testing.T) {
t.Parallel()
// DA_MQ_FindTheFremen and any of its sub-nodes must trigger spice vision.
cases := []struct {
nodeID string
want bool
}{
{"DA_MQ_FindTheFremen", true},
{"DA_MQ_FindTheFremen.FourthTest", true},
{"DA_MQ_FindTheFremen.FourthTest.FourthQuestion.CompleteFourthTest", true},
{"DA_MQ_FindTheFremen.Epilogue", true},
{"DA_MQ_ANewBeginning", false},
{"DA_MQ_ANewBeginning.Aql No 1.FabricateStillsuit.Equip the Stillsuit", false},
{"DA_SQ_VermiliusGap", false},
{"DA_FQ_ClimbTheRanks", false},
}
for _, tc := range cases {
got := nodeIDTriggersSpiceVision(tc.nodeID)
if got != tc.want {
t.Errorf("nodeIDTriggersSpiceVision(%q) = %v, want %v", tc.nodeID, got, tc.want)
}
}
}
func TestSpiceVisionSQL(t *testing.T) {
t.Parallel()
// Verify the SQL snippet is well-formed and targets the right JSONB path.
sql := spiceVisionEnableSQL
for _, substr := range []string{
"FSpiceAddictionComponent",
"SpiceVisionEnabledStatus",
"FullyEnabled",
"DuneCharacter",
} {
if !containsSubstring(sql, substr) {
t.Errorf("spiceVisionEnableSQL missing %q", substr)
}
}
}
func containsSubstring(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstringHelper(s, sub))
}
func containsSubstringHelper(s, sub string) bool {
for i := range s {
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -0,0 +1,46 @@
package main
import (
"strings"
"testing"
)
// TestGMSeedSpec locks the recon-derived seed values for the GM/Server persona:
// the sentinel ids (collision-free per Phase 0 recon), the exact actor class paths
// the live schema uses (or the game's player-info lookup fails and the sender never
// renders), and the blast-radius-safe defaults (Offline status; the seed routine
// leaves actors.transform NULL so the GM never plots on the live map).
func TestGMSeedSpec(t *testing.T) {
t.Parallel()
s := gmSeedSpec()
if s.AccountID != gmIdentityAccountID {
t.Fatalf("AccountID = %d, want %d", s.AccountID, gmIdentityAccountID)
}
// Actor ids derive from the account id: 9000001 -> 900000101/02/03.
if s.ControllerID != 900000101 || s.StateID != 900000102 || s.PawnID != 900000103 {
t.Fatalf("actor ids wrong: %d/%d/%d", s.ControllerID, s.StateID, s.PawnID)
}
if !strings.Contains(s.ControllerClass, "BP_DunePlayerController") {
t.Fatalf("controller class wrong: %s", s.ControllerClass)
}
if !strings.Contains(s.StateClass, "DunePlayerState") {
t.Fatalf("state class wrong: %s", s.StateClass)
}
if !strings.Contains(s.PawnClass, "BP_DunePlayerCharacter") {
t.Fatalf("pawn class wrong: %s", s.PawnClass)
}
// Blast-radius: Offline keeps the GM out of the online pollers / welcome scanner.
if s.OnlineStatus != "Offline" {
t.Fatalf("OnlineStatus = %q, want Offline", s.OnlineStatus)
}
if s.LifeState != "Alive" {
t.Fatalf("LifeState = %q, want Alive", s.LifeState)
}
if s.FuncomID != "GM#0001" || s.CharacterName != "GM" {
t.Fatalf("persona wrong: funcom=%q char=%q", s.FuncomID, s.CharacterName)
}
if s.Map != "HaggaBasin" || s.PartitionID != 1 {
t.Fatalf("location wrong: map=%q partition=%d", s.Map, s.PartitionID)
}
}

View File

@@ -0,0 +1,245 @@
package main
import (
"context"
"fmt"
"strings"
)
// schematicCategory returns the effective category for a template ID.
// Schematics (detected by is_schematic flag or naming conventions) are
// reclassified under "schematics/<type>" where <type> is only the first
// path segment after "items/" (e.g. "weapons", "utility").
// Using a single sub-level prevents mirroring the full items tree under schematics/.
//
// Naming patterns covered:
// - T6_Lasgun_Schematic (suffix _Schematic — regular schematics)
// - Schematic_UniqueXxx (prefix Schematic_ — unique/named schematics)
// - ChoamHeavyLasgunSchematic (suffix Schematic, no underscore)
func schematicCategory(templateID, baseCategory string, isSchematic bool) string {
lc := strings.ToLower(templateID)
if !isSchematic &&
!strings.HasSuffix(lc, "_schematic") &&
!strings.HasPrefix(lc, "schematic_") &&
!strings.HasSuffix(lc, "schematic") {
return baseCategory
}
rest := strings.TrimPrefix(baseCategory, "items/")
if rest == "" || rest == baseCategory {
return "schematics"
}
// Take only the first segment (e.g. "utility" from "utility/gatheringtools/compactor").
if idx := strings.Index(rest, "/"); idx != -1 {
rest = rest[:idx]
}
return "schematics/" + rest
}
// itemRuleLookup returns the itemRule for a template ID, trying exact key then lowercase fallback.
func itemRuleLookup(templateID string) (itemRule, bool) {
if r, ok := itemData.Items[templateID]; ok {
return r, true
}
if r, ok := itemData.Items[strings.ToLower(templateID)]; ok {
return r, true
}
return itemRule{}, false
}
// itemNameLookup returns the display name for a template ID.
func itemNameLookup(templateID string) string {
if n := itemData.Names[templateID]; n != "" {
return n
}
if n := itemData.Names[strings.ToLower(templateID)]; n != "" {
return n
}
return templateID
}
// cmdFetchMarketItems returns all active exchange listings aggregated by template ID,
// enriched with catalog metadata from item-data.json.
func cmdFetchMarketItems() Msg {
if globalDB == nil {
return msgMarketItems{err: fmt.Errorf("not connected")}
}
rows, err := globalDB.Query(context.Background(), `
SELECT
o.template_id,
o.quality_level,
MIN(o.item_price) AS lowest_price,
COALESCE(SUM(COALESCE(i.stack_size, s.initial_stack_size)), 0) AS total_stock,
COALESCE(SUM(CASE WHEN o.is_npc_order
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS bot_stock,
COUNT(*) AS listing_count
FROM dune.dune_exchange_orders o
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
LEFT JOIN dune.items i ON i.id = o.item_id
GROUP BY o.template_id, o.quality_level
ORDER BY o.template_id, o.quality_level`)
if err != nil {
return msgMarketItems{err: err}
}
defer rows.Close()
var items []marketItem
for rows.Next() {
var tmpl string
var quality, lowestPrice, totalStock, botStock, listingCount int64
if err := rows.Scan(&tmpl, &quality, &lowestPrice, &totalStock, &botStock, &listingCount); err != nil {
continue
}
rule, _ := itemRuleLookup(tmpl)
cat := schematicCategory(tmpl, rule.Category, rule.IsSchematic)
name := itemNameLookup(tmpl)
if cat == "schematics" || strings.HasPrefix(cat, "schematics/") {
name += " (Schematic)"
}
items = append(items, marketItem{
TemplateID: tmpl,
Quality: quality,
DisplayName: name,
Category: cat,
Tier: rule.Tier,
Rarity: rule.Rarity,
LowestPrice: lowestPrice,
TotalStock: totalStock,
BotStock: botStock,
ListingCount: listingCount,
Icon: rule.Icon,
})
}
if rows.Err() != nil {
return msgMarketItems{err: rows.Err()}
}
return msgMarketItems{rows: items}
}
// cmdFetchMarketListings returns all active exchange listings, optionally filtered by template ID.
// Pass templateID="" to fetch all listings.
func cmdFetchMarketListings(templateID string) Msg {
if globalDB == nil {
return msgMarketListings{err: fmt.Errorf("not connected")}
}
var args []any
where := ""
if templateID != "" {
where = "WHERE o.template_id = $1"
args = append(args, templateID)
}
rows, err := globalDB.Query(context.Background(), `
SELECT
o.id,
o.template_id,
o.is_npc_order,
COALESCE(ps.character_name, a.class, 'Unknown') AS owner_name,
o.item_price,
COALESCE(i.stack_size, s.initial_stack_size) AS stock,
COALESCE(o.quality_level, 0) AS quality
FROM dune.dune_exchange_orders o
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
LEFT JOIN dune.items i ON i.id = o.item_id
LEFT JOIN dune.actors a ON a.id = o.owner_id
LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id
`+where+`
ORDER BY o.template_id, o.item_price`, args...)
if err != nil {
return msgMarketListings{err: err}
}
defer rows.Close()
var listings []marketListing
for rows.Next() {
var l marketListing
var isNPC bool
if err := rows.Scan(&l.OrderID, &l.TemplateID, &isNPC, &l.OwnerName, &l.Price, &l.Stock, &l.Quality); err != nil {
continue
}
if isNPC {
l.OwnerType = "bot"
l.OwnerName = "Revy"
} else {
l.OwnerType = "player"
}
listings = append(listings, l)
}
if rows.Err() != nil {
return msgMarketListings{err: rows.Err()}
}
return msgMarketListings{rows: listings}
}
// cmdFetchMarketSales returns recent sales from bot listings (players buying from Revy).
func cmdFetchMarketSales() Msg {
if globalDB == nil {
return msgMarketSales{err: fmt.Errorf("not connected")}
}
rows, err := globalDB.Query(context.Background(), `
SELECT
f.order_id,
o.template_id,
o.is_npc_order,
COALESCE(ps.character_name, a.class, 'Unknown') AS seller_name,
o.item_price,
f.stack_size
FROM dune.dune_exchange_fulfilled_orders f
JOIN dune.dune_exchange_orders o ON o.id = f.order_id
LEFT JOIN dune.actors a ON a.id = o.owner_id
LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id
ORDER BY f.order_id DESC
LIMIT 200`)
if err != nil {
return msgMarketSales{err: err}
}
defer rows.Close()
var sales []marketSale
for rows.Next() {
var s marketSale
var isNPC bool
if err := rows.Scan(&s.OrderID, &s.TemplateID, &isNPC, &s.SellerName, &s.Price, &s.Quantity); err != nil {
continue
}
if isNPC {
s.SellerType = "bot"
s.SellerName = "Revy"
} else {
s.SellerType = "player"
}
sales = append(sales, s)
}
if rows.Err() != nil {
return msgMarketSales{err: rows.Err()}
}
return msgMarketSales{rows: sales}
}
// cmdFetchMarketStats returns aggregate market statistics.
func cmdFetchMarketStats() Msg {
if globalDB == nil {
return msgMarketStats{err: fmt.Errorf("not connected")}
}
var stats marketStats
err := globalDB.QueryRow(context.Background(), `
SELECT
COUNT(*) AS total_listings,
COUNT(*) FILTER (WHERE o.is_npc_order) AS bot_listings,
COUNT(*) FILTER (WHERE NOT o.is_npc_order) AS player_listings,
COALESCE(SUM(COALESCE(i.stack_size, s.initial_stack_size)), 0) AS total_stock,
COALESCE(SUM(CASE WHEN o.is_npc_order
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS bot_stock,
COALESCE(SUM(CASE WHEN NOT o.is_npc_order
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS player_stock,
COUNT(DISTINCT o.template_id) AS unique_items
FROM dune.dune_exchange_orders o
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
LEFT JOIN dune.items i ON i.id = o.item_id`).
Scan(&stats.TotalListings, &stats.BotListings, &stats.PlayerListings,
&stats.TotalStock, &stats.BotStock, &stats.PlayerStock, &stats.UniqueItems)
if err != nil {
return msgMarketStats{err: err}
}
return msgMarketStats{stats: stats}
}

View File

@@ -0,0 +1,175 @@
package main
import (
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// dialRecordingExecutor is an Executor whose Dial records the requested address
// and then connects to a fixed target instead. It lets tests prove that HTTP
// traffic is routed through the executor: the requested address is unreachable,
// so a successful response can only have arrived via the redirected dial.
type dialRecordingExecutor struct {
psOut string
target string // real address Dial connects to, regardless of requested addr
dialAddr string // requested address, recorded for assertions
}
func (e *dialRecordingExecutor) Exec(string) (string, error) { return e.psOut, nil }
func (e *dialRecordingExecutor) Stream(string) (<-chan string, func(), error) {
return nil, func() {}, nil
}
func (e *dialRecordingExecutor) PipeToWriter(string, io.Writer) error { return nil }
func (e *dialRecordingExecutor) WriteFile(string, io.Reader) error { return nil }
func (e *dialRecordingExecutor) Dial(network, addr string) (net.Conn, error) {
e.dialAddr = addr
return net.Dial(network, e.target)
}
func (e *dialRecordingExecutor) Close() {}
func (e *dialRecordingExecutor) Type() string { return "ssh" }
// TestHTTPTransportVia_UsesProvidedDialer verifies the transport built by
// httpTransportVia establishes every connection through the supplied dialer,
// not via a direct dial to the request's host.
func TestHTTPTransportVia_UsesProvidedDialer(t *testing.T) {
t.Parallel()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "reached-backend")
}))
defer backend.Close()
var dialedAddr string
transport := httpTransportVia(func(network, addr string) (net.Conn, error) {
dialedAddr = addr
return net.Dial(network, backend.Listener.Addr().String())
})
client := &http.Client{Transport: transport}
// director.invalid is unresolvable: success proves the dialer was used.
resp, err := client.Get("http://director.invalid:11717/v0/battlegroup")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if string(body) != "reached-backend" {
t.Errorf("body = %q, want %q", body, "reached-backend")
}
if dialedAddr != "director.invalid:11717" {
t.Errorf("dialed addr = %q, want %q", dialedAddr, "director.invalid:11717")
}
}
// TestNewDirectorProxy_RoutesThroughDialer verifies the /director/ reverse
// proxy strips the /director prefix and routes the upstream connection through
// the supplied dialer (the executor tunnel).
func TestNewDirectorProxy_RoutesThroughDialer(t *testing.T) {
t.Parallel()
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "path="+r.URL.Path)
}))
defer backend.Close()
target, err := url.Parse("http://director.invalid:11717")
if err != nil {
t.Fatalf("parse target: %v", err)
}
var dialedAddr string
handler := newDirectorProxy(target, func(network, addr string) (net.Conn, error) {
dialedAddr = addr
return net.Dial(network, backend.Listener.Addr().String())
})
req := httptest.NewRequest(http.MethodGet, "/director/v0/battlegroup", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "path=/v0/battlegroup" {
t.Errorf("backend saw %q, want %q (prefix not stripped?)", got, "path=/v0/battlegroup")
}
if dialedAddr != "director.invalid:11717" {
t.Errorf("dialed addr = %q, want %q", dialedAddr, "director.invalid:11717")
}
}
// TestDialThroughExecutor verifies the shared dialer routes through the active
// executor when set and falls back to a direct dial when it is nil.
func TestDialThroughExecutor(t *testing.T) {
// Not parallel: mutates the globalExecutor package global.
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer backend.Close()
addr := backend.Listener.Addr().String()
saved := globalExecutor
defer func() { globalExecutor = saved }()
// nil executor → direct dial succeeds against the listening backend.
globalExecutor = nil
conn, err := dialThroughExecutor("tcp", addr)
if err != nil {
t.Fatalf("direct dial failed: %v", err)
}
_ = conn.Close()
// executor set → unreachable addr still connects, routed through executor.
rec := &dialRecordingExecutor{target: addr}
globalExecutor = rec
conn2, err := dialThroughExecutor("tcp", "director.invalid:11717")
if err != nil {
t.Fatalf("executor dial failed: %v", err)
}
_ = conn2.Close()
if rec.dialAddr != "director.invalid:11717" {
t.Errorf("executor dialed %q, want %q", rec.dialAddr, "director.invalid:11717")
}
}
// TestAmpGetStatus_DirectorDialsThroughExecutor proves director enrichment in
// GetStatus reaches the director through the executor's tunnel: the configured
// director URL is unresolvable, so enrichment can only succeed via the
// executor's redirected dial.
func TestAmpGetStatus_DirectorDialsThroughExecutor(t *testing.T) {
// Not parallel: GetStatus reads the globalDB package global.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v0/battlegroup" {
http.NotFound(w, r)
return
}
_, _ = io.WriteString(w, directorBattlegroupJSON)
}))
defer srv.Close()
exec := &dialRecordingExecutor{
psOut: psLineFor(1001, "Overmap", 7794, 2),
target: srv.Listener.Addr().String(),
}
c := &ampControl{
container: "AMP_X",
useContainer: false,
directorURL: "http://director.invalid:11717",
}
status, err := c.GetStatus(t.Context(), exec)
if err != nil {
t.Fatalf("GetStatus: %v", err)
}
if len(status.Servers) != 1 {
t.Fatalf("got %d servers, want 1", len(status.Servers))
}
row := status.Servers[0]
if row.Partition != 2 || row.Sietch != "Overland" || row.Players != 5 {
t.Errorf("row = %+v, want partition 2 sietch Overland players 5 (director enrichment via executor)", row)
}
if exec.dialAddr != "director.invalid:11717" {
t.Errorf("director dialed %q, want %q (not routed through executor)", exec.dialAddr, "director.invalid:11717")
}
}

View File

@@ -0,0 +1,7 @@
//go:build !embed
package main
import "net/http"
func embeddedSPAFS() http.FileSystem { return nil }

View File

@@ -0,0 +1,21 @@
//go:build embed
package main
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:dist
var embeddedDist embed.FS
func embeddedSPAFS() http.FileSystem {
sub, err := fs.Sub(embeddedDist, "dist")
if err != nil {
log.Fatal("embedded dist is malformed:", err)
}
return http.FS(sub)
}

View File

@@ -0,0 +1,240 @@
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"strings"
"golang.org/x/crypto/ssh"
)
// dialThroughExecutor establishes a TCP connection through the active executor
// (an SSH tunnel when configured), falling back to a direct dial when no
// executor is set. This is the same dial path used for the DB pool and the
// RabbitMQ brokers, letting HTTP clients reach hosts reachable from wherever
// the executor runs (e.g. the AMP box over SSH) rather than the machine
// dune-admin runs on.
func dialThroughExecutor(network, addr string) (net.Conn, error) {
if globalExecutor != nil {
return globalExecutor.Dial(network, addr)
}
return net.Dial(network, addr)
}
// httpTransportVia returns an *http.Transport that establishes every connection
// through dial. It clones http.DefaultTransport so timeouts and connection
// pooling match the stdlib defaults; only the dial path is overridden. Used to
// tunnel director HTTP traffic through the executor.
func httpTransportVia(dial func(network, addr string) (net.Conn, error)) *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
return dial(network, addr)
}
return t
}
// Executor abstracts where commands run and how TCP connections are made.
// localExecutor runs everything on the same machine; sshExecutor tunnels
// through an SSH connection to a remote host.
type Executor interface {
Exec(cmd string) (string, error)
Stream(cmd string) (<-chan string, func(), error)
PipeToWriter(cmd string, w io.Writer) error
WriteFile(path string, data io.Reader) error
Dial(network, addr string) (net.Conn, error)
Close()
// Type returns "local" or "ssh" for status reporting.
Type() string
}
// newExecutor returns an sshExecutor when sshHost is non-empty, otherwise
// a localExecutor. The SSH connection is established immediately; the error
// must be checked before using the executor.
func newExecutor(sshHost, sshUser, sshKeyPath string) (Executor, error) {
if sshHost == "" {
return &localExecutor{}, nil
}
client, err := dialSSH(sshHost, sshUser, sshKeyPath)
if err != nil {
return nil, err
}
return &sshExecutor{client: client}, nil
}
// ── SSH executor ──────────────────────────────────────────────────────────────
type sshExecutor struct {
client *ssh.Client
}
func (e *sshExecutor) Type() string { return "ssh" }
func (e *sshExecutor) Close() {
if e.client != nil {
_ = e.client.Close()
}
}
func (e *sshExecutor) Exec(cmd string) (string, error) {
sess, err := e.client.NewSession()
if err != nil {
return "", err
}
defer func() { _ = sess.Close() }()
out, err := sess.CombinedOutput(cmd)
return strings.TrimSpace(string(out)), err
}
func (e *sshExecutor) Stream(cmd string) (<-chan string, func(), error) {
sess, err := e.client.NewSession()
if err != nil {
return nil, func() {}, err
}
pipe, err := sess.StdoutPipe()
if err != nil {
_ = sess.Close()
return nil, func() {}, err
}
if err := sess.Start(cmd); err != nil {
_ = sess.Close()
return nil, func() {}, err
}
ch := make(chan string, 256)
go func() {
defer close(ch)
sc := bufio.NewScanner(pipe)
for sc.Scan() {
ch <- sc.Text()
}
_ = sess.Wait()
}()
return ch, func() { _ = sess.Close() }, nil
}
func (e *sshExecutor) PipeToWriter(cmd string, w io.Writer) error {
sess, err := e.client.NewSession()
if err != nil {
return err
}
defer func() { _ = sess.Close() }()
sess.Stdout = w
return sess.Run(cmd)
}
func (e *sshExecutor) WriteFile(path string, data io.Reader) error {
sess, err := e.client.NewSession()
if err != nil {
return err
}
defer func() { _ = sess.Close() }()
stdin, err := sess.StdinPipe()
if err != nil {
return err
}
if err := sess.Start(fmt.Sprintf("sudo tee %s > /dev/null", shellQuote(path))); err != nil {
return err
}
if _, err := io.Copy(stdin, data); err != nil {
return err
}
_ = stdin.Close()
return sess.Wait()
}
func (e *sshExecutor) Dial(network, addr string) (net.Conn, error) {
return e.client.Dial(network, addr)
}
// ── Local executor ────────────────────────────────────────────────────────────
type localExecutor struct{}
func (e *localExecutor) Type() string { return "local" }
func (e *localExecutor) Close() {}
func (e *localExecutor) Exec(cmd string) (string, error) {
c := exec.Command("sh", "-c", cmd)
var buf bytes.Buffer
c.Stdout = &buf
c.Stderr = &buf
err := c.Run()
return strings.TrimSpace(buf.String()), err
}
func (e *localExecutor) Stream(cmd string) (<-chan string, func(), error) {
c := exec.Command("sh", "-c", cmd)
pipe, err := c.StdoutPipe()
if err != nil {
return nil, func() {}, err
}
c.Stderr = os.Stderr
if err := c.Start(); err != nil {
return nil, func() {}, err
}
ch := make(chan string, 256)
go func() {
defer close(ch)
sc := bufio.NewScanner(pipe)
for sc.Scan() {
ch <- sc.Text()
}
_ = c.Wait()
}()
cancel := func() {
if c.Process != nil {
_ = c.Process.Kill()
}
}
return ch, cancel, nil
}
func (e *localExecutor) PipeToWriter(cmd string, w io.Writer) error {
c := exec.Command("sh", "-c", cmd) // #nosec G702 -- all callers build cmd via shellQuote
c.Stdout = w
var errBuf bytes.Buffer
c.Stderr = &errBuf
return c.Run()
}
func (e *localExecutor) WriteFile(path string, data io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) // #nosec G304 -- path comes from admin config
if err != nil {
return err
}
defer func() { _ = f.Close() }()
_, err = io.Copy(f, data)
return err
}
func (e *localExecutor) Dial(network, addr string) (net.Conn, error) {
return net.Dial(network, addr)
}
// ── SSH dialer (used by newExecutor and setup wizard) ─────────────────────────
func dialSSH(host, user, keyPath string) (*ssh.Client, error) {
keyData, err := os.ReadFile(keyPath) // #nosec G304 -- keyPath is admin-supplied config
if err != nil {
return nil, fmt.Errorf("read key %s: %w", keyPath, err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse key: %w", err)
}
client, err := ssh.Dial("tcp", host, &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // #nosec G106 -- private admin tool, known host
})
if err != nil {
return nil, fmt.Errorf("SSH dial %s: %w", host, err)
}
return client, nil
}

View File

@@ -0,0 +1,75 @@
package main
import (
"bytes"
"fmt"
"io"
"net"
"os/exec"
"path/filepath"
)
// ampExecutor wraps any Executor with sudo-elevated file writes. The dune-admin
// process typically runs as a non-AMP user (e.g. mehdune) and cannot write
// UserGame.ini directly — that file is owned by the AMP user. WriteFile pipes
// content through `sudo -i -u <ampUser> tee`, which the sudoers grant allows.
//
// All other methods delegate to the inner executor unchanged, so ampExecutor
// works whether the inner executor is a localExecutor or an sshExecutor.
type ampExecutor struct {
Executor // inner: *localExecutor or *sshExecutor
ampUser string // OS user to write files as (default "amp")
}
func (e *ampExecutor) Type() string { return "amp" }
func (e *ampExecutor) WriteFile(path string, data io.Reader) error {
if e.ampUser == "" {
return fmt.Errorf("amp executor requires amp_user to be configured")
}
cleanPath := filepath.Clean(path)
if !filepath.IsAbs(cleanPath) {
return fmt.Errorf("WriteFile path must be absolute: %s", path)
}
path = cleanPath
cmd := fmt.Sprintf("sudo -i -u %s tee %s > /dev/null", shellQuote(e.ampUser), shellQuote(path))
if sshExec, ok := e.Executor.(*sshExecutor); ok {
sess, err := sshExec.client.NewSession()
if err != nil {
return err
}
defer func() { _ = sess.Close() }()
stdin, err := sess.StdinPipe()
if err != nil {
return err
}
var errBuf bytes.Buffer
sess.Stderr = &errBuf
if err := sess.Start(cmd); err != nil {
return err
}
if _, err := io.Copy(stdin, data); err != nil {
return err
}
_ = stdin.Close()
if err := sess.Wait(); err != nil {
return fmt.Errorf("sudo tee %s: %w — %s", path, err, errBuf.String())
}
return nil
}
c := exec.Command("sudo", "-i", "-u", e.ampUser, "tee", path) // #nosec G204,G702 -- args passed as slice (no shell); ampUser and path are admin-supplied config
c.Stdin = data
c.Stdout = io.Discard
var errBuf bytes.Buffer
c.Stderr = &errBuf
err := c.Run()
if err != nil {
return fmt.Errorf("sudo tee %s: %w — %s", path, err, errBuf.String())
}
return nil
}
// Dial delegates to the inner executor so SSH-tunnelled TCP connections work.
func (e *ampExecutor) Dial(network, addr string) (net.Conn, error) {
return e.Executor.Dial(network, addr)
}

View File

@@ -0,0 +1,58 @@
// Code generated by dune-item-data/build-fillables-gen.sh. DO NOT EDIT.
package main
// waterFillableTemplates is the set of lowercase item template IDs for water-only
// fillable containers. Used by cmdRefillWaterOffline to target the correct items.
// Source: DT_ItemTableFillables.json (FillableTypeRestriction = Water).
var waterFillableTemplates = []string{
"advancedstillsuit",
"combat_nati_fremenexile04_top",
"decajon",
"dewpack",
"highcapacityliterjon",
"highcapacityliterjon_02",
"highcapacityliterjon_03",
"highcapacityliterjon_04",
"highcapacityliterjon_05",
"highcapacityliterjon_06",
"literjon",
"literjon_03",
"literjon_04",
"literjon_05",
"literjon_06",
"literjon_07",
"literjon_08",
"literjon_09",
"literjon_t6",
"simplestillsuit",
"stillsuit_choam_01_top",
"stillsuit_choam_02_top",
"stillsuit_choam_04_top",
"stillsuit_choam_05_top",
"stillsuit_choam_06_top",
"stillsuit_choam_unique_dashed02_top",
"stillsuit_choam_unique_dashed03_top",
"stillsuit_choam_unique_dashed04_top",
"stillsuit_choam_unique_dashed05_top",
"stillsuit_choam_unique_dashed06_top",
"stillsuit_nati_05_body",
"stillsuit_nati_06_body",
"stillsuit_nati_07_body",
"stillsuit_nati_08_body",
"stillsuit_nati_arrakeen05_body",
"stillsuit_neut_leaking01_top",
"stillsuit_neut_patchy02_top",
"stillsuit_unique_armored_01_top",
"stillsuit_unique_armored_02_top",
"stillsuit_unique_armored_03_top",
"stillsuit_unique_armored_04_top",
"stillsuit_unique_armored_05_top",
"stillsuit_unique_armored_06_top",
"stillsuit_unique_efficient_04_top",
"stillsuit_unique_efficient_05_top",
"stillsuit_unique_efficient_06_top",
"stillsuit_unique_highcapacity_06_top",
"stillsuit_unique_thermalsuit_06_top",
"waterpack_consumable",
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,103 @@
package main
import (
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite" // pure-Go sqlite driver (registers "sqlite")
)
// givePacksStore persists the operator-configurable give-items pack library in
// a local SQLite database. Kept in our own file so we never touch Funcom's
// dune schema. Mirrors welcomeStore / locationStore in structure and intent.
type givePacksStore struct {
db *sql.DB
}
const givePacksStoreSchema = `
CREATE TABLE IF NOT EXISTS give_packs_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
base_packs_loaded INTEGER NOT NULL DEFAULT 0,
packs_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);`
// initGivePacksSchema creates the give_packs_config table on db. Safe to call
// against a shared handle (the unified store). Idempotent.
func initGivePacksSchema(db *sql.DB) error {
if _, err := db.Exec(givePacksStoreSchema); err != nil {
return fmt.Errorf("init give-packs schema: %w", err)
}
return nil
}
// newGivePacksStore wraps an already-initialised shared handle (schema created
// by openUnifiedStore). Used so all stores share one SQLite file in production.
func newGivePacksStore(db *sql.DB) *givePacksStore {
return &givePacksStore{db: db}
}
// openGivePacksStore opens (or creates) the give-packs database at path and
// ensures the schema exists. path may be ":memory:" for tests.
func openGivePacksStore(path string) (*givePacksStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open give-packs store: %w", err)
}
if err := initGivePacksSchema(db); err != nil {
_ = db.Close()
return nil, err
}
return &givePacksStore{db: db}, nil
}
func (s *givePacksStore) close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// saveConfig upserts the single give_packs_config row (id=1).
// packsJSON must be a valid JSON array (never nil — use "[]" for empty).
// basePacksLoaded=true means the default seed has been applied; subsequent
// startups will skip re-seeding even when packsJSON is "[]" (user deleted all).
func (s *givePacksStore) saveConfig(packsJSON string, basePacksLoaded bool) error {
loaded := 0
if basePacksLoaded {
loaded = 1
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(`
INSERT INTO give_packs_config (id, base_packs_loaded, packs_json, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
base_packs_loaded = excluded.base_packs_loaded,
packs_json = excluded.packs_json,
updated_at = excluded.updated_at`,
loaded, packsJSON, now)
if err != nil {
return fmt.Errorf("save give-packs config: %w", err)
}
return nil
}
// loadConfig reads the single give_packs_config row.
// Returns (basePacksLoaded, packsJSON, ok, err).
// ok=false when the table is empty (first boot); in that case the caller
// should seed from the embedded default.
func (s *givePacksStore) loadConfig() (basePacksLoaded bool, packsJSON string, ok bool, err error) {
var loadedInt int
scanErr := s.db.QueryRow(`
SELECT base_packs_loaded, packs_json FROM give_packs_config WHERE id = 1`).
Scan(&loadedInt, &packsJSON)
if errors.Is(scanErr, sql.ErrNoRows) {
return false, "", false, nil
}
if scanErr != nil {
return false, "", false, fmt.Errorf("load give-packs config: %w", scanErr)
}
return loadedInt != 0, packsJSON, true, nil
}

View File

@@ -0,0 +1,130 @@
package main
import (
"testing"
)
// openMemGivePacksStore opens an in-memory give-packs store for testing.
func openMemGivePacksStore(t *testing.T) *givePacksStore {
t.Helper()
s, err := openGivePacksStore(":memory:")
if err != nil {
t.Fatalf("openGivePacksStore: %v", err)
}
t.Cleanup(func() { _ = s.close() })
return s
}
func TestGivePacksStore_LoadMissingReturnsNotOK(t *testing.T) {
t.Parallel()
s := openMemGivePacksStore(t)
loaded, packsJSON, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Fatal("expected ok=false on empty store, got true")
}
if loaded {
t.Error("expected base_packs_loaded=false on empty store")
}
if packsJSON != "" {
t.Errorf("expected empty packsJSON, got %q", packsJSON)
}
}
func TestGivePacksStore_SaveAndLoad(t *testing.T) {
t.Parallel()
s := openMemGivePacksStore(t)
const testJSON = `[{"id":"starter-t1","name":"T1","category":"Starter","tier":1,"items":[]}]`
if err := s.saveConfig(testJSON, true); err != nil {
t.Fatalf("saveConfig: %v", err)
}
loaded, packsJSON, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !ok {
t.Fatal("expected ok=true after save")
}
if !loaded {
t.Error("expected base_packs_loaded=true after saveConfig(..., true)")
}
if packsJSON != testJSON {
t.Errorf("packsJSON mismatch:\nwant: %s\ngot: %s", testJSON, packsJSON)
}
}
func TestGivePacksStore_SavedUnloaded(t *testing.T) {
t.Parallel()
s := openMemGivePacksStore(t)
if err := s.saveConfig(`[]`, false); err != nil {
t.Fatalf("saveConfig: %v", err)
}
loaded, _, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
if loaded {
t.Error("expected base_packs_loaded=false when saved with false")
}
}
func TestGivePacksStore_OverwriteWithSave(t *testing.T) {
t.Parallel()
s := openMemGivePacksStore(t)
if err := s.saveConfig(`[{"id":"a"}]`, false); err != nil {
t.Fatalf("first save: %v", err)
}
const second = `[{"id":"b"},{"id":"c"}]`
if err := s.saveConfig(second, true); err != nil {
t.Fatalf("second save: %v", err)
}
loaded, packsJSON, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
if !loaded {
t.Error("expected base_packs_loaded=true after second save")
}
if packsJSON != second {
t.Errorf("expected second packs JSON, got %q", packsJSON)
}
}
func TestGivePacksStore_EmptyPacksRoundTrip(t *testing.T) {
t.Parallel()
s := openMemGivePacksStore(t)
// Saving empty packs with loaded=true (user deleted all) must not re-seed.
if err := s.saveConfig(`[]`, true); err != nil {
t.Fatalf("saveConfig: %v", err)
}
loaded, packsJSON, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !ok {
t.Fatal("expected ok=true")
}
if !loaded {
t.Error("base_packs_loaded should remain true even when packs are empty")
}
if packsJSON != `[]` {
t.Errorf("expected empty array, got %q", packsJSON)
}
}

View File

@@ -0,0 +1,185 @@
package main
import (
"testing"
)
// setupGivePacksStore wires a fresh in-memory store into givePacksStoreDB and
// restores nil on cleanup. NOT parallel — mutates a package global.
func setupGivePacksStore(t *testing.T) *givePacksStore {
t.Helper()
s := openMemGivePacksStore(t)
givePacksStoreDB = s
t.Cleanup(func() { givePacksStoreDB = nil })
return s
}
// ── validateGivePacks ────────────────────────────────────────────────────────
func TestValidateGivePacks_Valid(t *testing.T) {
t.Parallel()
packs := []givePack{
{ID: "starter-t1", Name: "T1 Starter", Category: "Starter", Tier: 1, Items: []welcomePackageItem{
{Template: "Ammo", Qty: 100, Quality: 0},
}},
{ID: "buggy-t6", Name: "Buggy T6", Category: "Buggy", Tier: 6, Items: []welcomePackageItem{
{Template: "BuggyBoost_6", Qty: 1, Quality: 0},
}},
}
if err := validateGivePacks(packs); err != nil {
t.Fatalf("unexpected error for valid packs: %v", err)
}
}
func TestValidateGivePacks_EmptySliceIsValid(t *testing.T) {
t.Parallel()
// An empty pack list is valid — operator deleted everything intentionally.
if err := validateGivePacks([]givePack{}); err != nil {
t.Fatalf("empty packs should be valid: %v", err)
}
}
func TestValidateGivePacks_EmptyID(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "", Name: "No ID", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for empty id")
}
}
func TestValidateGivePacks_DuplicateID(t *testing.T) {
t.Parallel()
packs := []givePack{
{ID: "dup", Name: "A", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}},
{ID: "dup", Name: "B", Category: "Y", Tier: 2, Items: []welcomePackageItem{{Template: "B", Qty: 1, Quality: 0}}},
}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for duplicate id")
}
}
func TestValidateGivePacks_EmptyName(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for empty name")
}
}
func TestValidateGivePacks_EmptyCategory(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for empty category")
}
}
func TestValidateGivePacks_ZeroQtyItem(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 0, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for item with qty=0")
}
}
func TestValidateGivePacks_NegativeQtyItem(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: -1, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for item with qty=-1")
}
}
func TestValidateGivePacks_NegativeQualityItem(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: -1}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for item with quality=-1")
}
}
func TestValidateGivePacks_EmptyTemplateItem(t *testing.T) {
t.Parallel()
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "", Qty: 1, Quality: 0}}}}
if err := validateGivePacks(packs); err == nil {
t.Fatal("expected error for empty template")
}
}
func TestValidateGivePacks_EmptyItemsIsValid(t *testing.T) {
t.Parallel()
// A pack with zero items is valid — operator may be building it.
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{}}}
if err := validateGivePacks(packs); err != nil {
t.Fatalf("pack with empty items should be valid: %v", err)
}
}
// ── parseDefaultPacks ────────────────────────────────────────────────────────
func TestParseDefaultPacks_NonEmpty(t *testing.T) {
t.Parallel()
packs, err := parseDefaultPacks()
if err != nil {
t.Fatalf("parseDefaultPacks: %v", err)
}
if len(packs) == 0 {
t.Fatal("expected at least one default pack from embedded JSON")
}
// Spot-check shape: every pack must have ID, Name, Category.
for _, p := range packs {
if p.ID == "" {
t.Error("pack missing ID")
}
if p.Name == "" {
t.Errorf("pack %q missing name", p.ID)
}
if p.Category == "" {
t.Errorf("pack %q missing category", p.ID)
}
}
}
func TestParseDefaultPacks_ValidShape(t *testing.T) {
t.Parallel()
packs, err := parseDefaultPacks()
if err != nil {
t.Fatalf("parseDefaultPacks: %v", err)
}
if err := validateGivePacks(packs); err != nil {
t.Fatalf("default packs fail validation: %v", err)
}
}
// ── seedGivePacks ────────────────────────────────────────────────────────────
func TestSeedGivePacks_SetsBasePacksLoaded(t *testing.T) {
s := setupGivePacksStore(t)
if err := seedGivePacks(); err != nil {
t.Fatalf("seedGivePacks: %v", err)
}
loaded, packsJSON, ok, err := s.loadConfig()
if err != nil {
t.Fatalf("loadConfig after seed: %v", err)
}
if !ok {
t.Fatal("expected config row after seed")
}
if !loaded {
t.Error("expected base_packs_loaded=true after seedGivePacks")
}
if packsJSON == "" || packsJSON == "null" || packsJSON == "[]" {
t.Error("expected non-empty packs JSON after seed")
}
}
func TestSeedGivePacks_NilStore(t *testing.T) {
// When the store is nil, seedGivePacks should return an error gracefully.
givePacksStoreDB = nil
err := seedGivePacks()
if err == nil {
t.Fatal("expected error when store is nil")
}
}

View File

@@ -0,0 +1,317 @@
package main
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"strings"
)
func quatToYaw(qx, qy, qz, qw float64) float64 {
return math.Atan2(2*(qw*qz+qx*qy), 1-2*(qy*qy+qz*qz)) * 180 / math.Pi
}
func quatToEuler(qx, qy, qz, qw float64) (rx, ry, rz float64) {
rx = math.Atan2(2*(qw*qx+qy*qz), 1-2*(qx*qx+qy*qy)) * 180 / math.Pi
sinp := 2 * (qw*qy - qz*qx)
if sinp >= 1 {
ry = 90
} else if sinp <= -1 {
ry = -90
} else {
ry = math.Asin(sinp) * 180 / math.Pi
}
rz = math.Atan2(2*(qw*qz+qx*qy), 1-2*(qy*qy+qz*qz)) * 180 / math.Pi
return
}
func parseVec3(s string) (x, y, z float64, err error) {
s = strings.Trim(strings.TrimSpace(s), "()")
parts := strings.SplitN(s, ",", 3)
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("expected 3 components in %q", s)
}
if x, err = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err != nil {
return
}
if y, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err != nil {
return
}
z, err = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
return
}
func parseVec4(s string) (x, y, z, w float64, err error) {
s = strings.Trim(strings.TrimSpace(s), "()")
parts := strings.SplitN(s, ",", 4)
if len(parts) != 4 {
return 0, 0, 0, 0, fmt.Errorf("expected 4 components in %q", s)
}
if x, err = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err != nil {
return
}
if y, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err != nil {
return
}
if z, err = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64); err != nil {
return
}
w, err = strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
return
}
// @Summary List all player bases
// @Tags bases
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/bases [get]
func handleListBases(w http.ResponseWriter, _ *http.Request) {
msg, ok := cmdListBases().(msgBaseList)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
rows := msg.rows
if rows == nil {
rows = []baseRow{}
}
jsonOK(w, rows)
}
type rawBaseInstance struct {
buildingType string
transform []float32
ownerEntityID int64
}
type rawBasePlaceable struct {
buildingType string
location string
rotation string
properties map[string]any
}
func parseBasePathID(id string) (int64, error) {
parsedID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid id")
}
return parsedID, nil
}
func queryBaseExportInstances(ctx context.Context, id int64) ([]rawBaseInstance, error) {
rows, err := globalDB.Query(ctx, `
SELECT building_type, transform, owner_entity_id
FROM dune.building_instances
WHERE building_id = $1`, id)
if err != nil {
return nil, fmt.Errorf("query instances: %w", err)
}
defer rows.Close()
raws := make([]rawBaseInstance, 0, 32)
for rows.Next() {
var ri rawBaseInstance
if err := rows.Scan(&ri.buildingType, &ri.transform, &ri.ownerEntityID); err != nil {
continue
}
if len(ri.transform) < 7 {
continue
}
raws = append(raws, ri)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read instances: %w", err)
}
return raws, nil
}
func queryBaseExportPlaceables(ctx context.Context, ownerEntityID int64) ([]rawBasePlaceable, error) {
rows, err := globalDB.Query(ctx, `
SELECT p.building_type,
(a.transform).location::text,
(a.transform).rotation::text,
a.properties
FROM dune.placeables p
JOIN dune.actors a ON a.id = p.id
WHERE p.owner_entity_id = $1`, ownerEntityID)
if err != nil {
return nil, fmt.Errorf("query placeables: %w", err)
}
defer rows.Close()
raws := make([]rawBasePlaceable, 0, 32)
for rows.Next() {
var rp rawBasePlaceable
if err := rows.Scan(&rp.buildingType, &rp.location, &rp.rotation, &rp.properties); err != nil {
continue
}
raws = append(raws, rp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read placeables: %w", err)
}
return raws, nil
}
func calculateBaseCentroid(raws []rawBaseInstance) (float64, float64, float64) {
var sumX, sumY, sumZ float64
for _, ri := range raws {
sumX += float64(ri.transform[0])
sumY += float64(ri.transform[1])
sumZ += float64(ri.transform[2])
}
n := float64(len(raws))
return sumX / n, sumY / n, sumZ / n
}
func buildBlueprintInstances(raws []rawBaseInstance, cx, cy, cz float64) []blueprintInstance {
instances := make([]blueprintInstance, 0, len(raws))
for _, ri := range raws {
qx, qy, qz, qw := float64(ri.transform[3]), float64(ri.transform[4]), float64(ri.transform[5]), float64(ri.transform[6])
instances = append(instances, blueprintInstance{
BuildingType: ri.buildingType,
X: float64(ri.transform[0]) - cx,
Y: float64(ri.transform[1]) - cy,
Z: float64(ri.transform[2]) - cz,
Rotation: quatToYaw(qx, qy, qz, qw),
})
}
return instances
}
func extractPentashieldScale(buildingType string, props map[string]any) ([3]int, bool) {
var scale [3]int
if props == nil {
return scale, false
}
inner, ok := props[strings.TrimSuffix(buildingType, "_Placeable")+"_C"].(map[string]any)
if !ok {
return scale, false
}
scaleValues, ok := inner["m_Scale"].([]any)
if !ok || len(scaleValues) < 3 {
return scale, false
}
for i := 0; i < 3; i++ {
value, ok := scaleValues[i].(float64)
if !ok {
return scale, false
}
scale[i] = int(value)
}
return scale, true
}
func convertExportPlaceable(raw rawBasePlaceable, cx, cy, cz float64, placeableID int) (blueprintPlaceable, *blueprintPentashield, bool) {
if raw.buildingType == "Totem_Placeable" {
return blueprintPlaceable{}, nil, false
}
lx, ly, lz, locErr := parseVec3(raw.location)
qx, qy, qz, qw, rotErr := parseVec4(raw.rotation)
if locErr != nil || rotErr != nil {
return blueprintPlaceable{}, nil, false
}
rx, ry, rz := quatToEuler(qx, qy, qz, qw)
placeable := blueprintPlaceable{
BuildingType: raw.buildingType,
X: lx - cx,
Y: ly - cy,
Z: lz - cz,
RX: rx,
RY: ry,
RZ: rz,
}
if !strings.Contains(raw.buildingType, "PentashieldSurface") {
return placeable, nil, true
}
scale, ok := extractPentashieldScale(raw.buildingType, raw.properties)
if !ok {
return blueprintPlaceable{}, nil, false
}
return placeable, &blueprintPentashield{PlaceableID: placeableID, Scale: scale}, true
}
func buildBlueprintPlaceables(raws []rawBasePlaceable, cx, cy, cz float64) ([]blueprintPlaceable, []blueprintPentashield) {
placeables := make([]blueprintPlaceable, 0, len(raws))
pentashields := make([]blueprintPentashield, 0, len(raws))
for _, raw := range raws {
nextID := len(placeables)
placeable, pentashield, ok := convertExportPlaceable(raw, cx, cy, cz, nextID)
if !ok {
continue
}
placeables = append(placeables, placeable)
if pentashield != nil {
pentashields = append(pentashields, *pentashield)
}
}
return placeables, pentashields
}
func writeExportBaseResponse(
w http.ResponseWriter,
id int64,
instances []blueprintInstance,
placeables []blueprintPlaceable,
pentashields []blueprintPentashield,
) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="base_%d.json"`, id))
jsonOK(w, blueprintFile{
Instances: instances,
Placeables: placeables,
Pentashields: pentashields,
})
}
// @Summary Export a base as a downloadable blueprint JSON file
// @Tags bases
// @Produce application/octet-stream
// @Param id path int true "Base (building) ID"
// @Success 200 {file} string "Base blueprint JSON file"
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/bases/{id}/export [get]
func handleExportBase(w http.ResponseWriter, r *http.Request) {
id, err := parseBasePathID(r.PathValue("id"))
if err != nil {
jsonErr(w, err, 400)
return
}
if globalDB == nil {
jsonErr(w, fmt.Errorf("not connected"), 500)
return
}
ctx := context.Background()
rawInstances, err := queryBaseExportInstances(ctx, id)
if err != nil {
jsonErr(w, err, 500)
return
}
if len(rawInstances) == 0 {
jsonErr(w, fmt.Errorf("building %d not found or empty", id), 404)
return
}
cx, cy, cz := calculateBaseCentroid(rawInstances)
instances := buildBlueprintInstances(rawInstances, cx, cy, cz)
rawPlaceables, err := queryBaseExportPlaceables(ctx, rawInstances[0].ownerEntityID)
if err != nil {
jsonErr(w, err, 500)
return
}
placeables, pentashields := buildBlueprintPlaceables(rawPlaceables, cx, cy, cz)
writeExportBaseResponse(w, id, instances, placeables, pentashields)
}

View File

@@ -0,0 +1,233 @@
package main
import (
"math"
"testing"
)
func nearlyEqual(a, b, epsilon float64) bool {
return math.Abs(a-b) <= epsilon
}
func TestParseBasePathID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantID int64
wantError string
}{
{name: "valid", input: "123", wantID: 123},
{name: "invalid", input: "abc", wantError: "invalid id"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseBasePathID(tt.input)
if tt.wantError != "" {
if err == nil || err.Error() != tt.wantError {
t.Fatalf("expected error %q, got %v", tt.wantError, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.wantID {
t.Fatalf("expected ID %d, got %d", tt.wantID, got)
}
})
}
}
func TestCalculateBaseCentroid(t *testing.T) {
t.Parallel()
raws := []rawBaseInstance{
{transform: []float32{10, 20, 30, 0, 0, 0, 1}},
{transform: []float32{14, 24, 34, 0, 0, 0, 1}},
}
cx, cy, cz := calculateBaseCentroid(raws)
if cx != 12 || cy != 22 || cz != 32 {
t.Fatalf("unexpected centroid: (%v, %v, %v)", cx, cy, cz)
}
}
func TestBuildBlueprintInstances(t *testing.T) {
t.Parallel()
raws := []rawBaseInstance{
{
buildingType: "A",
transform: []float32{10, 20, 30, 0, 0, 0, 1},
},
{
buildingType: "B",
transform: []float32{14, 24, 34, 0, 0, 0.70710677, 0.70710677},
},
}
cx, cy, cz := calculateBaseCentroid(raws)
got := buildBlueprintInstances(raws, cx, cy, cz)
if len(got) != 2 {
t.Fatalf("expected 2 instances, got %d", len(got))
}
if got[0].BuildingType != "A" || got[1].BuildingType != "B" {
t.Fatalf("unexpected building types: %#v", got)
}
if got[0].X != -2 || got[0].Y != -2 || got[0].Z != -2 {
t.Fatalf("unexpected first offsets: %+v", got[0])
}
if !nearlyEqual(got[0].Rotation, 0, 0.001) {
t.Fatalf("unexpected first rotation: %v", got[0].Rotation)
}
if !nearlyEqual(got[1].Rotation, 90, 0.01) {
t.Fatalf("unexpected second rotation: %v", got[1].Rotation)
}
}
func TestConvertExportPlaceable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw rawBasePlaceable
cx float64
cy float64
cz float64
placeableID int
wantInclude bool
wantHasPenta bool
wantPlaceableX float64
}{
{
name: "skip totem",
raw: rawBasePlaceable{buildingType: "Totem_Placeable"},
wantInclude: false,
},
{
name: "skip invalid transform",
raw: rawBasePlaceable{
buildingType: "SomeType_Placeable",
location: "invalid",
rotation: "(0,0,0,1)",
},
wantInclude: false,
},
{
name: "normal placeable",
raw: rawBasePlaceable{
buildingType: "SomeType_Placeable",
location: "(10,20,30)",
rotation: "(0,0,0,1)",
},
cx: 1,
cy: 2,
cz: 3,
wantInclude: true,
wantHasPenta: false,
wantPlaceableX: 9,
},
{
name: "pentashield with scale",
raw: rawBasePlaceable{
buildingType: "MyPentashieldSurface_Placeable",
location: "(10,20,30)",
rotation: "(0,0,0,1)",
properties: map[string]any{
"MyPentashieldSurface_C": map[string]any{
"m_Scale": []any{3.0, 4.0, 5.0},
},
},
},
cx: 1,
cy: 2,
cz: 3,
placeableID: 7,
wantInclude: true,
wantHasPenta: true,
wantPlaceableX: 9,
},
{
name: "pentashield without scale skipped",
raw: rawBasePlaceable{
buildingType: "MyPentashieldSurface_Placeable",
location: "(10,20,30)",
rotation: "(0,0,0,1)",
properties: map[string]any{
"MyPentashieldSurface_C": map[string]any{},
},
},
wantInclude: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
placeable, pentashield, include := convertExportPlaceable(tt.raw, tt.cx, tt.cy, tt.cz, tt.placeableID)
if include != tt.wantInclude {
t.Fatalf("expected include=%v, got %v", tt.wantInclude, include)
}
if !include {
return
}
if placeable.X != tt.wantPlaceableX {
t.Fatalf("unexpected placeable X: %v", placeable.X)
}
if (pentashield != nil) != tt.wantHasPenta {
t.Fatalf("expected pentashield=%v, got %v", tt.wantHasPenta, pentashield != nil)
}
if pentashield != nil {
if pentashield.PlaceableID != tt.placeableID {
t.Fatalf("expected pentashield placeable id %d, got %d", tt.placeableID, pentashield.PlaceableID)
}
if pentashield.Scale != [3]int{3, 4, 5} {
t.Fatalf("unexpected pentashield scale: %#v", pentashield.Scale)
}
}
})
}
}
func TestBuildBlueprintPlaceables_AssignsPentashieldIndex(t *testing.T) {
t.Parallel()
raws := []rawBasePlaceable{
{buildingType: "Totem_Placeable"},
{
buildingType: "Normal_Placeable",
location: "(1,1,1)",
rotation: "(0,0,0,1)",
},
{
buildingType: "ShieldPentashieldSurface_Placeable",
location: "(2,2,2)",
rotation: "(0,0,0,1)",
properties: map[string]any{
"ShieldPentashieldSurface_C": map[string]any{
"m_Scale": []any{1.0, 2.0, 3.0},
},
},
},
}
placeables, pentashields := buildBlueprintPlaceables(raws, 0, 0, 0)
if len(placeables) != 2 {
t.Fatalf("expected 2 placeables, got %d", len(placeables))
}
if len(pentashields) != 1 {
t.Fatalf("expected 1 pentashield, got %d", len(pentashields))
}
if pentashields[0].PlaceableID != 1 {
t.Fatalf("expected pentashield PlaceableID=1, got %d", pentashields[0].PlaceableID)
}
}

View File

@@ -0,0 +1,603 @@
package main
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
)
type backupFile struct {
Name string `json:"name"`
SizeB int64 `json:"size_bytes"`
Modified string `json:"modified"`
HasYAML bool `json:"has_yaml"`
}
var bgCmdAllowlist = map[string]bool{
"start": true, "stop": true, "restart": true,
"update": true, "backup": true,
// restore handled separately via handleBGRestore
}
// @Summary Get battlegroup and server status from the control plane
// @Tags battlegroup
// @Produce json
// @Success 200 {object} map[string]any
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/status [get]
func handleBGStatus(w http.ResponseWriter, r *http.Request) {
if globalControl == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
status, err := globalControl.GetStatus(r.Context(), globalExecutor)
if err != nil {
jsonErr(w, err, 500)
return
}
jsonOK(w, map[string]any{"battlegroup": map[string]string{
"name": status.Name,
"title": status.Title,
"phase": status.Phase,
"database": status.Database,
}, "servers": status.Servers})
}
func safeIdx(s []string, i int) string {
if i < len(s) {
return s[i]
}
return ""
}
// @Summary Execute a battlegroup lifecycle command via the control plane
// @Tags battlegroup
// @Accept json
// @Produce json
// @Param body body object true "Command: start, stop, restart, update, or backup"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/exec [post]
func handleBGExec(w http.ResponseWriter, r *http.Request) {
var req struct {
Cmd string `json:"cmd"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, 400)
return
}
if !bgCmdAllowlist[req.Cmd] {
jsonErr(w, fmt.Errorf("unknown command %q", req.Cmd), 400)
return
}
if globalControl == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
out, err := globalControl.ExecCommand(r.Context(), globalExecutor, req.Cmd)
if err != nil {
jsonErr(w, fmt.Errorf("exec: %w — output: %s", err, out), 500)
return
}
jsonOK(w, map[string]string{"output": out})
}
// @Summary List battlegroup pods/processes and their namespace
// @Tags battlegroup
// @Produce json
// @Success 200 {object} map[string]any
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/pods [get]
func handleBGPods(w http.ResponseWriter, r *http.Request) {
if globalControl == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
procs, ns, err := globalControl.ListProcesses(r.Context(), globalExecutor)
if err != nil {
jsonErr(w, err, 500)
return
}
// Return raw lines for backward compat with the frontend which renders them as-is.
var lines []string
for _, p := range procs {
lines = append(lines, p.Name)
}
jsonOK(w, map[string]any{"pods": lines, "namespace": ns})
}
func activeBackupDir() (string, error) {
if backupDir != "" {
return backupDir, nil
}
if loadedConfig.BackupDir != "" {
return loadedConfig.BackupDir, nil
}
ns := firstNonEmpty(controlNS, loadedConfig.ControlNamespace, globalPodNS)
bg := strings.TrimPrefix(ns, "funcom-seabass-")
if globalControl != nil && globalControl.Name() == "local" && ns != "" && globalExecutor != nil {
pod, err := discoverK8sBackupPod(ns)
if err == nil && pod != "" && bg != "" {
return fmt.Sprintf("k8s://%s/%s/home/dune/artifacts/database-dumps/%s", ns, pod, bg), nil
}
}
if bg != "" {
// Legacy kubectl/host default.
return fmt.Sprintf("/funcom/artifacts/database-dumps/%s", bg), nil
}
return "", fmt.Errorf("backup_dir not configured and no battlegroup namespace discovered")
}
func parseK8sBackupDir(dir string) (ns, pod, inPodDir string, ok bool) {
const prefix = "k8s://"
if !strings.HasPrefix(dir, prefix) {
return "", "", "", false
}
rest := strings.TrimPrefix(dir, prefix)
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return "", "", "", false
}
ns, pod, inPodDir = parts[0], parts[1], "/"+strings.TrimLeft(parts[2], "/")
return ns, pod, inPodDir, true
}
func discoverK8sBackupPod(ns string) (string, error) {
if globalExecutor == nil {
return "", fmt.Errorf("not connected")
}
kctl := kubectlCLI(globalExecutor)
out, err := globalExecutor.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- '-sg-' | head -1",
kctl, shellQuote(ns),
))
if err == nil && strings.TrimSpace(out) != "" {
return strings.TrimSpace(out), nil
}
out, err = globalExecutor.Exec(fmt.Sprintf(
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
kctl, shellQuote(ns),
))
if err == nil && strings.TrimSpace(out) != "" {
return strings.TrimSpace(out), nil
}
return "", fmt.Errorf("could not discover backup pod in namespace %s", ns)
}
func ensureBackupDir(dir string) error {
if globalExecutor == nil {
return fmt.Errorf("not connected")
}
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
kctl := kubectlCLI(globalExecutor)
out, err := globalExecutor.Exec(fmt.Sprintf(
"%s exec -n %s %s -- mkdir -p %s 2>&1",
kctl, shellQuote(ns), shellQuote(pod), shellQuote(inPodDir),
))
if err != nil {
return fmt.Errorf("ensure k8s backup dir: %w (%s)", err, strings.TrimSpace(out))
}
return nil
}
out, err := globalExecutor.Exec(fmt.Sprintf(
"mkdir -p %s 2>/dev/null || sudo mkdir -p %s 2>&1",
shellQuote(dir), shellQuote(dir),
))
if err != nil {
return fmt.Errorf("ensure backup dir: %w (%s)", err, strings.TrimSpace(out))
}
return nil
}
func listBackupDir(dir string) (string, string, error) {
if globalExecutor == nil {
return "", "", fmt.Errorf("not connected")
}
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
kctl := kubectlCLI(globalExecutor)
listCmd := fmt.Sprintf(`ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`, inPodDir)
out, err := globalExecutor.Exec(fmt.Sprintf(
"%s exec -n %s %s -- sh -lc %s 2>&1",
kctl, shellQuote(ns), shellQuote(pod), shellQuote(listCmd),
))
if err != nil {
return "", "", fmt.Errorf("list backups: %w (%s)", err, strings.TrimSpace(out))
}
yamlCmd := fmt.Sprintf(`ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`, inPodDir)
yamlOut, err := globalExecutor.Exec(fmt.Sprintf(
"%s exec -n %s %s -- sh -lc %s 2>&1",
kctl, shellQuote(ns), shellQuote(pod), shellQuote(yamlCmd),
))
if err != nil {
return "", "", fmt.Errorf("list backup metadata: %w (%s)", err, strings.TrimSpace(yamlOut))
}
return out, yamlOut, nil
}
out, err := globalExecutor.Exec(fmt.Sprintf(
`ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`,
dir))
if err != nil {
out, err = globalExecutor.Exec(fmt.Sprintf(
`sudo ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`,
dir))
if err != nil {
return "", "", fmt.Errorf("list backups: %w (%s)", err, strings.TrimSpace(out))
}
}
yamlOut, err := globalExecutor.Exec(fmt.Sprintf(
`ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`,
dir))
if err != nil {
yamlOut, err = globalExecutor.Exec(fmt.Sprintf(
`sudo ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`,
dir))
if err != nil {
return "", "", fmt.Errorf("list backup metadata: %w (%s)", err, strings.TrimSpace(yamlOut))
}
}
return out, yamlOut, nil
}
func backupFileExists(dir, name string) bool {
if globalExecutor == nil {
return false
}
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
kctl := kubectlCLI(globalExecutor)
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
out, _ := globalExecutor.Exec(fmt.Sprintf(
"%s exec -n %s %s -- sh -lc %s 2>/dev/null",
kctl, shellQuote(ns), shellQuote(pod),
shellQuote(fmt.Sprintf("test -f %s && echo yes || echo no", shellQuote(remotePath))),
))
return strings.TrimSpace(out) == "yes"
}
path := strings.TrimRight(dir, "/") + "/" + name
out, _ := globalExecutor.Exec(fmt.Sprintf("test -f %s && echo yes || echo no", shellQuote(path)))
if strings.TrimSpace(out) == "yes" {
return true
}
out, _ = globalExecutor.Exec(fmt.Sprintf("sudo test -f %s && echo yes || echo no", shellQuote(path)))
return strings.TrimSpace(out) == "yes"
}
func backupReadCmd(dir, name string) string {
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
kctl := kubectlCLI(globalExecutor)
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
return fmt.Sprintf("%s exec -n %s %s -- cat %s", kctl, shellQuote(ns), shellQuote(pod), shellQuote(remotePath))
}
path := strings.TrimRight(dir, "/") + "/" + name
return fmt.Sprintf("cat %s 2>/dev/null || sudo cat %s", shellQuote(path), shellQuote(path))
}
func writeBackupFile(dir, name string, src io.Reader) error {
if globalExecutor == nil {
return fmt.Errorf("not connected")
}
if err := ensureBackupDir(dir); err != nil {
return err
}
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
tmp := fmt.Sprintf("/tmp/dune-admin-backup-%d.tmp", time.Now().UnixNano())
if err := globalExecutor.WriteFile(tmp, src); err != nil {
return fmt.Errorf("stage upload: %w", err)
}
defer func() {
_, _ = globalExecutor.Exec(fmt.Sprintf("rm -f %s 2>/dev/null || sudo rm -f %s 2>/dev/null || true",
shellQuote(tmp), shellQuote(tmp)))
}()
kctl := kubectlCLI(globalExecutor)
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
out, err := globalExecutor.Exec(fmt.Sprintf(
"%s cp %s %s/%s:%s 2>&1",
kctl, shellQuote(tmp), shellQuote(ns), shellQuote(pod), shellQuote(remotePath),
))
if err != nil {
return fmt.Errorf("copy to k8s pod: %w (%s)", err, strings.TrimSpace(out))
}
return nil
}
cleanDir := filepath.Clean(dir)
destPath := filepath.Join(cleanDir, name)
if !strings.HasPrefix(destPath, cleanDir+string(filepath.Separator)) {
return fmt.Errorf("backup entry %q escapes target directory", name)
}
return globalExecutor.WriteFile(destPath, src)
}
// @Summary List available database backup files in the backup directory
// @Tags battlegroup
// @Produce json
// @Success 200 {object} []backupFile
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/backup-files [get]
func handleBGBackupFiles(w http.ResponseWriter, r *http.Request) {
if globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
dir, err := activeBackupDir()
if err != nil {
jsonErr(w, err, 500)
return
}
if err := ensureBackupDir(dir); err != nil {
jsonErr(w, err, 500)
return
}
out, yamlOut, err := listBackupDir(dir)
if err != nil {
jsonErr(w, err, 500)
return
}
hasYAML := make(map[string]bool)
for _, n := range strings.Split(strings.TrimSpace(yamlOut), "\n") {
if n != "" {
hasYAML[strings.TrimSpace(n)] = true
}
}
var files []backupFile
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
p := strings.SplitN(line, "|", 3)
if len(p) < 3 {
continue
}
size, _ := strconv.ParseInt(p[1], 10, 64)
name := p[0]
files = append(files, backupFile{Name: name, SizeB: size, Modified: p[2], HasYAML: hasYAML[name]})
}
if files == nil {
files = []backupFile{}
}
jsonOK(w, files)
}
// @Summary Download a backup file (and its YAML metadata) as a zip archive
// @Tags battlegroup
// @Produce application/zip
// @Param file query string true "Backup filename (must end in .backup)"
// @Success 200 {file} binary
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/backup-files/download [get]
func handleBGBackupDownload(w http.ResponseWriter, r *http.Request) {
if globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
filename := r.URL.Query().Get("file")
if filename == "" || strings.ContainsAny(filename, "/\\") || !strings.HasSuffix(filename, ".backup") {
jsonErr(w, fmt.Errorf("invalid filename"), 400)
return
}
baseName := strings.TrimSuffix(filename, ".backup")
dir, err := activeBackupDir()
if err != nil {
jsonErr(w, err, 500)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, baseName))
w.Header().Set("Content-Type", "application/zip")
zw := zip.NewWriter(w)
for _, ext := range []string{".backup", ".backup.yaml"} {
name := baseName + ext
if !backupFileExists(dir, name) {
continue
}
fw, err := zw.Create(name)
if err != nil {
continue
}
if err := globalExecutor.PipeToWriter(backupReadCmd(dir, name), fw); err != nil {
fmt.Printf("zip entry %s: %v\n", name, err)
}
}
if err := zw.Close(); err != nil {
fmt.Printf("zip close: %v\n", err)
}
}
// @Summary Restore the database from a named backup file via the control plane
// @Tags battlegroup
// @Accept json
// @Produce json
// @Param body body object true "Backup filename (must end in .backup)"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/restore [post]
func handleBGRestore(w http.ResponseWriter, r *http.Request) {
if globalControl == nil || globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
var req struct {
File string `json:"file"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, 400)
return
}
if req.File == "" || strings.ContainsAny(req.File, "/\\") || !strings.HasSuffix(req.File, ".backup") {
jsonErr(w, fmt.Errorf("invalid filename"), 400)
return
}
out, err := restoreViaControl(r.Context(), req.File)
if err != nil {
jsonErr(w, fmt.Errorf("restore failed: %w\n%s", err, out), 500)
return
}
jsonOK(w, map[string]string{"output": out})
}
func allowedBackupArchiveEntry(entryName string) (string, bool) {
name := filepath.Base(entryName)
if strings.ContainsAny(name, "/\\") {
return "", false
}
if strings.HasSuffix(name, ".backup") || strings.HasSuffix(name, ".backup.yaml") {
return name, true
}
return "", false
}
func writeBackupArchiveEntries(dir string, zr *zip.Reader) (string, error) {
var backupName string
for _, zf := range zr.File {
name, ok := allowedBackupArchiveEntry(zf.Name)
if !ok {
continue
}
rc, err := zf.Open()
if err != nil {
continue
}
if err := writeBackupFile(dir, name, rc); err != nil {
_ = rc.Close()
return "", fmt.Errorf("upload failed for %s: %w", name, err)
}
if err := rc.Close(); err != nil {
continue
}
if strings.HasSuffix(name, ".backup") {
backupName = name
}
}
return backupName, nil
}
func uploadBackupArchive(dir string, file multipart.File) (string, int, error) {
data, err := io.ReadAll(file)
if err != nil {
return "", 400, fmt.Errorf("read zip: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", 400, fmt.Errorf("invalid zip: %w", err)
}
backupName, err := writeBackupArchiveEntries(dir, zr)
if err != nil {
return "", 500, err
}
if backupName == "" {
return "", 400, fmt.Errorf("zip contains no .backup file")
}
return backupName, 200, nil
}
func isDirectBackupUpload(filename string) bool {
return strings.HasSuffix(filename, ".backup") && !strings.ContainsAny(filename, "/\\")
}
// @Summary Upload a backup file (.backup or .zip) to the backup directory
// @Tags battlegroup
// @Accept multipart/form-data
// @Produce json
// @Param backup formData file true "Backup file (.backup or .zip)"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/battlegroup/backup-files/upload [post]
func handleBGBackupUpload(w http.ResponseWriter, r *http.Request) {
if globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4<<30)
if err := r.ParseMultipartForm(64 << 20); err != nil {
jsonErr(w, fmt.Errorf("parse form: %w", err), 400)
return
}
file, header, err := r.FormFile("backup")
if err != nil {
jsonErr(w, fmt.Errorf("no file: %w", err), 400)
return
}
defer func() { _ = file.Close() }()
filename := header.Filename
dir, err := activeBackupDir()
if err != nil {
jsonErr(w, err, 500)
return
}
if err := ensureBackupDir(dir); err != nil {
jsonErr(w, err, 500)
return
}
if strings.HasSuffix(filename, ".zip") {
backupName, status, err := uploadBackupArchive(dir, file)
if err != nil {
jsonErr(w, err, status)
return
}
jsonOK(w, map[string]string{"name": backupName})
return
}
if isDirectBackupUpload(filename) {
if err := writeBackupFile(dir, filename, file); err != nil {
jsonErr(w, fmt.Errorf("upload failed: %w", err), 500)
return
}
jsonOK(w, map[string]string{"name": filename})
return
}
jsonErr(w, fmt.Errorf("file must be .backup or .zip"), 400)
}
// restoreViaControl runs a restore command appropriate for the active control plane.
// Called by handleBGRestore — kept separate so the restore logic per-provider
// can be extended without touching the HTTP handler.
func restoreViaControl(ctx context.Context, filename string) (string, error) {
// kubectl uses the battlegroup.sh import script.
// TODO: NEVER run battlegroup.sh with sudo — see ExecCommand in control_kubectl.go.
if globalControl != nil && globalControl.Name() == "kubectl" {
return globalExecutor.Exec(fmt.Sprintf(
`echo yes | ~/.dune/download/scripts/battlegroup.sh import %s 2>&1`,
shellQuote(filename)))
}
// docker / local: pg_restore from the backup directory.
dir, err := activeBackupDir()
if err != nil {
return "", err
}
path := strings.TrimRight(dir, "/") + "/" + filename
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
kctl := kubectlCLI(globalExecutor)
tmp := fmt.Sprintf("/tmp/dune-admin-restore-%d.backup", time.Now().UnixNano())
remotePath := strings.TrimRight(inPodDir, "/") + "/" + filename
copyOut, copyErr := globalExecutor.Exec(fmt.Sprintf(
"%s cp %s/%s:%s %s 2>&1",
kctl, shellQuote(ns), shellQuote(pod), shellQuote(remotePath), shellQuote(tmp),
))
if copyErr != nil {
return copyOut, fmt.Errorf("copy backup to local restore path: %w", copyErr)
}
defer func() {
_, _ = globalExecutor.Exec(fmt.Sprintf("rm -f %s 2>/dev/null || sudo rm -f %s 2>/dev/null || true",
shellQuote(tmp), shellQuote(tmp)))
}()
path = tmp
}
return globalExecutor.Exec(fmt.Sprintf(
`PGPASSWORD=%s pg_restore --no-password --clean --if-exists -h %s -p %d -U %s -d %s %s 2>&1`,
shellQuote(dbPass), dbHost, dbPort, dbUser, dbName, shellQuote(path)))
}

View File

@@ -0,0 +1,96 @@
package main
import (
"archive/zip"
"bytes"
"testing"
)
type testMultipartFile struct {
*bytes.Reader
}
func (f testMultipartFile) Close() error { return nil }
func TestAllowedBackupArchiveEntry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
entryName string
wantName string
wantOK bool
}{
{name: "backup", entryName: "save.backup", wantName: "save.backup", wantOK: true},
{name: "backup-yaml", entryName: "save.backup.yaml", wantName: "save.backup.yaml", wantOK: true},
{name: "nested-path", entryName: "dir/sub/save.backup", wantName: "save.backup", wantOK: true},
{name: "non-backup", entryName: "notes.txt", wantOK: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotName, gotOK := allowedBackupArchiveEntry(tt.entryName)
if gotOK != tt.wantOK {
t.Fatalf("expected ok=%v, got %v", tt.wantOK, gotOK)
}
if gotName != tt.wantName {
t.Fatalf("expected name %q, got %q", tt.wantName, gotName)
}
})
}
}
func TestIsDirectBackupUpload(t *testing.T) {
t.Parallel()
if !isDirectBackupUpload("save.backup") {
t.Fatal("expected plain .backup filename to be accepted")
}
if isDirectBackupUpload("save.zip") {
t.Fatal("expected .zip to be rejected for direct backup upload")
}
if isDirectBackupUpload("../save.backup") {
t.Fatal("expected path traversal to be rejected")
}
}
func TestUploadBackupArchive_InvalidZip(t *testing.T) {
t.Parallel()
file := testMultipartFile{Reader: bytes.NewReader([]byte("not a zip"))}
_, status, err := uploadBackupArchive("/unused", file)
if err == nil {
t.Fatal("expected invalid zip error")
}
if status != 400 {
t.Fatalf("expected status 400, got %d", status)
}
}
func TestUploadBackupArchive_NoBackupFile(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("notes.txt")
if err != nil {
t.Fatalf("create zip entry: %v", err)
}
if _, err := w.Write([]byte("hello")); err != nil {
t.Fatalf("write zip entry: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
file := testMultipartFile{Reader: bytes.NewReader(buf.Bytes())}
_, status, err := uploadBackupArchive("/unused", file)
if err == nil || err.Error() != "zip contains no .backup file" {
t.Fatalf("expected no-backup error, got %v", err)
}
if status != 400 {
t.Fatalf("expected status 400, got %d", status)
}
}

View File

@@ -0,0 +1,568 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
)
// @Summary List all building blueprints
// @Tags blueprints
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/blueprints [get]
func handleListBlueprints(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdListBlueprints().(msgBlueprintList)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
rows := msg.rows
if rows == nil {
rows = []blueprintRow{}
}
jsonOK(w, rows)
}
// @Summary Export a blueprint as a downloadable JSON file
// @Tags blueprints
// @Produce application/octet-stream
// @Param id path int true "Blueprint ID"
// @Success 200 {file} string "Blueprint JSON file"
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/blueprints/{id}/export [get]
func handleExportBlueprint(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid id"), 400)
return
}
bf, err := fetchBlueprintData(r.Context(), id)
if err != nil {
jsonErr(w, err, 500)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, blueprintFilename(bf.Name, id)))
_ = json.NewEncoder(w).Encode(bf)
}
// blueprintFilename returns the suggested download filename: the in-game name
// if present (sanitized), otherwise blueprint_<id>.json.
func blueprintFilename(name string, id int64) string {
clean := sanitizeFilename(name)
if clean == "" {
return fmt.Sprintf("blueprint_%d.json", id)
}
return clean + ".json"
}
// sanitizeFilename strips characters that are unsafe in filenames or
// Content-Disposition values across common filesystems.
func sanitizeFilename(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r < 0x20, r == 0x7f:
// drop control chars
case r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
b.WriteRune('_')
default:
b.WriteRune(r)
}
}
return strings.TrimSpace(b.String())
}
// @Summary Import a blueprint JSON file into a player's inventory
// @Tags blueprints
// @Accept multipart/form-data
// @Produce json
// @Param blueprint formData file true "Blueprint JSON file"
// @Param player_id formData int true "Player pawn ID to receive the blueprint"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/blueprints/import [post]
func handleImportBlueprint(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonErr(w, err, 400)
return
}
playerIDStr := r.FormValue("player_id")
playerID, err := strconv.ParseInt(playerIDStr, 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid player_id"), 400)
return
}
f, _, err := r.FormFile("file")
if err != nil {
jsonErr(w, fmt.Errorf("file required"), 400)
return
}
defer func() { _ = f.Close() }()
var bf blueprintFile
if err := json.NewDecoder(f).Decode(&bf); err != nil {
jsonErr(w, fmt.Errorf("invalid blueprint JSON: %w", err), 400)
return
}
if len(bf.Instances) == 0 && len(bf.Placeables) == 0 {
jsonErr(w, fmt.Errorf("blueprint has no instances or placeables"), 400)
return
}
msg, ok := importBlueprintData(r.Context(), playerID, bf).(msgMutate)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
jsonOK(w, map[string]string{"ok": msg.ok})
}
// structuralBuildingTypes lists building_type values that game-saved blueprints
// commonly mark with provides_stability=true (foundations, pillars, columns).
// Used only as a fallback when importing legacy JSON that doesn't carry the
// per-instance flag; the game's structural solver actually picks a subset of
// these per build, so re-exported files always carry the exact bool.
var structuralBuildingTypes = map[string]bool{
"Atreides_Outpost_Column": true,
"Atreides_Outpost_Column_Corner": true,
"Atreides_Outpost_Foundation": true,
"Atreides_Outpost_Foundation_Round_Corner": true,
"Atreides_Outpost_Foundation_Wedge": true,
"Atreides_Outpost_Pillar_Bottom": true,
"Atreides_Outpost_Pillar_Middle": true,
"Atreides_Outpost_Pillar_Top": true,
"Choam_Level2_Column": true,
"Choam_Level2_Foundation": true,
"Choam_Level2_Pillar_Bottom": true,
"Choam_Shelter_Column_Corner_New": true,
"Choam_Shelter_Column_New": true,
"Harkonnen_Outpost_Column": true,
"Harkonnen_Outpost_Foundation": true,
"MTX_Neut_DesertMechanic_Center_Column": true,
"MTX_Neut_DesertMechanic_Corner_Column": true,
"MTX_Neut_DesertMechanic_Foundation": true,
"MTX_Smug_Foundation": true,
}
func isStructuralBuilding(buildingType string) bool {
return structuralBuildingTypes[buildingType]
}
func fetchBlueprintName(ctx context.Context, blueprintID int64) string {
var name string
_ = globalDB.QueryRow(ctx, `
SELECT COALESCE(i.stats->'FBuildingBlueprintItemStats'->1->>'BuildingBlueprintName', '')
FROM dune.building_blueprints bb
JOIN dune.items i ON i.id = bb.item_id
WHERE bb.id = $1`, blueprintID).Scan(&name)
return name
}
func buildBlueprintInstance(iid int, buildingType string, transform []float32, stability bool) (blueprintInstance, bool) {
if len(transform) < 4 {
return blueprintInstance{}, false
}
return blueprintInstance{
InstanceID: &iid,
BuildingType: buildingType,
X: float64(transform[0]),
Y: float64(transform[1]),
Z: float64(transform[2]),
Rotation: float64(transform[3]),
ProvidesStability: &stability,
}, true
}
func fetchBlueprintInstances(ctx context.Context, blueprintID int64) ([]blueprintInstance, error) {
rows, err := globalDB.Query(ctx, `
SELECT instance_id, building_type, transform, provides_stability
FROM dune.building_blueprint_instances
WHERE building_blueprint_id = $1
ORDER BY instance_id`, blueprintID)
if err != nil {
return nil, fmt.Errorf("query instances: %w", err)
}
defer rows.Close()
var instances []blueprintInstance
for rows.Next() {
var iid int
var buildingType string
var transform []float32
var stability bool
if err := rows.Scan(&iid, &buildingType, &transform, &stability); err != nil {
continue
}
instance, ok := buildBlueprintInstance(iid, buildingType, transform, stability)
if !ok {
continue
}
instances = append(instances, instance)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read instances: %w", err)
}
return instances, nil
}
func buildBlueprintPlaceable(pid int, buildingType string, transform []float32) (blueprintPlaceable, bool) {
if len(transform) < 6 {
return blueprintPlaceable{}, false
}
return blueprintPlaceable{
PlaceableID: &pid,
BuildingType: buildingType,
X: float64(transform[0]),
Y: float64(transform[1]),
Z: float64(transform[2]),
RX: float64(transform[3]),
RY: float64(transform[4]),
RZ: float64(transform[5]),
}, true
}
func fetchBlueprintPlaceables(ctx context.Context, blueprintID int64) ([]blueprintPlaceable, error) {
rows, err := globalDB.Query(ctx, `
SELECT placeable_id, building_type, transform
FROM dune.building_blueprint_placeables
WHERE building_blueprint_id = $1
ORDER BY placeable_id`, blueprintID)
if err != nil {
return nil, fmt.Errorf("query placeables: %w", err)
}
defer rows.Close()
var placeables []blueprintPlaceable
for rows.Next() {
var pid int
var buildingType string
var transform []float32
if err := rows.Scan(&pid, &buildingType, &transform); err != nil {
continue
}
placeable, ok := buildBlueprintPlaceable(pid, buildingType, transform)
if !ok {
continue
}
placeables = append(placeables, placeable)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read placeables: %w", err)
}
return placeables, nil
}
func buildBlueprintPentashield(pid int, scale []int16) (blueprintPentashield, bool) {
if len(scale) < 3 {
return blueprintPentashield{}, false
}
return blueprintPentashield{
PlaceableID: pid,
Scale: [3]int{int(scale[0]), int(scale[1]), int(scale[2])},
}, true
}
func fetchBlueprintPentashields(ctx context.Context, blueprintID int64) ([]blueprintPentashield, error) {
rows, err := globalDB.Query(ctx, `
SELECT placeable_id, scale
FROM dune.building_blueprint_pentashields
WHERE building_blueprint_id = $1
ORDER BY placeable_id`, blueprintID)
if err != nil {
return nil, fmt.Errorf("query pentashields: %w", err)
}
defer rows.Close()
var pentashields []blueprintPentashield
for rows.Next() {
var pid int
var scale []int16
if err := rows.Scan(&pid, &scale); err != nil {
continue
}
pentashield, ok := buildBlueprintPentashield(pid, scale)
if !ok {
continue
}
pentashields = append(pentashields, pentashield)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read pentashields: %w", err)
}
return pentashields, nil
}
// fetchBlueprintData fetches blueprint instances, placeables, and pentashields
// from the DB and returns a blueprintFile ready for JSON serialization.
func fetchBlueprintData(ctx context.Context, blueprintID int64) (blueprintFile, error) {
if globalDB == nil {
return blueprintFile{}, fmt.Errorf("not connected")
}
name := fetchBlueprintName(ctx, blueprintID)
instances, err := fetchBlueprintInstances(ctx, blueprintID)
if err != nil {
return blueprintFile{}, err
}
placeables, err := fetchBlueprintPlaceables(ctx, blueprintID)
if err != nil {
return blueprintFile{}, err
}
pentashields, err := fetchBlueprintPentashields(ctx, blueprintID)
if err != nil {
return blueprintFile{}, err
}
return blueprintFile{
Name: name,
Instances: instances,
Placeables: placeables,
Pentashields: pentashields,
}, nil
}
const blueprintImportBatchSize = 50
const blueprintPlaceholderStats = `{"FCustomizationStats":[[], {}],"FBuildingBlueprintItemStats":[[], {"PlayerBlueprintId":"!!bbp#0"}],"FItemStackAndDurabilityStats":[[], {"DecayedMaxDurability":0.0}]}`
func findBackpackInventoryID(ctx context.Context, tx pgx.Tx, playerPawnID int64) (int64, error) {
var invID int64
err := tx.QueryRow(ctx, `
SELECT id FROM dune.inventories
WHERE actor_id = $1 AND inventory_type = 0
LIMIT 1`, playerPawnID).Scan(&invID)
if err != nil {
return 0, fmt.Errorf("find inventory: %w", err)
}
return invID, nil
}
func nextInventoryPosition(ctx context.Context, tx pgx.Tx, inventoryID int64) int64 {
var nextPos int64
_ = tx.QueryRow(ctx, `
SELECT COALESCE(MAX(position_index), -1) + 1
FROM dune.items WHERE inventory_id = $1`, inventoryID).Scan(&nextPos)
return nextPos
}
func createBlueprintItem(ctx context.Context, tx pgx.Tx, inventoryID, position int64) (int64, error) {
var itemID int64
err := tx.QueryRow(ctx, `
INSERT INTO dune.items
(inventory_id, stack_size, position_index, template_id, quality_level, stats)
VALUES ($1, 1, $2, 'BuildingBlueprint_CopyDevice', 0, $3::jsonb)
RETURNING id`,
inventoryID, position, blueprintPlaceholderStats).Scan(&itemID)
if err != nil {
return 0, fmt.Errorf("create item: %w", err)
}
return itemID, nil
}
func createBlueprintRecord(ctx context.Context, tx pgx.Tx, itemID int64) (int64, error) {
var blueprintID int64
err := tx.QueryRow(ctx, `
INSERT INTO dune.building_blueprints (item_id, player_id, building_blueprint_map)
VALUES ($1, null, '')
RETURNING id`, itemID).Scan(&blueprintID)
if err != nil {
return 0, fmt.Errorf("create blueprint: %w", err)
}
return blueprintID, nil
}
func blueprintItemStatsJSON(blueprintID int64, name string) string {
nameJSON := ""
if name != "" {
nameJSON = fmt.Sprintf(`,"BuildingBlueprintName":%q`, name)
}
return fmt.Sprintf(
`{"FCustomizationStats":[[], {}],"FBuildingBlueprintItemStats":[[], {"PlayerBlueprintId":"!!bbp#%d"%s}],"FItemStackAndDurabilityStats":[[], {"DecayedMaxDurability":0.0}]}`,
blueprintID, nameJSON)
}
func updateBlueprintItemStats(ctx context.Context, tx pgx.Tx, itemID, blueprintID int64, name string) error {
if _, err := tx.Exec(ctx, `UPDATE dune.items SET stats = $1::jsonb WHERE id = $2`,
blueprintItemStatsJSON(blueprintID, name), itemID); err != nil {
return fmt.Errorf("update item stats: %w", err)
}
return nil
}
func resolveBlueprintImportInstance(start, offset int, inst blueprintInstance) (instanceID int, transform string, stability bool) {
transform = fmt.Sprintf("{%g,%g,%g,%g}",
float32(inst.X), float32(inst.Y), float32(inst.Z), float32(inst.Rotation))
instanceID = start + offset + 1
if inst.InstanceID != nil {
instanceID = *inst.InstanceID
}
stability = isStructuralBuilding(inst.BuildingType)
if inst.ProvidesStability != nil {
stability = *inst.ProvidesStability
}
return instanceID, transform, stability
}
func insertBlueprintInstances(ctx context.Context, tx pgx.Tx, blueprintID int64, instances []blueprintInstance) error {
for start := 0; start < len(instances); start += blueprintImportBatchSize {
end := start + blueprintImportBatchSize
if end > len(instances) {
end = len(instances)
}
batch := &pgx.Batch{}
for i, inst := range instances[start:end] {
instanceID, transform, stability := resolveBlueprintImportInstance(start, i, inst)
batch.Queue(`
INSERT INTO dune.building_blueprint_instances
(building_blueprint_id, instance_id, building_type, transform, hologram, provides_stability, health)
VALUES ($1, $2, $3, $4::real[], true, $5, 0)`,
blueprintID, instanceID, inst.BuildingType, transform, stability)
}
br := tx.SendBatch(ctx, batch)
for i := start; i < end; i++ {
if _, err := br.Exec(); err != nil {
_ = br.Close()
return fmt.Errorf("insert instance %d: %w", i, err)
}
}
_ = br.Close()
}
return nil
}
func resolveBlueprintImportPlaceable(start, offset int, pl blueprintPlaceable) (placeableID int, transform string) {
transform = fmt.Sprintf("{%g,%g,%g,%g,%g,%g}",
float32(pl.X), float32(pl.Y), float32(pl.Z),
float32(pl.RX), float32(pl.RY), float32(pl.RZ))
placeableID = start + offset + 1
if pl.PlaceableID != nil {
placeableID = *pl.PlaceableID
}
return placeableID, transform
}
func insertBlueprintPlaceables(ctx context.Context, tx pgx.Tx, blueprintID int64, placeables []blueprintPlaceable) error {
for start := 0; start < len(placeables); start += blueprintImportBatchSize {
end := start + blueprintImportBatchSize
if end > len(placeables) {
end = len(placeables)
}
batch := &pgx.Batch{}
for i, pl := range placeables[start:end] {
placeableID, transform := resolveBlueprintImportPlaceable(start, i, pl)
batch.Queue(`
INSERT INTO dune.building_blueprint_placeables
(building_blueprint_id, placeable_id, building_type, transform, hologram)
VALUES ($1, $2, $3, $4::real[], true)`,
blueprintID, placeableID, pl.BuildingType, transform)
}
br := tx.SendBatch(ctx, batch)
for i := start; i < end; i++ {
if _, err := br.Exec(); err != nil {
_ = br.Close()
return fmt.Errorf("insert placeable %d: %w", i, err)
}
}
_ = br.Close()
}
return nil
}
func insertBlueprintPentashields(ctx context.Context, tx pgx.Tx, blueprintID int64, pentashields []blueprintPentashield) error {
for _, ps := range pentashields {
if _, err := tx.Exec(ctx, `
INSERT INTO dune.building_blueprint_pentashields
(building_blueprint_id, placeable_id, scale)
VALUES ($1, $2, ARRAY[$3,$4,$5]::smallint[])`,
blueprintID, ps.PlaceableID,
int16(ps.Scale[0]), int16(ps.Scale[1]), int16(ps.Scale[2])); err != nil {
return fmt.Errorf("insert pentashield %d: %w", ps.PlaceableID, err)
}
}
return nil
}
// importBlueprintData imports a blueprintFile into the DB for the given player pawn ID.
func importBlueprintData(ctx context.Context, playerPawnID int64, bf blueprintFile) Msg {
if globalDB == nil {
return msgMutate{err: fmt.Errorf("not connected")}
}
// Player must be offline.
if err := checkPlayerOffline(ctx, playerPawnID); err != nil {
return msgMutate{err: err}
}
tx, err := globalDB.Begin(ctx)
if err != nil {
return msgMutate{err: fmt.Errorf("begin tx: %w", err)}
}
defer func() { _ = tx.Rollback(ctx) }()
invID, err := findBackpackInventoryID(ctx, tx, playerPawnID)
if err != nil {
return msgMutate{err: err}
}
itemID, err := createBlueprintItem(ctx, tx, invID, nextInventoryPosition(ctx, tx, invID))
if err != nil {
return msgMutate{err: err}
}
blueprintID, err := createBlueprintRecord(ctx, tx, itemID)
if err != nil {
return msgMutate{err: err}
}
// Update item stats with real blueprint ID and name (no PlayerBaseBackupId — crashes the game).
if err := updateBlueprintItemStats(ctx, tx, itemID, blueprintID, bf.Name); err != nil {
return msgMutate{err: err}
}
// Insert instances in batches of 50.
// Per-row instance_id and provides_stability come from the JSON when present
// (fresh exports always include them). Legacy files without these fields fall
// back to 1-based sequential ids and a structural-type stability lookup —
// matching the indexing scheme used by every existing blueprint in the DB
// that the source pentashield placeable_id references assume.
if err := insertBlueprintInstances(ctx, tx, blueprintID, bf.Instances); err != nil {
return msgMutate{err: err}
}
// Insert placeables in batches of 50.
if err := insertBlueprintPlaceables(ctx, tx, blueprintID, bf.Placeables); err != nil {
return msgMutate{err: err}
}
// Insert pentashield scale data.
if err := insertBlueprintPentashields(ctx, tx, blueprintID, bf.Pentashields); err != nil {
return msgMutate{err: err}
}
if err := tx.Commit(ctx); err != nil {
return msgMutate{err: fmt.Errorf("commit: %w", err)}
}
return msgMutate{ok: fmt.Sprintf(
"Imported %d pieces + %d placeables + %d pentashields → blueprint #%d (item %d) in player inventory",
len(bf.Instances), len(bf.Placeables), len(bf.Pentashields), blueprintID, itemID)}
}

View File

@@ -0,0 +1,60 @@
package main
import "testing"
func TestBuildBlueprintInstance(t *testing.T) {
t.Parallel()
instance, ok := buildBlueprintInstance(7, "TypeA", []float32{1, 2, 3, 4}, true)
if !ok {
t.Fatalf("expected valid transform to produce an instance")
}
if instance.InstanceID == nil || *instance.InstanceID != 7 {
t.Fatalf("unexpected instance id: %+v", instance.InstanceID)
}
if instance.BuildingType != "TypeA" || instance.X != 1 || instance.Y != 2 || instance.Z != 3 || instance.Rotation != 4 {
t.Fatalf("unexpected instance payload: %+v", instance)
}
if instance.ProvidesStability == nil || !*instance.ProvidesStability {
t.Fatalf("expected stability=true pointer in instance")
}
if _, ok := buildBlueprintInstance(1, "TypeB", []float32{1, 2, 3}, false); ok {
t.Fatalf("expected short transform to be rejected")
}
}
func TestBuildBlueprintPlaceable(t *testing.T) {
t.Parallel()
placeable, ok := buildBlueprintPlaceable(9, "TypeP", []float32{1, 2, 3, 4, 5, 6})
if !ok {
t.Fatalf("expected valid transform to produce a placeable")
}
if placeable.PlaceableID == nil || *placeable.PlaceableID != 9 {
t.Fatalf("unexpected placeable id: %+v", placeable.PlaceableID)
}
if placeable.BuildingType != "TypeP" || placeable.X != 1 || placeable.Y != 2 || placeable.Z != 3 || placeable.RX != 4 || placeable.RY != 5 || placeable.RZ != 6 {
t.Fatalf("unexpected placeable payload: %+v", placeable)
}
if _, ok := buildBlueprintPlaceable(1, "TypeBad", []float32{1, 2, 3, 4, 5}); ok {
t.Fatalf("expected short placeable transform to be rejected")
}
}
func TestBuildBlueprintPentashield(t *testing.T) {
t.Parallel()
pentashield, ok := buildBlueprintPentashield(11, []int16{10, 20, 30})
if !ok {
t.Fatalf("expected valid scale to produce pentashield")
}
if pentashield.PlaceableID != 11 || pentashield.Scale != [3]int{10, 20, 30} {
t.Fatalf("unexpected pentashield payload: %+v", pentashield)
}
if _, ok := buildBlueprintPentashield(1, []int16{1, 2}); ok {
t.Fatalf("expected short pentashield scale to be rejected")
}
}

View File

@@ -0,0 +1,86 @@
package main
import (
"strings"
"testing"
)
func boolPtr(v bool) *bool {
return &v
}
func intPtr(v int) *int {
return &v
}
func TestBlueprintItemStatsJSON(t *testing.T) {
withName := blueprintItemStatsJSON(123, "My Blueprint")
if !strings.Contains(withName, `"PlayerBlueprintId":"!!bbp#123"`) {
t.Fatalf("missing blueprint id in stats JSON: %s", withName)
}
if !strings.Contains(withName, `"BuildingBlueprintName":"My Blueprint"`) {
t.Fatalf("missing blueprint name in stats JSON: %s", withName)
}
withoutName := blueprintItemStatsJSON(77, "")
if strings.Contains(withoutName, "BuildingBlueprintName") {
t.Fatalf("expected no blueprint name when empty, got: %s", withoutName)
}
}
func TestResolveBlueprintImportInstance(t *testing.T) {
inst := blueprintInstance{
BuildingType: "Atreides_Outpost_Foundation",
X: 1,
Y: 2,
Z: 3,
Rotation: 90,
}
id, transform, stability := resolveBlueprintImportInstance(50, 2, inst)
if id != 53 {
t.Fatalf("expected fallback instance id 53, got %d", id)
}
if transform != "{1,2,3,90}" {
t.Fatalf("unexpected transform: %q", transform)
}
if !stability {
t.Fatal("expected structural building fallback to set stability=true")
}
customID := 900
inst.InstanceID = intPtr(customID)
inst.ProvidesStability = boolPtr(false)
id, _, stability = resolveBlueprintImportInstance(0, 0, inst)
if id != customID {
t.Fatalf("expected explicit instance id %d, got %d", customID, id)
}
if stability {
t.Fatal("expected explicit ProvidesStability override to win")
}
}
func TestResolveBlueprintImportPlaceable(t *testing.T) {
pl := blueprintPlaceable{
BuildingType: "SomePlaceable",
X: 4,
Y: 5,
Z: 6,
RX: 1,
RY: 2,
RZ: 3,
}
id, transform := resolveBlueprintImportPlaceable(10, 1, pl)
if id != 12 {
t.Fatalf("expected fallback placeable id 12, got %d", id)
}
if transform != "{4,5,6,1,2,3}" {
t.Fatalf("unexpected transform: %q", transform)
}
customID := 321
pl.PlaceableID = intPtr(customID)
id, _ = resolveBlueprintImportPlaceable(0, 0, pl)
if id != customID {
t.Fatalf("expected explicit placeable id %d, got %d", customID, id)
}
}

View File

@@ -0,0 +1,264 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"gopkg.in/yaml.v3"
)
const masked = "••••••••"
// handleGetConfig returns the current config with all secret fields masked.
//
// @Summary Get current runtime configuration (secrets masked)
// @Tags config
// @Produce json
// @Success 200 {object} appConfig
// @Router /api/v1/config [get]
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(configPath())
if err != nil {
jsonOK(w, buildCurrentConfig())
return
}
var cfg appConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
jsonErr(w, fmt.Errorf("parse config: %w", err), 500)
return
}
maskSecrets(&cfg)
jsonOK(w, cfg)
}
// maskSecrets replaces all secret fields with the display placeholder.
func maskSecrets(cfg *appConfig) {
if cfg.DBPass != "" {
cfg.DBPass = masked
}
if cfg.BrokerPass != "" {
cfg.BrokerPass = masked
}
if cfg.BrokerJWTSecret != "" {
cfg.BrokerJWTSecret = masked
}
if cfg.MarketBotRemoteToken != "" {
cfg.MarketBotRemoteToken = masked
}
if cfg.AmpAPIPass != "" {
cfg.AmpAPIPass = masked
}
}
// preserveMaskedSecrets restores real secret values when the client sent back
// the display placeholder. Falls back to loadedConfig when the file is
// unreadable so in-memory secrets survive a mid-session config file move.
func preserveMaskedSecrets(
cfg *appConfig,
readFile func(string) ([]byte, error),
path string,
) {
needsRestore := cfg.DBPass == masked ||
cfg.BrokerPass == masked ||
cfg.BrokerJWTSecret == masked ||
cfg.MarketBotRemoteToken == masked ||
cfg.AmpAPIPass == masked
if !needsRestore {
return
}
old := loadedConfig
if data, err := readFile(path); err == nil {
_ = yaml.Unmarshal(data, &old)
}
// dbPass global may differ from loadedConfig when set from env var
if old.DBPass == "" {
old.DBPass = dbPass
}
if cfg.DBPass == masked {
cfg.DBPass = old.DBPass
}
if cfg.BrokerPass == masked {
cfg.BrokerPass = old.BrokerPass
}
if cfg.BrokerJWTSecret == masked {
cfg.BrokerJWTSecret = old.BrokerJWTSecret
}
if cfg.MarketBotRemoteToken == masked {
cfg.MarketBotRemoteToken = old.MarketBotRemoteToken
}
if cfg.AmpAPIPass == masked {
cfg.AmpAPIPass = old.AmpAPIPass
}
}
func writeConfigFile(cfg appConfig) error {
if err := os.MkdirAll(configDir(), 0700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(configPath(), data, 0600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
// stopEmbeddedMarketBot cancels the running embedded bot (if any) and clears
// embeddedBot and globalBotCancel. Call this before resetRuntimeConnections so
// the bot releases its reference to the old (about-to-be-closed) globalDB pool.
func stopEmbeddedMarketBot() {
if embeddedBot == nil {
return
}
if globalBotCancel != nil {
globalBotCancel()
globalBotCancel = nil
}
embeddedBot = nil
}
func resetRuntimeConnections() {
if globalDB != nil {
globalDB.Close()
globalDB = nil
}
if globalExecutor != nil {
globalExecutor.Close()
globalExecutor = nil
}
globalSSH = nil
globalControl = nil
}
// @Summary Save configuration and reconnect
// @Tags config
// @Accept json
// @Produce json
// @Param config body appConfig true "Updated configuration"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/config [post]
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
var cfg appConfig
if err := decode(r, &cfg); err != nil {
jsonErr(w, fmt.Errorf("decode: %w", err), 400)
return
}
preserveMaskedSecrets(&cfg, os.ReadFile, configPath())
if err := writeConfigFile(cfg); err != nil {
jsonErr(w, err, 500)
return
}
applyConfig(cfg)
// Stop the running bot (if any) before closing the DB pool.
// A running bot holds a reference to globalDB; if we close the pool first
// the bot's next tick will use a closed pool and panic or error.
stopEmbeddedMarketBot()
resetRuntimeConnections()
// Reconnect is best-effort — config is already written to disk.
// If reconnect fails (e.g. SSH not yet reachable), the file is still
// saved and will take effect on the next restart or manual reconnect.
if err := connectAll(); err != nil {
log.Printf("handleSaveConfig: reconnect after save: %v", err)
}
// Apply the market bot config AFTER connectAll so the bot gets the
// freshly-established globalDB rather than the old (closed) pool.
// applyMarketBotConfig will restart the bot (if enabled) with the new pool.
applyMarketBotConfig(cfg)
handleStatus(w, r)
}
// buildCurrentConfig constructs an appConfig from the current global vars.
func buildCurrentConfig() appConfig {
return appConfig{
SSHHost: sshHost,
SSHUser: sshUser,
SSHKey: sshKeyPath,
DBHost: dbHost,
DBPort: dbPort,
DBUser: dbUser,
DBPass: masked,
DBName: dbName,
DBSchema: dbSchema,
Control: controlPlane,
ControlNamespace: controlNS,
BrokerGameAddr: brokerGameAddr,
BrokerAdminAddr: brokerAdminAddr,
BrokerTLS: brokerTLS,
BackupDir: backupDir,
ListenAddr: listenAddr,
ScripCurrency: scripCurrencyID,
}
}
// applyMarketBotConfig stops or starts the embedded market bot to match the
// new config. Called after applyConfig so loadedConfig is already updated.
func applyMarketBotConfig(cfg appConfig) {
wantEnabled := marketBotEnabled(cfg)
botRunning := embeddedBot != nil
if botRunning && !wantEnabled {
log.Printf("config: market_bot_enabled set to false — stopping embedded bot")
if globalBotCancel != nil {
globalBotCancel()
globalBotCancel = nil
}
embeddedBot = nil
}
if !botRunning && wantEnabled {
log.Printf("config: market_bot_enabled set to true — starting embedded bot")
if cancel := startEmbeddedMarketBotIfEnabled(cfg); cancel != nil {
globalBotCancel = cancel
}
}
// Update remote proxy from new config.
if cfg.MarketBotRemoteURL != "" {
remoteBotProxy = newRemoteBotClient(cfg.MarketBotRemoteURL, cfg.MarketBotRemoteToken)
} else {
remoteBotProxy = nil
}
}
// applyConfig pushes a saved appConfig back into the runtime globals so that
// connectAll() picks up the new values without requiring a process restart.
func applyConfig(cfg appConfig) {
sshHost = cfg.SSHHost
sshUser = cfg.SSHUser
if cfg.SSHKey != "" {
sshKeyPath = cfg.SSHKey
}
dbHost = cfg.DBHost
if cfg.DBPort != 0 {
dbPort = cfg.DBPort
}
dbUser = cfg.DBUser
dbPass = cfg.DBPass
dbName = cfg.DBName
dbSchema = cfg.DBSchema
controlPlane = cfg.Control
controlNS = cfg.ControlNamespace
brokerGameAddr = cfg.BrokerGameAddr
brokerAdminAddr = cfg.BrokerAdminAddr
brokerTLS = cfg.BrokerTLS
brokerUser = cfg.BrokerUser
brokerPass = cfg.BrokerPass
backupDir = cfg.BackupDir
loadedConfig = cfg
}

View File

@@ -0,0 +1,179 @@
package main
import (
"errors"
"testing"
"dune-admin/internal/marketbot"
)
// TestApplyMarketBotConfig_StopClearsBot verifies that when wantEnabled=false,
// applyMarketBotConfig cancels the running bot and sets embeddedBot to nil.
func TestApplyMarketBotConfig_StopClearsBot(t *testing.T) {
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
origBot := embeddedBot
origCancel := globalBotCancel
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
cancelled := false
globalBotCancel = func() { cancelled = true }
// Provide a non-nil embeddedBot instance so the running-check passes.
embeddedBot = new(marketbot.Instance)
disabled := false
cfg := appConfig{MarketBotEnabled: &disabled}
applyMarketBotConfig(cfg)
if embeddedBot != nil {
t.Error("embeddedBot should be nil after disabling")
}
if globalBotCancel != nil {
t.Error("globalBotCancel should be nil after disabling")
}
if !cancelled {
t.Error("cancel function should have been called")
}
}
// TestApplyMarketBotConfig_StartRequiresDB verifies that applyMarketBotConfig
// does NOT attempt to start the embedded bot when globalDB is nil. This
// enforces the ordering contract: applyMarketBotConfig must only be called
// AFTER connectAll() has established globalDB.
func TestApplyMarketBotConfig_StartRequiresDB(t *testing.T) {
// Not parallel: mutates package-level embeddedBot / globalDB.
origBot := embeddedBot
origCancel := globalBotCancel
origDB := globalDB
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel; globalDB = origDB })
embeddedBot = nil
globalBotCancel = nil
globalDB = nil // simulate pre-connectAll state
enabled := true
cfg := appConfig{MarketBotEnabled: &enabled}
applyMarketBotConfig(cfg)
// With globalDB nil, startEmbeddedMarketBotIfEnabled should fail and
// embeddedBot should remain nil rather than holding a broken instance.
if embeddedBot != nil {
t.Error("embeddedBot should remain nil when globalDB is nil (connectAll not yet called)")
}
}
// TestStopEmbeddedMarketBot_CancelsAndClearsGlobals verifies that
// stopEmbeddedMarketBot cancels the running bot's goroutines and clears both
// embeddedBot and globalBotCancel so the old (closed) DB pool is released.
// This is the prerequisite step before resetRuntimeConnections in handleSaveConfig.
func TestStopEmbeddedMarketBot_CancelsAndClearsGlobals(t *testing.T) {
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
origBot := embeddedBot
origCancel := globalBotCancel
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
cancelled := false
globalBotCancel = func() { cancelled = true }
embeddedBot = new(marketbot.Instance)
stopEmbeddedMarketBot()
if !cancelled {
t.Error("stopEmbeddedMarketBot should call globalBotCancel")
}
if embeddedBot != nil {
t.Error("stopEmbeddedMarketBot should set embeddedBot = nil")
}
if globalBotCancel != nil {
t.Error("stopEmbeddedMarketBot should set globalBotCancel = nil")
}
}
// TestStopEmbeddedMarketBot_NoopWhenNotRunning verifies that stopEmbeddedMarketBot
// is safe to call when no bot is running (nil embeddedBot).
func TestStopEmbeddedMarketBot_NoopWhenNotRunning(t *testing.T) {
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
origBot := embeddedBot
origCancel := globalBotCancel
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
embeddedBot = nil
globalBotCancel = nil
// Should not panic.
stopEmbeddedMarketBot()
if embeddedBot != nil {
t.Error("embeddedBot should remain nil")
}
}
// TestApplyConfig_SetsBrokerCredentials verifies that applyConfig copies broker
// credentials into the package-level globals so hot-apply works without restart.
func TestApplyConfig_SetsBrokerCredentials(t *testing.T) {
// Not parallel: mutates package-level globals.
origUser := brokerUser
origPass := brokerPass
origLoaded := loadedConfig
t.Cleanup(func() {
brokerUser = origUser
brokerPass = origPass
loadedConfig = origLoaded
})
cfg := appConfig{
BrokerUser: "cap_user",
BrokerPass: "cap_pass",
BrokerJWTSecret: "jwt_secret",
}
applyConfig(cfg)
if brokerUser != "cap_user" {
t.Errorf("brokerUser = %q, want cap_user", brokerUser)
}
if brokerPass != "cap_pass" {
t.Errorf("brokerPass = %q, want cap_pass", brokerPass)
}
// BrokerJWTSecret is read from loadedConfig in buildCaptureJWT; confirm it is set there.
if loadedConfig.BrokerJWTSecret != "jwt_secret" {
t.Errorf("loadedConfig.BrokerJWTSecret = %q, want jwt_secret", loadedConfig.BrokerJWTSecret)
}
}
// PreserveMaskedDBPass exercises the preserveMaskedSecrets function for the
// DBPass field specifically. Not parallel because subtests mutate loadedConfig.
func TestPreserveMaskedDBPass(t *testing.T) {
t.Run("keeps explicit password", func(t *testing.T) {
cfg := appConfig{DBPass: "new-pass"}
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
t.Fatalf("readFile should not be called for explicit password")
return nil, nil
}, "/tmp/unused")
if cfg.DBPass != "new-pass" {
t.Fatalf("expected explicit password to stay unchanged, got %q", cfg.DBPass)
}
})
t.Run("uses existing config password from file", func(t *testing.T) {
cfg := appConfig{DBPass: "••••••••"}
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
return []byte("db_pass: stored-pass\n"), nil
}, "/tmp/config.yaml")
if cfg.DBPass != "stored-pass" {
t.Fatalf("expected stored password from config file, got %q", cfg.DBPass)
}
})
t.Run("falls back to loadedConfig when file missing", func(t *testing.T) {
orig := loadedConfig
loadedConfig = appConfig{DBPass: "in-memory-pass"}
t.Cleanup(func() { loadedConfig = orig })
cfg := appConfig{DBPass: "••••••••"}
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
return nil, errors.New("no file")
}, "/tmp/missing.yaml")
if cfg.DBPass != "in-memory-pass" {
t.Fatalf("expected in-memory fallback password, got %q", cfg.DBPass)
}
})
}

View File

@@ -0,0 +1,48 @@
package main
import (
"fmt"
"net/http"
"os"
)
// allowedDataFiles is the exact set of filenames the /api/v1/data/{file}
// endpoint will serve. It acts as the path-traversal guard: only these
// well-known filenames are ever passed to the filesystem.
var allowedDataFiles = map[string]bool{
"item-data.json": true,
"tags-data.json": true,
"quality-data.json": true,
"packs.json": true,
"gameplayTags.json": true,
"skillModules.json": true,
"vehicles.json": true,
"cheatScripts.json": true,
}
// resolveDataFilePathFn is the file-path resolver used by handleGetDataFile.
// Replaced in tests to inject a temp directory without touching the real filesystem.
var resolveDataFilePathFn = resolveDataFilePath
// handleGetDataFile serves the named JSON data file as raw bytes.
// The frontend calls this first; if the file is absent the frontend falls
// back to the CDN. A 404 here is normal and expected.
func handleGetDataFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
if !allowedDataFiles[name] {
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
return
}
path := resolveDataFilePathFn(name)
if path == "" {
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
return
}
data, err := os.ReadFile(path) // #nosec G304 -- allowlisted filename only; no user input reaches the path
if err != nil {
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}

View File

@@ -0,0 +1,188 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// ── allowlist enforcement ─────────────────────────────────────────────────────
func TestHandleGetDataFile_NonAllowlistedReturns404(t *testing.T) {
tests := []struct {
name string
filename string
}{
{"path traversal", "../config.yaml"},
{"unknown json", "secrets.json"},
{"dot-env", ".env"},
{"go source", "main.go"},
{"empty string", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/"+tt.filename, nil)
req.SetPathValue("file", tt.filename)
rec := httptest.NewRecorder()
handleGetDataFile(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 for %q, got %d", tt.filename, rec.Code)
}
})
}
}
// ── file-absent path ──────────────────────────────────────────────────────────
func TestHandleGetDataFile_AllowlistedFileAbsent(t *testing.T) {
// Not parallel: mutates resolveDataFilePathFn.
orig := resolveDataFilePathFn
resolveDataFilePathFn = func(string) string { return "" }
t.Cleanup(func() { resolveDataFilePathFn = orig })
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/item-data.json", nil)
req.SetPathValue("file", "item-data.json")
rec := httptest.NewRecorder()
handleGetDataFile(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
}
// ── file-present path ─────────────────────────────────────────────────────────
func TestHandleGetDataFile_AllowlistedFilePresent(t *testing.T) {
// Not parallel: mutates resolveDataFilePathFn.
tmpDir := t.TempDir()
content := []byte(`{"items":{}}`)
if err := os.WriteFile(filepath.Join(tmpDir, "item-data.json"), content, 0600); err != nil {
t.Fatal(err)
}
orig := resolveDataFilePathFn
resolveDataFilePathFn = func(name string) string { return filepath.Join(tmpDir, name) }
t.Cleanup(func() { resolveDataFilePathFn = orig })
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/item-data.json", nil)
req.SetPathValue("file", "item-data.json")
rec := httptest.NewRecorder()
handleGetDataFile(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("want Content-Type application/json, got %q", ct)
}
if got := rec.Body.Bytes(); string(got) != string(content) {
t.Fatalf("want body %q, got %q", content, got)
}
}
// TestHandleGetDataFile_AllEightFilesServed verifies every file in the allowlist
// passes through the allowlist check and is served raw when present.
func TestHandleGetDataFile_AllEightFilesServed(t *testing.T) {
// Not parallel: mutates resolveDataFilePathFn.
tmpDir := t.TempDir()
wantFiles := []string{
"item-data.json",
"tags-data.json",
"quality-data.json",
"packs.json",
"gameplayTags.json",
"skillModules.json",
"vehicles.json",
"cheatScripts.json",
}
payload := []byte(`["sentinel"]`)
for _, f := range wantFiles {
if err := os.WriteFile(filepath.Join(tmpDir, f), payload, 0600); err != nil {
t.Fatal(err)
}
}
orig := resolveDataFilePathFn
resolveDataFilePathFn = func(name string) string { return filepath.Join(tmpDir, name) }
t.Cleanup(func() { resolveDataFilePathFn = orig })
for _, f := range wantFiles {
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/"+f, nil)
req.SetPathValue("file", f)
rec := httptest.NewRecorder()
handleGetDataFile(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: want 200, got %d (body: %s)", f, rec.Code, rec.Body.String())
continue
}
if got := rec.Body.String(); got != string(payload) {
t.Errorf("%s: want body %q, got %q", f, payload, got)
}
}
}
// ── firstExistingPath ─────────────────────────────────────────────────────────
func TestFirstExistingPath_ReturnsFirstMatch(t *testing.T) {
t.Parallel()
dir1 := t.TempDir()
dir2 := t.TempDir()
// Only dir2 has the file — should skip dir1 and return dir2.
if err := os.WriteFile(filepath.Join(dir2, "data.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
got := firstExistingPath([]string{
filepath.Join(dir1, "data.json"),
filepath.Join(dir2, "data.json"),
})
want := filepath.Join(dir2, "data.json")
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
func TestFirstExistingPath_PrefersEarlierCandidate(t *testing.T) {
t.Parallel()
dir1 := t.TempDir()
dir2 := t.TempDir()
// Both dirs have the file — should return dir1 (first in list).
for _, d := range []string{dir1, dir2} {
if err := os.WriteFile(filepath.Join(d, "data.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
}
got := firstExistingPath([]string{
filepath.Join(dir1, "data.json"),
filepath.Join(dir2, "data.json"),
})
want := filepath.Join(dir1, "data.json")
if got != want {
t.Fatalf("want %q (first match), got %q", want, got)
}
}
func TestFirstExistingPath_NoneExist(t *testing.T) {
t.Parallel()
dir := t.TempDir()
got := firstExistingPath([]string{
filepath.Join(dir, "absent1.json"),
filepath.Join(dir, "absent2.json"),
})
if got != "" {
t.Fatalf("want empty string, got %q", got)
}
}
func TestFirstExistingPath_EmptyCandidateList(t *testing.T) {
t.Parallel()
got := firstExistingPath(nil)
if got != "" {
t.Fatalf("want empty string, got %q", got)
}
}

View File

@@ -0,0 +1,191 @@
package main
import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
var (
sqlLineComment = regexp.MustCompile(`--[^\n]*`)
sqlBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
sqlReadOnlyRe = regexp.MustCompile(`^(select|explain|show|with)[\s(]`)
)
func isReadOnlySQL(sql string) bool {
s := sqlBlockComment.ReplaceAllString(sql, " ")
s = sqlLineComment.ReplaceAllString(s, " ")
s = strings.ToLower(strings.TrimSpace(s))
return sqlReadOnlyRe.MatchString(s)
}
// @Summary List all tables in the dune schema
// @Tags database
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/database/tables [get]
func handleDBTables(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdFetchTables().(msgTables)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
type tableOut struct {
Name string `json:"name"`
RowCount int64 `json:"row_count"`
}
rows := make([]tableOut, 0, len(msg.rows))
for _, r := range msg.rows {
rows = append(rows, tableOut(r))
}
jsonOK(w, rows)
}
// @Summary Describe columns of a table
// @Tags database
// @Produce json
// @Param table query string true "Table name"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/database/describe [get]
func handleDBDescribe(w http.ResponseWriter, r *http.Request) {
table := r.URL.Query().Get("table")
if table == "" {
jsonErr(w, fmt.Errorf("table required"), 400)
return
}
msg, ok := cmdDescribeTable(table)().(msgDescribe)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
type colOut struct {
Name string `json:"name"`
DataType string `json:"data_type"`
Nullable string `json:"nullable"`
}
cols := make([]colOut, 0, len(msg.cols))
for _, c := range msg.cols {
cols = append(cols, colOut(c))
}
jsonOK(w, map[string]any{"table": msg.table, "columns": cols})
}
// @Summary Return sample rows from a table
// @Tags database
// @Produce json
// @Param table query string true "Table name"
// @Param limit query int false "Number of rows to return (default 20, max 500)"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/database/sample [get]
func handleDBSample(w http.ResponseWriter, r *http.Request) {
table := r.URL.Query().Get("table")
if table == "" {
jsonErr(w, fmt.Errorf("table required"), 400)
return
}
limitStr := r.URL.Query().Get("limit")
limit, _ := strconv.Atoi(limitStr)
if limit <= 0 {
limit = 20
}
msg, ok := cmdSampleTable(table, limit)().(msgSample)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
jsonOK(w, map[string]any{
"table": msg.table,
"headers": msg.headers,
"rows": msg.rows,
})
}
// @Summary Search for a term across all table columns
// @Tags database
// @Produce json
// @Param term query string true "Search term"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/database/search [get]
func handleDBSearch(w http.ResponseWriter, r *http.Request) {
term := r.URL.Query().Get("term")
if term == "" {
jsonErr(w, fmt.Errorf("term required"), 400)
return
}
msg, ok := cmdSearchColumns(term)().(msgSearchCols)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
jsonOK(w, map[string]any{
"headers": msg.headers,
"rows": msg.rows,
})
}
// @Summary Execute a read-only SQL query (SELECT/EXPLAIN/SHOW only)
// @Tags database
// @Accept json
// @Produce json
// @Param body body object true "SQL query" SchemaExample({"sql": "SELECT 1"})
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/database/sql [post]
func handleDBSQL(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct {
SQL string `json:"sql"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, 400)
return
}
if req.SQL == "" {
jsonErr(w, fmt.Errorf("sql required"), 400)
return
}
if !isReadOnlySQL(req.SQL) {
jsonErr(w, fmt.Errorf("only SELECT, EXPLAIN, and SHOW statements are allowed"), 400)
return
}
msg, ok := cmdRunSQL(req.SQL)().(msgSQL)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
jsonOK(w, map[string]any{
"headers": msg.headers,
"rows": msg.rows,
"truncated": msg.truncated,
})
}

View File

@@ -0,0 +1,218 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
// dbBackupProviderOrErr guards the globals and asserts the control plane supports
// native DB backups, writing the appropriate error response if not.
func dbBackupProviderOrErr(w http.ResponseWriter) (dbBackupProvider, bool) {
if globalControl == nil || globalExecutor == nil {
jsonErr(w, fmt.Errorf("control plane not connected"), http.StatusServiceUnavailable)
return nil, false
}
prov, ok := globalControl.(dbBackupProvider)
if !ok {
jsonErr(w, fmt.Errorf("database backups are not supported by the %q control plane", globalControl.Name()),
http.StatusNotImplemented)
return nil, false
}
return prov, true
}
// gameServersRunning reports whether any game-server processes are live, used as
// the "battlegroup is stopped" guard for the destructive restore.
func gameServersRunning(ctx context.Context) (bool, error) {
st, err := globalControl.GetStatus(ctx, globalExecutor)
if err != nil {
return false, err
}
return len(st.Servers) > 0, nil
}
// verifyDumpFile sanity-checks that a freshly written backup is a non-empty
// pg_dump custom-format archive (magic "PGDMP"), so a silent failure (exit 0 but
// empty output) doesn't masquerade as a good backup.
func verifyDumpFile(path string) error {
f, err := os.Open(path) // #nosec G304 G703 -- path is dbBackupDir() + a timestamped name we generated
if err != nil {
return err
}
defer func() { _ = f.Close() }()
hdr := make([]byte, 5)
n, _ := io.ReadFull(f, hdr)
if n < 5 || string(hdr[:5]) != "PGDMP" {
return fmt.Errorf("not a pg_dump custom-format archive")
}
return nil
}
// @Summary List database backups
// @Tags db-backups
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/db-backups [get]
func handleDBBackupList(w http.ResponseWriter, _ *http.Request) {
files, err := listDBBackups()
if err != nil {
log.Printf("handleDBBackupList: %v", err)
jsonErr(w, fmt.Errorf("could not list backups"), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]any{"backups": files})
}
// @Summary Take a database backup now
// @Tags db-backups
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 501 {object} map[string]string
// @Router /api/v1/db-backups [post]
func handleDBBackupCreate(w http.ResponseWriter, _ *http.Request) {
prov, ok := dbBackupProviderOrErr(w)
if !ok {
return
}
dir, err := dbBackupDir()
if err != nil {
log.Printf("handleDBBackupCreate: %v", err)
jsonErr(w, fmt.Errorf("could not prepare backup dir"), http.StatusInternalServerError)
return
}
name := dbBackupFilename(time.Now())
dest := filepath.Join(dir, name)
out, err := prov.BackupDatabase(globalExecutor, dbBackupConn(), dest)
if err != nil {
log.Printf("handleDBBackupCreate: %v (%s)", err, out)
jsonErr(w, fmt.Errorf("backup failed"), http.StatusInternalServerError)
return
}
if err := verifyDumpFile(dest); err != nil {
_ = os.Remove(dest)
log.Printf("handleDBBackupCreate: invalid dump: %v", err)
jsonErr(w, fmt.Errorf("backup produced no valid archive"), http.StatusInternalServerError)
return
}
var size int64
if info, statErr := os.Stat(dest); statErr == nil {
size = info.Size()
}
jsonOK(w, map[string]any{"ok": "backup created", "name": name, "size_bytes": size})
}
// @Summary Download a database backup
// @Tags db-backups
// @Produce octet-stream
// @Param file query string true "backup filename"
// @Router /api/v1/db-backups/download [get]
func handleDBBackupDownload(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
if err := validateBackupName(name); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
dir, err := dbBackupDir()
if err != nil {
jsonErr(w, fmt.Errorf("backup dir unavailable"), http.StatusInternalServerError)
return
}
f, err := os.Open(filepath.Join(dir, name)) // #nosec G304 G703 -- name validated by validateBackupName (no separators/..)
if err != nil {
jsonErr(w, fmt.Errorf("backup not found"), http.StatusNotFound)
return
}
defer func() { _ = f.Close() }()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
if _, err := io.Copy(w, f); err != nil {
log.Printf("handleDBBackupDownload: %v", err)
}
}
// @Summary Delete a database backup
// @Tags db-backups
// @Produce json
// @Param file query string true "backup filename"
// @Router /api/v1/db-backups [delete]
func handleDBBackupDelete(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
if err := validateBackupName(name); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if err := deleteDBBackup(name); err != nil {
log.Printf("handleDBBackupDelete: %v", err)
jsonErr(w, fmt.Errorf("could not delete backup"), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]string{"ok": "backup deleted"})
}
// @Summary Restore the database from a backup (DESTRUCTIVE — battlegroup must be stopped)
// @Tags db-backups
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Router /api/v1/db-backups/restore [post]
func handleDBBackupRestore(w http.ResponseWriter, r *http.Request) {
var body struct {
File string `json:"file"`
Confirm bool `json:"confirm"`
}
if err := decode(r, &body); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if !body.Confirm {
jsonErr(w, fmt.Errorf("restore requires confirm=true"), http.StatusBadRequest)
return
}
if err := validateBackupName(body.File); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
prov, ok := dbBackupProviderOrErr(w)
if !ok {
return
}
// Destructive-op guard: refuse while the game is live — pg_restore --clean
// over a running server would corrupt in-flight state.
running, err := gameServersRunning(r.Context())
if err != nil {
log.Printf("handleDBBackupRestore: status check: %v", err)
jsonErr(w, fmt.Errorf("could not verify the battlegroup is stopped"), http.StatusInternalServerError)
return
}
if running {
jsonErr(w, fmt.Errorf("stop the battlegroup before restoring — game servers are running"),
http.StatusConflict)
return
}
dir, err := dbBackupDir()
if err != nil {
jsonErr(w, fmt.Errorf("backup dir unavailable"), http.StatusInternalServerError)
return
}
src := filepath.Join(dir, body.File)
if _, err := os.Stat(src); err != nil {
jsonErr(w, fmt.Errorf("backup not found"), http.StatusNotFound)
return
}
out, err := prov.RestoreDatabase(globalExecutor, dbBackupConn(), src)
if err != nil {
log.Printf("handleDBBackupRestore: %v (%s)", err, out)
jsonErr(w, fmt.Errorf("restore failed"), http.StatusInternalServerError)
return
}
invalidateAllJourneyCache() // the database was replaced under us
jsonOK(w, map[string]string{"ok": "database restored", "output": out})
}

View File

@@ -0,0 +1,44 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestHandleDBBackupRestore_RequiresConfirm verifies the destructive restore
// endpoint rejects a request without confirm=true before doing anything else.
func TestHandleDBBackupRestore_RequiresConfirm(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/db-backups/restore",
strings.NewReader(`{"file":"dune-x.dump","confirm":false}`))
handleDBBackupRestore(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("restore without confirm: code = %d, want 400", rec.Code)
}
}
// TestHandleDBBackupCreate_NoControl verifies a 503 when no control plane is
// connected (globals nil).
func TestHandleDBBackupCreate_NoControl(t *testing.T) {
prevC, prevE := globalControl, globalExecutor
t.Cleanup(func() { globalControl, globalExecutor = prevC, prevE })
globalControl, globalExecutor = nil, nil
rec := httptest.NewRecorder()
handleDBBackupCreate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/db-backups", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("create with no control: code = %d, want 503", rec.Code)
}
}
// TestHandleDBBackupDownload_BadName verifies path-traversal / bad names are rejected.
func TestHandleDBBackupDownload_BadName(t *testing.T) {
rec := httptest.NewRecorder()
handleDBBackupDownload(rec, httptest.NewRequest(http.MethodGet,
"/api/v1/db-backups/download?file=../../etc/passwd", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("download traversal: code = %d, want 400", rec.Code)
}
}

View File

@@ -0,0 +1,228 @@
package main
import (
"fmt"
"log"
"net/http"
"strings"
)
// directorConfigStore is implemented by control planes that expose the
// Battlegroup Director config file (AMP). Reads return the file path + content;
// writes rewrite it. Under AMP the target is $STATE/director_config.ini, which
// prestart.sh seeds from the Funcom template only when absent and then copies
// into the runtime conf dir on EVERY start ("so director picks up any admin
// edits") — so edits persist and take effect on the next instance restart.
type directorConfigStore interface {
readDirectorConfig(exec Executor) (path, content string, err error)
writeDirectorConfig(exec Executor, content string) (path string, err error)
}
// directorReadOnlySections are infrastructure wiring: launched values are
// overridden by env/CLI in start-director.sh (Database_*, --RMQ*Hostname) and/or
// contain secrets, so editing them in the ini has no effect — surfaced read-only.
var directorReadOnlySections = map[string]bool{
"Database": true, "RMQAdmin": true, "RMQGame": true,
}
func isDirectorSecretKey(key string) bool {
k := strings.ToLower(key)
return strings.Contains(k, "password") || strings.Contains(k, "secret") || strings.Contains(k, "token")
}
type directorKV struct {
Key string `json:"key"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
Secret bool `json:"secret,omitempty"`
}
type directorSection struct {
Name string `json:"name"`
ReadOnly bool `json:"read_only"`
Lines []directorKV `json:"lines"`
}
// parseDirectorINI parses director_config.ini into ordered sections, splitting
// each "key = value ;; comment" line into its parts. Section headers look like
// "[ Battlegroup ]". Whole-line comments (';', '#') and blanks are skipped.
// Secret values are blanked so they never reach the client.
func parseDirectorINI(content string) []directorSection {
sections := []directorSection{}
cur := -1
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
name := strings.TrimSpace(line[1 : len(line)-1])
sections = append(sections, directorSection{Name: name, ReadOnly: directorReadOnlySections[name], Lines: []directorKV{}})
cur = len(sections) - 1
continue
}
if cur < 0 {
continue
}
eq := strings.Index(line, "=")
if eq <= 0 {
continue
}
kv := splitDirectorLine(line, eq)
sections[cur].Lines = append(sections[cur].Lines, kv)
}
return sections
}
// inlineCommentStart returns the index of the first inline comment delimiter
// in s, or -1 if none. Recognises ";;" (double-semicolon), " ; " (single
// semicolon padded with spaces), and " : " (colon padded with spaces).
func inlineCommentStart(s string) int {
best := -1
for _, delim := range []string{";;", " ; ", " : "} {
if c := strings.Index(s, delim); c >= 0 && (best < 0 || c < best) {
best = c
}
}
return best
}
func splitDirectorLine(line string, eq int) directorKV {
key := strings.TrimSpace(line[:eq])
value, comment := line[eq+1:], ""
if c := inlineCommentStart(value); c >= 0 {
comment = strings.TrimSpace(strings.TrimLeft(value[c:], ";: "))
value = value[:c]
}
kv := directorKV{Key: key, Value: strings.TrimSpace(value), Comment: comment, Secret: isDirectorSecretKey(key)}
if kv.Secret {
kv.Value = ""
}
return kv
}
// rewriteDirectorLine replaces the value in a "key=value [comment]" line while
// preserving any inline comment and its original delimiter verbatim.
func rewriteDirectorLine(raw string, eq int, newVal string) string {
afterEq := raw[eq+1:]
comment := ""
if c := inlineCommentStart(afterEq); c >= 0 {
start := c
if start > 0 && afterEq[start-1] == ' ' {
start--
}
comment = afterEq[start:]
}
return raw[:eq+1] + newVal + comment
}
// applyDirectorEdits rewrites the file, replacing the value of edited keys within
// their section while preserving the key part, inline comments, ordering,
// and all non-edited lines. edits is section -> key -> new value. Read-only
// sections and secret keys are never written (double-guarded).
func applyDirectorEdits(content string, edits map[string]map[string]string) string {
lines := strings.Split(content, "\n")
curSec := ""
for i, raw := range lines {
trimmed := strings.TrimSpace(raw)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
curSec = strings.TrimSpace(trimmed[1 : len(trimmed)-1])
continue
}
secEdits, ok := edits[curSec]
if !ok || directorReadOnlySections[curSec] || trimmed == "" ||
strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#") {
continue
}
eq := strings.Index(raw, "=")
if eq <= 0 {
continue
}
key := strings.TrimSpace(raw[:eq])
newVal, has := secEdits[key]
if !has || isDirectorSecretKey(key) {
continue
}
lines[i] = rewriteDirectorLine(raw, eq, newVal)
}
return strings.Join(lines, "\n")
}
func directorStore() (directorConfigStore, bool) {
if globalControl == nil || globalExecutor == nil {
return nil, false
}
store, ok := globalControl.(directorConfigStore)
return store, ok
}
// @Summary Read the Battlegroup Director config (AMP only)
// @Tags director
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 501 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/director-config [get]
func handleGetDirectorConfig(w http.ResponseWriter, _ *http.Request) {
if globalControl == nil || globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), http.StatusServiceUnavailable)
return
}
store, ok := directorStore()
if !ok {
jsonErr(w, fmt.Errorf("director config is only available on the AMP control plane"), http.StatusNotImplemented)
return
}
path, content, err := store.readDirectorConfig(globalExecutor)
if err != nil {
log.Printf("handleGetDirectorConfig: %v", err)
jsonErr(w, fmt.Errorf("could not read director config"), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]any{"path": path, "sections": parseDirectorINI(content)})
}
// @Summary Update the Battlegroup Director config (AMP only)
// @Tags director
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 501 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/director-config [put]
func handleUpdateDirectorConfig(w http.ResponseWriter, r *http.Request) {
if globalControl == nil || globalExecutor == nil {
jsonErr(w, fmt.Errorf("not connected"), http.StatusServiceUnavailable)
return
}
store, ok := directorStore()
if !ok {
jsonErr(w, fmt.Errorf("director config is only available on the AMP control plane"), http.StatusNotImplemented)
return
}
var body struct {
Updates map[string]map[string]string `json:"updates"`
}
if err := decode(r, &body); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if len(body.Updates) == 0 {
jsonErr(w, fmt.Errorf("no updates provided"), http.StatusBadRequest)
return
}
_, content, err := store.readDirectorConfig(globalExecutor)
if err != nil {
log.Printf("handleUpdateDirectorConfig: read: %v", err)
jsonErr(w, fmt.Errorf("could not read director config"), http.StatusInternalServerError)
return
}
path, err := store.writeDirectorConfig(globalExecutor, applyDirectorEdits(content, body.Updates))
if err != nil {
log.Printf("handleUpdateDirectorConfig: write: %v", err)
jsonErr(w, fmt.Errorf("could not write director config"), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]string{"ok": "director config updated — restart the server to apply", "path": path})
}

View File

@@ -0,0 +1,204 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const sampleDirectorINI = `[ Database ]
address=localhost
password=seabass
[ Battlegroup ]
DbFetchInterval=5 ;; seconds between fetch
ForceIsWorldClosed=false ;; override db value
[ InstancingModes ]
Survival_1=Dimension
DeepDesert_1=ClassicalInstancing
`
// inlineCommentStart tests
func TestInlineCommentStart(t *testing.T) {
t.Parallel()
tests := []struct {
s string
want int
}{
{"false ;; some comment", 6}, // ;; starts at 6 (after "false ")
{"false ; some comment", 5}, // " ; " starts at 5 (the leading space)
{"false : some comment", 5}, // " : " starts at 5 (the leading space)
{"value", -1}, // no comment
{"5", -1}, // no comment
{"ClassicalInstancing", -1}, // no comment
{" ;; leading space delim", 1}, // ;; starts at 1 (after the leading space)
}
for _, tt := range tests {
got := inlineCommentStart(tt.s)
if got != tt.want {
t.Errorf("inlineCommentStart(%q) = %d, want %d", tt.s, got, tt.want)
}
}
}
// splitDirectorLine tests for alternative comment delimiters
func TestSplitDirectorLine_Delimiters(t *testing.T) {
t.Parallel()
tests := []struct {
line string
wantKey string
wantValue string
wantComment string
}{
{
// existing double-semicolon style
line: "DbFetchInterval=5 ;; seconds between fetch",
wantKey: "DbFetchInterval",
wantValue: "5",
wantComment: "seconds between fetch",
},
{
// single semicolon with spaces (Funcom style)
line: "KeepPartiesTogether=false ; Remove when PlayerHardCap is changed to > 1",
wantKey: "KeepPartiesTogether",
wantValue: "false",
wantComment: "Remove when PlayerHardCap is changed to > 1",
},
{
// colon delimiter
line: "KeepPartiesTogether=false : Remove when PlayerHardCap is changed to > 1",
wantKey: "KeepPartiesTogether",
wantValue: "false",
wantComment: "Remove when PlayerHardCap is changed to > 1",
},
{
// no comment
line: "Survival_1=Dimension",
wantKey: "Survival_1",
wantValue: "Dimension",
},
}
for _, tt := range tests {
eq := strings.Index(tt.line, "=")
kv := splitDirectorLine(tt.line, eq)
if kv.Key != tt.wantKey || kv.Value != tt.wantValue || kv.Comment != tt.wantComment {
t.Errorf("splitDirectorLine(%q) = {Key:%q Value:%q Comment:%q}, want {Key:%q Value:%q Comment:%q}",
tt.line, kv.Key, kv.Value, kv.Comment, tt.wantKey, tt.wantValue, tt.wantComment)
}
}
}
// applyDirectorEdits must preserve the original comment delimiter verbatim
func TestApplyDirectorEdits_Delimiters(t *testing.T) {
t.Parallel()
ini := `[ Battlegroup ]
KeepPartiesTogether=false : Remove when PlayerHardCap is changed to > 1
MaxPlayersPerParty=10 ; max players
NoComment=old
`
edits := map[string]map[string]string{
"Battlegroup": {
"KeepPartiesTogether": "true",
"MaxPlayersPerParty": "20",
"NoComment": "new",
},
}
out := applyDirectorEdits(ini, edits)
if !strings.Contains(out, "KeepPartiesTogether=true : Remove when PlayerHardCap is changed to > 1") {
t.Errorf("colon delimiter not preserved:\n%s", out)
}
if !strings.Contains(out, "MaxPlayersPerParty=20 ; max players") {
t.Errorf("semicolon delimiter not preserved:\n%s", out)
}
if !strings.Contains(out, "NoComment=new") {
t.Errorf("no-comment edit not applied:\n%s", out)
}
}
func findDirectorSection(secs []directorSection, name string) *directorSection {
for i := range secs {
if secs[i].Name == name {
return &secs[i]
}
}
return nil
}
func findDirectorKV(sec *directorSection, key string) *directorKV {
if sec == nil {
return nil
}
for i := range sec.Lines {
if sec.Lines[i].Key == key {
return &sec.Lines[i]
}
}
return nil
}
func TestParseDirectorINI(t *testing.T) {
t.Parallel()
secs := parseDirectorINI(sampleDirectorINI)
db := findDirectorSection(secs, "Database")
if db == nil || !db.ReadOnly {
t.Fatalf("Database section missing or not read-only: %+v", db)
}
if pw := findDirectorKV(db, "password"); pw == nil || !pw.Secret || pw.Value != "" {
t.Errorf("password should be secret + blanked, got %+v", pw)
}
bg := findDirectorSection(secs, "Battlegroup")
if bg == nil || bg.ReadOnly {
t.Fatalf("Battlegroup section missing or wrongly read-only")
}
fetch := findDirectorKV(bg, "DbFetchInterval")
if fetch == nil || fetch.Value != "5" || fetch.Comment != "seconds between fetch" {
t.Errorf("DbFetchInterval parse wrong: %+v", fetch)
}
im := findDirectorSection(secs, "InstancingModes")
if dd := findDirectorKV(im, "DeepDesert_1"); dd == nil || dd.Value != "ClassicalInstancing" {
t.Errorf("InstancingModes DeepDesert_1 parse wrong: %+v", dd)
}
}
func TestApplyDirectorEdits(t *testing.T) {
t.Parallel()
edits := map[string]map[string]string{
"Battlegroup": {"DbFetchInterval": "10"},
"InstancingModes": {"DeepDesert_1": "Dimension"},
"Database": {"address": "evil-host", "password": "hacked"}, // read-only + secret → ignored
}
out := applyDirectorEdits(sampleDirectorINI, edits)
if !strings.Contains(out, "DbFetchInterval=10 ;; seconds between fetch") {
t.Errorf("edited value/comment not preserved:\n%s", out)
}
if !strings.Contains(out, "DeepDesert_1=Dimension") || strings.Contains(out, "DeepDesert_1=ClassicalInstancing") {
t.Errorf("InstancingModes edit not applied:\n%s", out)
}
if !strings.Contains(out, "address=localhost") || strings.Contains(out, "evil-host") {
t.Errorf("read-only Database section must NOT be edited:\n%s", out)
}
if !strings.Contains(out, "password=seabass") || strings.Contains(out, "hacked") {
t.Errorf("secret/read-only key must NOT be edited:\n%s", out)
}
}
func TestHandleGetDirectorConfig_NotConnected(t *testing.T) {
origC, origE := globalControl, globalExecutor
globalControl, globalExecutor = nil, nil
defer func() { globalControl, globalExecutor = origC, origE }()
req := httptest.NewRequest(http.MethodGet, "/api/v1/director-config", nil)
rr := httptest.NewRecorder()
handleGetDirectorConfig(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}

View File

@@ -0,0 +1,75 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
// givePacksConfigResponse is the shape of the GET and PUT /give-packs/config
// endpoints. The whole pack library is transferred in one payload.
type givePacksConfigResponse struct {
Packs []givePack `json:"packs"`
}
// handleGetGivePacksConfig returns the current operator-configured pack library.
// When the store has no row (first boot after seeding was skipped or failed),
// it returns an empty list rather than erroring.
func handleGetGivePacksConfig(w http.ResponseWriter, _ *http.Request) {
if givePacksStoreDB == nil {
jsonErr(w, fmt.Errorf("give-packs store not available"), http.StatusServiceUnavailable)
return
}
_, packsJSON, ok, err := givePacksStoreDB.loadConfig()
if err != nil {
log.Printf("handleGetGivePacksConfig: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
packs := make([]givePack, 0)
if ok && packsJSON != "" && packsJSON != "null" {
if err := json.Unmarshal([]byte(packsJSON), &packs); err != nil {
log.Printf("handleGetGivePacksConfig: unmarshal packs: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
}
jsonOK(w, givePacksConfigResponse{Packs: packs})
}
// handlePutGivePacksConfig replaces the operator's pack library. Validates the
// incoming packs and persists with base_packs_loaded=true so startup never
// re-seeds (requirement: deleting all packs must stay empty).
func handlePutGivePacksConfig(w http.ResponseWriter, r *http.Request) {
if givePacksStoreDB == nil {
jsonErr(w, fmt.Errorf("give-packs store not available"), http.StatusServiceUnavailable)
return
}
var req givePacksConfigResponse
if err := decode(r, &req); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if req.Packs == nil {
req.Packs = []givePack{}
}
if err := validateGivePacks(req.Packs); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
packsJSON, err := json.Marshal(req.Packs)
if err != nil {
log.Printf("handlePutGivePacksConfig: marshal: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
// Always persist with base_packs_loaded=true — this is a deliberate operator
// action, including an explicit empty list.
if err := givePacksStoreDB.saveConfig(string(packsJSON), true); err != nil {
log.Printf("handlePutGivePacksConfig: save: %v", err)
jsonErr(w, fmt.Errorf("failed to save packs"), http.StatusInternalServerError)
return
}
jsonOK(w, givePacksConfigResponse{Packs: req.Packs})
}

View File

@@ -0,0 +1,210 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// ── handleGetGivePacksConfig ─────────────────────────────────────────────────
func TestHandleGetGivePacksConfig_NilStore503(t *testing.T) {
givePacksStoreDB = nil
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
rec := httptest.NewRecorder()
handleGetGivePacksConfig(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetGivePacksConfig_EmptyStore(t *testing.T) {
setupGivePacksStore(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
rec := httptest.NewRecorder()
handleGetGivePacksConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp givePacksConfigResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Packs == nil {
t.Error("expected non-nil packs slice (empty), got nil")
}
if len(resp.Packs) != 0 {
t.Errorf("expected empty packs on fresh store, got %d", len(resp.Packs))
}
}
func TestHandleGetGivePacksConfig_ReturnsSavedPacks(t *testing.T) {
s := setupGivePacksStore(t)
packs := []givePack{
{ID: "starter-t1", Name: "T1", Category: "Starter", Tier: 1, Items: []welcomePackageItem{
{Template: "Ammo", Qty: 500, Quality: 0},
}},
}
packsJSON, _ := json.Marshal(packs)
if err := s.saveConfig(string(packsJSON), true); err != nil {
t.Fatalf("saveConfig: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
rec := httptest.NewRecorder()
handleGetGivePacksConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp givePacksConfigResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Packs) != 1 {
t.Fatalf("expected 1 pack, got %d", len(resp.Packs))
}
if resp.Packs[0].ID != "starter-t1" {
t.Errorf("expected id=starter-t1, got %q", resp.Packs[0].ID)
}
}
// ── handlePutGivePacksConfig ─────────────────────────────────────────────────
func TestHandlePutGivePacksConfig_NilStore503(t *testing.T) {
givePacksStoreDB = nil
body, _ := json.Marshal(givePacksConfigResponse{Packs: []givePack{}})
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
rec := httptest.NewRecorder()
handlePutGivePacksConfig(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandlePutGivePacksConfig_BadBody400(t *testing.T) {
setupGivePacksStore(t)
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader([]byte("not-json")))
rec := httptest.NewRecorder()
handlePutGivePacksConfig(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandlePutGivePacksConfig_ValidationError400(t *testing.T) {
setupGivePacksStore(t)
// Duplicate id → validation fails.
badPacks := givePacksConfigResponse{Packs: []givePack{
{ID: "dup", Name: "A", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}},
{ID: "dup", Name: "B", Category: "Y", Tier: 2, Items: []welcomePackageItem{{Template: "B", Qty: 1, Quality: 0}}},
}}
body, _ := json.Marshal(badPacks)
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
rec := httptest.NewRecorder()
handlePutGivePacksConfig(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandlePutGivePacksConfig_PersistsAndReturns(t *testing.T) {
setupGivePacksStore(t)
packs := givePacksConfigResponse{Packs: []givePack{
{ID: "buggy-t6", Name: "T6", Category: "Buggy", Tier: 6, Items: []welcomePackageItem{
{Template: "BuggyBoost_6", Qty: 1, Quality: 0},
}},
}}
body, _ := json.Marshal(packs)
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
rec := httptest.NewRecorder()
handlePutGivePacksConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp givePacksConfigResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
if len(resp.Packs) != 1 || resp.Packs[0].ID != "buggy-t6" {
t.Fatalf("unexpected response packs: %+v", resp.Packs)
}
}
func TestHandlePutGivePacksConfig_RoundTrip(t *testing.T) {
setupGivePacksStore(t)
putPacks := givePacksConfigResponse{Packs: []givePack{
{ID: "scout-t3", Name: "T3", Category: "Scout", Tier: 3, Items: []welcomePackageItem{
{Template: "ScoutPart_3", Qty: 2, Quality: 0},
}},
}}
body, _ := json.Marshal(putPacks)
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
putRec := httptest.NewRecorder()
handlePutGivePacksConfig(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("PUT: want 200, got %d: %s", putRec.Code, putRec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
getRec := httptest.NewRecorder()
handleGetGivePacksConfig(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET: want 200, got %d: %s", getRec.Code, getRec.Body.String())
}
var got givePacksConfigResponse
if err := json.NewDecoder(getRec.Body).Decode(&got); err != nil {
t.Fatalf("decode GET: %v", err)
}
if len(got.Packs) != 1 || got.Packs[0].ID != "scout-t3" {
t.Fatalf("GET after PUT returned wrong packs: %+v", got.Packs)
}
}
func TestHandlePutGivePacksConfig_EmptyPacksNoReSeed(t *testing.T) {
// Deleting all packs (empty PUT) must NOT trigger re-seed on the next GET.
s := setupGivePacksStore(t)
// Pre-seed with some data.
if err := s.saveConfig(`[{"id":"x","name":"X","category":"X","tier":1,"items":[]}]`, true); err != nil {
t.Fatalf("pre-seed: %v", err)
}
// PUT empty packs.
emptyBody, _ := json.Marshal(givePacksConfigResponse{Packs: []givePack{}})
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(emptyBody))
putRec := httptest.NewRecorder()
handlePutGivePacksConfig(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("PUT empty: want 200, got %d: %s", putRec.Code, putRec.Body.String())
}
// GET must return empty, no re-seed.
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
getRec := httptest.NewRecorder()
handleGetGivePacksConfig(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET: want 200, got %d: %s", getRec.Code, getRec.Body.String())
}
var got givePacksConfigResponse
if err := json.NewDecoder(getRec.Body).Decode(&got); err != nil {
t.Fatalf("decode GET: %v", err)
}
if len(got.Packs) != 0 {
t.Fatalf("expected 0 packs after empty PUT, got %d (re-seed must NOT happen)", len(got.Packs))
}
}

View File

@@ -0,0 +1,192 @@
package main
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
)
var errEmptyGuildName = errors.New("guild name must not be empty")
// @Summary List all guilds with member count + faction name
// @Tags guilds
// @Produce json
// @Success 200 {array} guildSummary
// @Failure 500 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/guilds [get]
func handleListGuilds(w http.ResponseWriter, r *http.Request) {
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
guilds, err := cmdFetchGuilds(r.Context(), globalDB)
if err != nil {
log.Printf("handleListGuilds: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
jsonOK(w, guilds)
}
// @Summary Get one guild with its members and pending invites
// @Tags guilds
// @Produce json
// @Param id path int true "Guild ID"
// @Success 200 {object} guildDetail
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/guilds/{id} [get]
func handleGetGuild(w http.ResponseWriter, r *http.Request) {
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
return
}
detail, err := cmdFetchGuildDetail(r.Context(), globalDB, id)
if err != nil {
if errors.Is(err, errGuildNotFound) {
jsonErr(w, fmt.Errorf("guild not found"), http.StatusNotFound)
return
}
log.Printf("handleGetGuild: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
jsonOK(w, detail)
}
// applyGuildUpdate applies the provided (optional) name/description edits. Returns
// sentinel errors (errEmptyGuildName / errGuildNameTaken / errGuildNotFound) that
// the handler maps to HTTP statuses.
func applyGuildUpdate(r *http.Request, id int64, name, desc *string) error {
if desc != nil {
if err := cmdEditGuildDescription(r.Context(), globalDB, id, *desc); err != nil {
return err
}
}
if name != nil {
n := strings.TrimSpace(*name)
if n == "" {
return errEmptyGuildName
}
if err := cmdEditGuildName(r.Context(), globalDB, id, n); err != nil {
return err
}
}
return nil
}
func writeGuildUpdateErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, errEmptyGuildName):
jsonErr(w, fmt.Errorf("guild name must not be empty"), http.StatusBadRequest)
case errors.Is(err, errGuildNameTaken):
jsonErr(w, fmt.Errorf("guild name already taken"), http.StatusConflict)
case errors.Is(err, errGuildNotFound):
jsonErr(w, fmt.Errorf("guild not found"), http.StatusNotFound)
default:
log.Printf("handleUpdateGuild: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
}
}
// @Summary Edit a guild's name and/or description
// @Tags guilds
// @Accept json
// @Produce json
// @Param id path int true "Guild ID"
// @Success 200 {object} guildDetail
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/guilds/{id} [patch]
func handleUpdateGuild(w http.ResponseWriter, r *http.Request) {
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := decode(r, &body); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if body.Name == nil && body.Description == nil {
jsonErr(w, fmt.Errorf("nothing to update"), http.StatusBadRequest)
return
}
if err := applyGuildUpdate(r, id, body.Name, body.Description); err != nil {
writeGuildUpdateErr(w, err)
return
}
detail, err := cmdFetchGuildDetail(r.Context(), globalDB, id)
if err != nil {
writeGuildUpdateErr(w, err)
return
}
jsonOK(w, detail)
}
// @Summary Set a guild member's role (50 = member, 100 = admin)
// @Tags guilds
// @Accept json
// @Produce json
// @Param id path int true "Guild ID"
// @Param pid path int true "Member player (actor) ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/guilds/{id}/members/{pid}/role [put]
func handleSetGuildMemberRole(w http.ResponseWriter, r *http.Request) {
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
return
}
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
if err != nil {
jsonErr(w, fmt.Errorf("invalid player id"), http.StatusBadRequest)
return
}
var body struct {
Role int16 `json:"role"`
}
if err := decode(r, &body); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if body.Role != guildRoleMember && body.Role != guildRoleAdmin {
jsonErr(w, fmt.Errorf("role must be %d (member) or %d (admin)", guildRoleMember, guildRoleAdmin), http.StatusBadRequest)
return
}
if err := cmdSetGuildMemberRole(r.Context(), globalDB, id, pid, body.Role); err != nil {
// The game procs raise on invalid transitions (e.g. demoting the sitting
// admin). Surface a hint; log the detail.
log.Printf("handleSetGuildMemberRole: %v", err)
jsonErr(w, fmt.Errorf("role change rejected — to change the admin, promote another member to admin first"), http.StatusBadRequest)
return
}
jsonOK(w, map[string]string{"ok": "role updated"})
}

View File

@@ -0,0 +1,125 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGuildMemberDisplayName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
charName string
actorID int64
want string
}{
{"resolved name passes through", "Paul Atreides", 123, "Paul Atreides"},
{"empty name falls back to actor id", "", 456, "Actor 456"},
{"whitespace-only name falls back", " ", 789, "Actor 789"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := guildMemberDisplayName(tt.charName, tt.actorID); got != tt.want {
t.Fatalf("guildMemberDisplayName(%q, %d) = %q, want %q", tt.charName, tt.actorID, got, tt.want)
}
})
}
}
func TestHandleListGuilds_DBNil(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds", nil)
rr := httptest.NewRecorder()
handleListGuilds(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}
func TestHandleGetGuild_DBNil(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds/42", nil)
req.SetPathValue("id", "42")
rr := httptest.NewRecorder()
handleGetGuild(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}
// Mirrors the project convention (see handlers_stats_test.go): the globalDB
// nil-guard is checked before the id parse, so a bad id with no DB returns 503,
// not 400.
func TestHandleGetGuild_InvalidID_DBNilFirst(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds/not-a-number", nil)
req.SetPathValue("id", "not-a-number")
rr := httptest.NewRecorder()
handleGetGuild(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503 (db nil checked before id parse), got %d", rr.Code)
}
}
func TestGuildRoleSetProc(t *testing.T) {
t.Parallel()
// Setting a member to admin (100) must route through promote_guild_member
// (which transfers the single admin slot); any lower role uses
// demote_guild_member (which guards against demoting the current admin).
cases := map[int16]string{
guildRoleAdmin: "promote_guild_member",
guildRoleMember: "demote_guild_member",
75: "demote_guild_member",
}
for role, want := range cases {
if got := guildRoleSetProc(role); got != want {
t.Errorf("guildRoleSetProc(%d) = %q, want %q", role, got, want)
}
}
}
func TestHandleUpdateGuild_DBNil(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodPatch, "/api/v1/guilds/1", nil)
req.SetPathValue("id", "1")
rr := httptest.NewRecorder()
handleUpdateGuild(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}
func TestHandleSetGuildMemberRole_DBNil(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodPut, "/api/v1/guilds/1/members/2/role", nil)
req.SetPathValue("id", "1")
req.SetPathValue("pid", "2")
rr := httptest.NewRecorder()
handleSetGuildMemberRole(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}

View File

@@ -0,0 +1,28 @@
package main
import (
"fmt"
"log"
"net/http"
)
// @Summary Landsraad overview — latest term, decree catalogue, and task board
// @Tags landsraad
// @Produce json
// @Success 200 {object} landsraadOverview
// @Failure 500 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/landsraad [get]
func handleGetLandsraad(w http.ResponseWriter, r *http.Request) {
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
ov, err := cmdFetchLandsraad(r.Context(), globalDB)
if err != nil {
log.Printf("handleGetLandsraad: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
jsonOK(w, ov)
}

View File

@@ -0,0 +1,40 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLandsraadHouseName(t *testing.T) {
t.Parallel()
tests := []struct{ name, in, want string }{
{"strips DA_House prefix", "DA_HouseHagal", "Hagal"},
{"strips prefix Moritani", "DA_HouseMoritani", "Moritani"},
{"unprefixed passes through", "Corrino", "Corrino"},
{"empty stays empty", "", ""},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := landsraadHouseName(tt.in); got != tt.want {
t.Fatalf("landsraadHouseName(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestHandleGetLandsraad_DBNil(t *testing.T) {
orig := globalDB
globalDB = nil
defer func() { globalDB = orig }()
req := httptest.NewRequest(http.MethodGet, "/api/v1/landsraad", nil)
rr := httptest.NewRecorder()
handleGetLandsraad(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rr.Code)
}
}

View File

@@ -0,0 +1,187 @@
package main
import (
"fmt"
"net/http"
"strings"
)
// resolveLocation looks up a named location from the editable store (or the
// compile-time cheatLocations fallback when the store is unavailable).
// Returns an error suitable for a 400 response if the name is unknown.
func resolveLocation(name string) (teleportLocation, error) {
if globalLocationStore != nil {
locs, err := globalLocationStore.list()
if err == nil {
for _, l := range locs {
if l.Name == name {
return l, nil
}
}
return teleportLocation{}, fmt.Errorf("unknown location: %s", name)
}
}
// Fallback to compile-time seeds.
for _, l := range cheatLocations {
if l.Name == name {
return l, nil
}
}
return teleportLocation{}, fmt.Errorf("unknown location: %s", name)
}
// globalLocationStore holds the open SQLite location store. Set once in
// main.go alongside globalSessionDB; nil when the store failed to open.
var globalLocationStore *locationStore
// @Summary List all saved teleport/spawn locations
// @Tags locations
// @Produce json
// @Success 200 {array} teleportLocation
// @Failure 503 {object} map[string]string
// @Router /api/v1/locations [get]
// GET /api/v1/locations
func handleListLocations(w http.ResponseWriter, _ *http.Request) {
if globalLocationStore == nil {
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
return
}
locs, err := globalLocationStore.list()
if err != nil {
jsonErr(w, fmt.Errorf("list locations: %w", err), http.StatusInternalServerError)
return
}
jsonOK(w, locs)
}
// @Summary Add or update a named teleport/spawn location
// @Tags locations
// @Accept json
// @Produce json
// @Param body body object true "name, x, y, z"
// @Success 200 {array} teleportLocation
// @Failure 400 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/locations [post]
// POST /api/v1/locations
func handleUpsertLocation(w http.ResponseWriter, r *http.Request) {
if globalLocationStore == nil {
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
return
}
var req struct {
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
jsonErr(w, fmt.Errorf("name required"), http.StatusBadRequest)
return
}
if err := globalLocationStore.upsert(req.Name, req.X, req.Y, req.Z); err != nil {
jsonErr(w, err, http.StatusInternalServerError)
return
}
locs, err := globalLocationStore.list()
if err != nil {
jsonErr(w, fmt.Errorf("list after upsert: %w", err), http.StatusInternalServerError)
return
}
jsonOK(w, locs)
}
// @Summary Rename an existing location
// @Tags locations
// @Accept json
// @Produce json
// @Param body body object true "old_name, new_name"
// @Success 200 {array} teleportLocation
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/locations [put]
// PUT /api/v1/locations
func handleRenameLocation(w http.ResponseWriter, r *http.Request) {
if globalLocationStore == nil {
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
return
}
var req struct {
OldName string `json:"old_name"`
NewName string `json:"new_name"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
req.OldName = strings.TrimSpace(req.OldName)
req.NewName = strings.TrimSpace(req.NewName)
if req.OldName == "" || req.NewName == "" {
jsonErr(w, fmt.Errorf("old_name and new_name required"), http.StatusBadRequest)
return
}
if err := globalLocationStore.rename(req.OldName, req.NewName); err != nil {
if strings.Contains(err.Error(), "not found") {
jsonErr(w, err, http.StatusNotFound)
return
}
jsonErr(w, err, http.StatusInternalServerError)
return
}
locs, err := globalLocationStore.list()
if err != nil {
jsonErr(w, fmt.Errorf("list after rename: %w", err), http.StatusInternalServerError)
return
}
jsonOK(w, locs)
}
// @Summary Delete a named location
// @Tags locations
// @Accept json
// @Produce json
// @Param body body object true "name"
// @Success 200 {array} teleportLocation
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/locations [delete]
// DELETE /api/v1/locations
func handleDeleteLocation(w http.ResponseWriter, r *http.Request) {
if globalLocationStore == nil {
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
return
}
var req struct {
Name string `json:"name"`
}
if err := decode(r, &req); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
jsonErr(w, fmt.Errorf("name required"), http.StatusBadRequest)
return
}
if err := globalLocationStore.delete(req.Name); err != nil {
if strings.Contains(err.Error(), "not found") {
jsonErr(w, err, http.StatusNotFound)
return
}
jsonErr(w, err, http.StatusInternalServerError)
return
}
locs, err := globalLocationStore.list()
if err != nil {
jsonErr(w, fmt.Errorf("list after delete: %w", err), http.StatusInternalServerError)
return
}
jsonOK(w, locs)
}

View File

@@ -0,0 +1,227 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// setupLocationStore sets globalLocationStore to a fresh in-memory store and
// restores nil on cleanup. NOT parallel — mutates package global.
func setupLocationStore(t *testing.T) *locationStore {
t.Helper()
s := openMemLocationStore(t)
globalLocationStore = s
t.Cleanup(func() { globalLocationStore = nil })
return s
}
// ── nil-guard tests (globalLocationStore == nil) ─────────────────────────────
func TestHandleListLocations_NilStore(t *testing.T) {
globalLocationStore = nil
req := httptest.NewRequest(http.MethodGet, "/api/v1/locations", nil)
rec := httptest.NewRecorder()
handleListLocations(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rec.Code)
}
}
func TestHandleUpsertLocation_NilStore(t *testing.T) {
globalLocationStore = nil
body, _ := json.Marshal(map[string]any{"name": "X", "x": 1.0, "y": 2.0, "z": 3.0})
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleUpsertLocation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rec.Code)
}
}
func TestHandleRenameLocation_NilStore(t *testing.T) {
globalLocationStore = nil
body, _ := json.Marshal(map[string]string{"old_name": "A", "new_name": "B"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleRenameLocation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rec.Code)
}
}
func TestHandleDeleteLocation_NilStore(t *testing.T) {
globalLocationStore = nil
body, _ := json.Marshal(map[string]string{"name": "X"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleDeleteLocation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", rec.Code)
}
}
// ── list ──────────────────────────────────────────────────────────────────────
func TestHandleListLocations_ReturnsSeededLocations(t *testing.T) {
setupLocationStore(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/locations", nil)
rec := httptest.NewRecorder()
handleListLocations(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
var locs []teleportLocation
if err := json.Unmarshal(rec.Body.Bytes(), &locs); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(locs) != len(cheatLocations) {
t.Fatalf("want %d locations, got %d", len(cheatLocations), len(locs))
}
}
// ── upsert ────────────────────────────────────────────────────────────────────
func TestHandleUpsertLocation_AddsNew(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]any{"name": "NewPlace", "x": 1.1, "y": 2.2, "z": 3.3})
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleUpsertLocation(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
// Confirm it appears in list.
locs, _ := globalLocationStore.list()
var found bool
for _, l := range locs {
if l.Name == "NewPlace" {
found = true
}
}
if !found {
t.Fatal("upserted location not in store")
}
}
func TestHandleUpsertLocation_RejectsMissingName(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]any{"x": 1.0, "y": 2.0, "z": 3.0})
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleUpsertLocation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestHandleUpsertLocation_RejectsBadJSON(t *testing.T) {
setupLocationStore(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader([]byte("{")))
rec := httptest.NewRecorder()
handleUpsertLocation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
// ── rename ────────────────────────────────────────────────────────────────────
func TestHandleRenameLocation_Success(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]string{"old_name": "Windsack", "new_name": "Windsack2"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleRenameLocation(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
locs, _ := globalLocationStore.list()
var found bool
for _, l := range locs {
if l.Name == "Windsack2" {
found = true
}
}
if !found {
t.Fatal("renamed location not found")
}
}
func TestHandleRenameLocation_RejectsMissingFields(t *testing.T) {
setupLocationStore(t)
tests := []struct {
name string
body map[string]string
}{
{"missing old_name", map[string]string{"new_name": "B"}},
{"missing new_name", map[string]string{"old_name": "Windsack"}},
{"both empty", map[string]string{"old_name": "", "new_name": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(b))
rec := httptest.NewRecorder()
handleRenameLocation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
})
}
}
func TestHandleRenameLocation_UnknownNameReturns404(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]string{"old_name": "NoSuch", "new_name": "Else"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleRenameLocation(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
}
// ── delete ────────────────────────────────────────────────────────────────────
func TestHandleDeleteLocation_Success(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]string{"name": "Windsack"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleDeleteLocation(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
locs, _ := globalLocationStore.list()
for _, l := range locs {
if l.Name == "Windsack" {
t.Fatal("deleted location still present")
}
}
}
func TestHandleDeleteLocation_RejectsMissingName(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]string{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleDeleteLocation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestHandleDeleteLocation_UnknownNameReturns404(t *testing.T) {
setupLocationStore(t)
body, _ := json.Marshal(map[string]string{"name": "NoSuch"})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
rec := httptest.NewRecorder()
handleDeleteLocation(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
}

View File

@@ -0,0 +1,130 @@
package main
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/gorilla/websocket"
)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return originAllowedForRequest(r)
},
}
// logPod is a discovered kubernetes pod available for log streaming.
type logPod struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
}
var k8sNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$`)
func isValidK8sName(name string) bool {
return len(name) > 0 && len(name) <= 253 && k8sNameRe.MatchString(name)
}
// @Summary List available log sources
// @Tags logs
// @Produce json
// @Success 200 {array} logPod
// @Failure 503 {object} map[string]string
// @Router /api/v1/logs/pods [get]
func handleLogPods(w http.ResponseWriter, r *http.Request) {
if globalControl == nil {
jsonErr(w, fmt.Errorf("not connected"), 503)
return
}
sources, err := globalControl.ListLogSources(r.Context(), globalExecutor)
if err != nil {
jsonErr(w, err, 500)
return
}
// Convert to logPod for frontend compat.
var pods []logPod
for _, s := range sources {
pods = append(pods, logPod(s))
}
if pods == nil {
pods = []logPod{}
}
jsonOK(w, pods)
}
// @Summary Stream log via WebSocket
// @Tags logs
// @Produce text/plain
// @Param ns query string true "Namespace or log source"
// @Param pod query string true "Pod or log file name"
// @Failure 503 {object} map[string]string
// @Router /api/v1/logs/stream [get]
func handleLogStream(w http.ResponseWriter, r *http.Request) {
ns := r.URL.Query().Get("ns")
pod := r.URL.Query().Get("pod")
if ns == "" || pod == "" {
http.Error(w, "ns and pod required", 400)
return
}
if isValidK8sName(ns) && isValidK8sName(pod) {
// K8s names validated — safe for kubectl.
} else if strings.ContainsAny(ns+pod, ";|&`$(){}\\") {
http.Error(w, "invalid characters in ns or pod", 400)
return
}
if globalControl == nil {
http.Error(w, "not connected", http.StatusServiceUnavailable)
return
}
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
_ = conn.SetWriteDeadline(time.Time{})
ch, cancel, err := globalControl.StreamLog(r.Context(), globalExecutor, ns, pod)
if err != nil {
conn.WriteMessage(websocket.TextMessage, []byte("error: "+err.Error())) //nolint:errcheck
return
}
defer cancel()
for line := range ch {
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
return
}
}
}
func splitLines(s string) []string {
return strings.Split(strings.TrimSpace(s), "\n")
}
// @Summary Fetch the cheat detection log
// @Tags logs
// @Produce json
// @Success 200 {array} cheatEntry
// @Failure 500 {object} map[string]string
// @Router /api/v1/logs/cheats [get]
func handleGetCheatLog(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdFetchCheatLog()().(msgCheatLog)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
rows := msg.rows
if rows == nil {
rows = []cheatEntry{}
}
jsonOK(w, rows)
}

View File

@@ -0,0 +1,40 @@
package main
import (
"fmt"
"log"
"net/http"
)
// handleGetMapMarkers returns the Live Map markers (players + vehicles, plus
// bases in Phase 2b) for the requested map. The ?map= input is validated before
// the DB is touched, so bad input fails fast with 400 and a valid map with no DB
// connection surfaces 503.
//
// @Summary Live Map markers for a map
// @Tags map
// @Produce json
// @Param map query string true "Map key (HaggaBasin | DeepDesert)"
// @Success 200 {array} mapMarker
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /api/v1/map/markers [get]
func handleGetMapMarkers(w http.ResponseWriter, r *http.Request) {
mapKey := r.URL.Query().Get("map")
if err := validateMapKey(mapKey); err != nil {
jsonErr(w, err, http.StatusBadRequest)
return
}
if globalDB == nil {
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
return
}
markers, err := cmdFetchMapMarkers(r.Context(), globalDB, mapKey)
if err != nil {
log.Printf("handleGetMapMarkers: %v", err)
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
return
}
jsonOK(w, markers)
}

View File

@@ -0,0 +1,35 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// handleGetMapMarkers validates the ?map= input before touching the DB, so bad
// input fails fast with 400 and a valid map with no DB connection surfaces 503.
// globalDB is nil in unit tests (connectAll is never called), which lets us
// exercise the input + guard paths without a database. Not parallel: it reads
// the globalDB package global.
func TestHandleGetMapMarkers_Input(t *testing.T) {
tests := []struct {
name string
query string
wantStatus int
}{
{name: "missing map param", query: "", wantStatus: http.StatusBadRequest},
{name: "unsupported map", query: "?map=Atlantis", wantStatus: http.StatusBadRequest},
{name: "valid map, db down", query: "?map=HaggaBasin", wantStatus: http.StatusServiceUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/map/markers"+tt.query, nil)
rec := httptest.NewRecorder()
handleGetMapMarkers(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}

View File

@@ -0,0 +1,266 @@
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
)
type marketItemsFilter struct {
search string
category string
tier *int
rarity string
owner string
}
func buildMarketItemsFilter(r *http.Request) marketItemsFilter {
q := r.URL.Query()
filter := marketItemsFilter{
search: strings.ToLower(q.Get("search")),
category: q.Get("category"),
rarity: strings.ToLower(q.Get("rarity")),
owner: q.Get("owner"),
}
if tierStr := q.Get("tier"); tierStr != "" {
if tier, err := strconv.Atoi(tierStr); err == nil {
filter.tier = &tier
}
}
return filter
}
func marketItemMatchesFilter(it marketItem, filter marketItemsFilter) bool {
if filter.search != "" {
if !strings.Contains(strings.ToLower(it.DisplayName), filter.search) &&
!strings.Contains(strings.ToLower(it.TemplateID), filter.search) {
return false
}
}
if filter.category != "" && !strings.HasPrefix(it.Category, filter.category) {
return false
}
if filter.tier != nil && it.Tier != *filter.tier {
return false
}
if filter.rarity != "" && !strings.EqualFold(it.Rarity, filter.rarity) {
return false
}
if filter.owner == "bot" && it.BotStock == 0 {
return false
}
if filter.owner == "player" && (it.TotalStock-it.BotStock) == 0 {
return false
}
return true
}
func filterMarketItems(items []marketItem, filter marketItemsFilter) []marketItem {
filtered := make([]marketItem, 0, len(items))
for _, it := range items {
if marketItemMatchesFilter(it, filter) {
filtered = append(filtered, it)
}
}
return filtered
}
func marketItemsPagination(r *http.Request, total int) (start, end, page, limit int) {
q := r.URL.Query()
limit = 100
page = 0
if parsedLimit, err := strconv.Atoi(q.Get("limit")); err == nil && parsedLimit > 0 && parsedLimit <= 500 {
limit = parsedLimit
}
if parsedPage, err := strconv.Atoi(q.Get("page")); err == nil && parsedPage > 0 {
page = parsedPage
}
start = page * limit
end = start + limit
if start >= total {
start = total
}
if end > total {
end = total
}
return start, end, page, limit
}
// handleMarketItems returns all active exchange listings aggregated by template ID.
// Query params: search, category, tier, rarity, owner (bot|player|all), page, limit.
// @Summary List market items aggregated by template ID
// @Tags market
// @Produce json
// @Param search query string false "Filter by display name or template ID"
// @Param category query string false "Filter by category prefix"
// @Param tier query int false "Filter by item tier"
// @Param rarity query string false "Filter by rarity"
// @Param owner query string false "Filter by owner type (bot|player|all)"
// @Param page query int false "Page number (0-based)"
// @Param limit query int false "Page size (default 100, max 500)"
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/market/items [get]
func handleMarketItems(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdFetchMarketItems().(msgMarketItems)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
items := msg.rows
if items == nil {
items = []marketItem{}
}
filter := buildMarketItemsFilter(r)
filtered := filterMarketItems(items, filter)
start, end, page, limit := marketItemsPagination(r, len(filtered))
jsonOK(w, map[string]any{
"items": filtered[start:end],
"total": len(filtered),
"page": page,
"limit": limit,
})
}
// handleMarketListings returns all active individual listings, optionally for one template.
// Query param: template_id, owner (bot|player|all), sort (price|quality).
// @Summary List individual active market listings
// @Tags market
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/market/listings [get]
func handleMarketListings(w http.ResponseWriter, r *http.Request) {
templateID := r.URL.Query().Get("template_id")
msg, ok := cmdFetchMarketListings(templateID).(msgMarketListings)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
listings := msg.rows
if listings == nil {
listings = []marketListing{}
}
if owner := r.URL.Query().Get("owner"); owner == "bot" || owner == "player" {
filtered := listings[:0]
for _, l := range listings {
if l.OwnerType == owner {
filtered = append(filtered, l)
}
}
listings = filtered
}
jsonOK(w, listings)
}
// handleMarketSales returns recent fulfilled sales (players buying from the bot).
// @Summary List recent fulfilled market sales
// @Tags market
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/market/sales [get]
func handleMarketSales(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdFetchMarketSales().(msgMarketSales)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
sales := msg.rows
if sales == nil {
sales = []marketSale{}
}
jsonOK(w, sales)
}
// handleMarketStats returns aggregate market statistics (admin-only by convention).
// @Summary Return aggregate market statistics
// @Tags market
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 500 {object} map[string]string
// @Router /api/v1/market/stats [get]
func handleMarketStats(w http.ResponseWriter, r *http.Request) {
msg, ok := cmdFetchMarketStats().(msgMarketStats)
if !ok {
jsonErr(w, fmt.Errorf("internal error"), 500)
return
}
if msg.err != nil {
jsonErr(w, msg.err, 500)
return
}
jsonOK(w, msg.stats)
}
// handleMarketCategories returns the category tree derived from item-data.json.
// Schematic items are reclassified under "schematics/" to surface as their own group.
// @Summary List distinct item categories from the item catalog
// @Tags market
// @Produce json
// @Success 200 {array} string
// @Router /api/v1/market/categories [get]
func handleMarketCategories(w http.ResponseWriter, r *http.Request) {
seen := map[string]bool{}
var categories []string
for templateID, rule := range itemData.Items {
if rule.Category == "" {
continue
}
cat := schematicCategory(templateID, rule.Category, rule.IsSchematic)
if !seen[cat] {
seen[cat] = true
categories = append(categories, cat)
}
}
jsonOK(w, categories)
}
// handleMarketCatalog returns a flat list of all known items (template_id + display_name)
// for use in autocomplete UIs such as the disabled-items manager.
// @Summary List all known item templates with display names
// @Tags market
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Router /api/v1/market/catalog [get]
func handleMarketCatalog(w http.ResponseWriter, r *http.Request) {
type entry struct {
TemplateID string `json:"template_id"`
DisplayName string `json:"display_name"`
}
seen := map[string]bool{}
var items []entry
for tmpl, rule := range itemData.Items {
name := rule.Name
if name == "" {
name = tmpl
}
seen[strings.ToLower(tmpl)] = true
items = append(items, entry{TemplateID: tmpl, DisplayName: name})
}
for tmpl, name := range itemData.Names {
if !seen[strings.ToLower(tmpl)] {
items = append(items, entry{TemplateID: tmpl, DisplayName: name})
}
}
jsonOK(w, items)
}

Some files were not shown because too many files have changed in this diff Show More