panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:18:05 -07:00
commit 4cf3471398
2161 changed files with 300831 additions and 0 deletions
+506
View File
@@ -0,0 +1,506 @@
package main
// Admin HTTP surface for out-of-band operator repair.
//
// These endpoints duplicate (and intentionally bypass) the regular
// session-protected /api/* surface so an operator with shell access to
// the prod box can drive recreate-dance fixes without first having a
// dashboard session — which historically required deploying a temporary
// "localhost-bypass" build on every incident (see changelog 2026-04-28).
//
// Auth model: a request is admitted if EITHER
// 1. RemoteAddr resolves to loopback (127.0.0.1 / ::1), OR
// 2. the X-Panel-Admin-Token header (constant-time compared) matches
// the on-disk token at $data_dir/admin-token.
//
// The token is auto-generated on first start. It lives at mode 0600 so
// only the panel user (refuge in prod) can read it. There is no
// rotation API yet — to rotate, delete the file and restart the
// controller. The new token is logged once at boot.
//
// Endpoints (all under /admin/v1/, not /api/, so they sit OUTSIDE the
// session middleware):
// GET /admin/v1/health
// GET /admin/v1/instances
// POST /admin/v1/instances/{id}/ark-cluster-rebind
// POST /admin/v1/instances/{id}/config body: {"set":{...},"delete":[...]}
// POST /admin/v1/instances/{id}/recreate body: {"mount_overrides":{...}}
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
adminTokenFile = "admin-token"
adminTokenHeader = "X-Panel-Admin-Token"
adminRecreateBudget = 3 * time.Minute
)
// ensureAdminToken loads the existing token from $dataDir/admin-token, or
// generates and persists a new one. Returns the token bytes (raw hex).
// Logs a "first run" message when generating; subsequent reads are silent.
func ensureAdminToken(dataDir string) (string, bool, error) {
path := filepath.Join(dataDir, adminTokenFile)
if b, err := os.ReadFile(path); err == nil {
tok := strings.TrimSpace(string(b))
if len(tok) >= 32 {
return tok, false, nil
}
// File exists but is empty/short — treat as missing and regenerate.
} else if !errors.Is(err, os.ErrNotExist) {
return "", false, fmt.Errorf("read admin-token: %w", err)
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return "", false, fmt.Errorf("mkdir data: %w", err)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false, fmt.Errorf("rand: %w", err)
}
tok := hex.EncodeToString(buf)
if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil {
return "", false, fmt.Errorf("write admin-token: %w", err)
}
return tok, true, nil
}
// adminAuth gates the admin mux. Loopback (127.0.0.1 / ::1) is always
// allowed without a token — this matches the on-host operator pattern.
// Otherwise the request must carry X-Panel-Admin-Token = the on-disk
// token. Constant-time compare so a timing oracle can't probe the token.
func (h *httpServer) adminAuth(token string, next http.Handler) http.Handler {
want := []byte(token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopback(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get(adminTokenHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), want) != 1 {
h.log.Warn("admin: token rejected", "remote", r.RemoteAddr, "path", r.URL.Path)
writeError(w, http.StatusUnauthorized, "unauthorized", "valid "+adminTokenHeader+" required for non-loopback requests")
return
}
next.ServeHTTP(w, r)
})
}
func isLoopback(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// requireSessionOrAdminToken is the gate for /api/*. A request is admitted if:
//
// 1. caller is loopback (on-host operator drives), OR
// 2. caller carries a valid X-Panel-Admin-Token header (service callers
// from a different LAN host — AMP-Monitor on toulouse, etc.), OR
// 3. caller has a valid panel_session cookie (browser session).
//
// Falls back through the cases in that order. Routes that need an
// authenticated user (handleMe, handleChangePassword) read sessionUserKey
// from the request context and 401 themselves if absent — service callers
// don't have a user identity, just admit-or-deny.
//
// adminToken empty (shouldn't happen at runtime; only in tests) → behaves
// exactly like requireSession.
func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) http.Handler {
sessionGate := h.auth.requireSession(isAPI, next)
if h.adminToken == "" {
return sessionGate
}
want := []byte(h.adminToken)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopback(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
if got := r.Header.Get(adminTokenHeader); got != "" &&
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
next.ServeHTTP(w, r)
return
}
sessionGate.ServeHTTP(w, r)
})
}
// registerAdminRoutes wires the admin mux onto the outer mux. Called
// from httpServer.handler() during construction.
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
if token == "" {
// Defensive: refuse to register if no token is available. This
// would only fire if ensureAdminToken returned empty, which
// shouldn't happen on a healthy boot.
h.log.Error("admin routes NOT registered (empty token)")
return
}
adm := http.NewServeMux()
adm.HandleFunc("GET /admin/v1/health", h.adminHealth)
adm.HandleFunc("GET /admin/v1/instances", h.adminListInstances)
adm.HandleFunc("POST /admin/v1/instances/{id}/ark-cluster-rebind", h.adminArkClusterRebind)
adm.HandleFunc("POST /admin/v1/instances/{id}/config", h.adminSetConfig)
adm.HandleFunc("POST /admin/v1/instances/{id}/recreate", h.adminRecreate)
adm.HandleFunc("POST /admin/v1/instances/{id}/install-base-mods", h.adminInstallBaseMods)
adm.HandleFunc("GET /admin/v1/empyrion/discoveries", h.adminEmpyrionDiscoveries)
adm.HandleFunc("GET /admin/v1/empyrion/player-summary", h.adminEmpyrionPlayerSummary)
mux.Handle("/admin/v1/", h.adminAuth(token, adm))
}
// ---- handlers ----
func (h *httpServer) adminHealth(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": controllerVersion,
"hostname": host,
"loopback": isLoopback(r.RemoteAddr),
"time": time.Now().UTC().Format(time.RFC3339),
})
}
type adminInstanceDTO struct {
InstanceID string `json:"instance_id"`
AgentID string `json:"agent_id"`
ModuleID string `json:"module_id"`
Status string `json:"status"`
ConfigValues map[string]string `json:"config_values"`
AssignedPorts map[string]uint32 `json:"assigned_ports"`
ARKClusterID string `json:"ark_cluster_id,omitempty"`
}
func (h *httpServer) adminListInstances(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.ListInstances(r.Context(), "")
if err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
clusterMembers := map[string]string{}
if h.arkClusters != nil {
h.arkClusters.mu.Lock()
reg, lerr := h.arkClusters.load()
h.arkClusters.mu.Unlock()
if lerr == nil {
clusterMembers = reg.Members
}
}
out := make([]adminInstanceDTO, 0, len(rows))
for _, row := range rows {
out = append(out, adminInstanceDTO{
InstanceID: row.InstanceID,
AgentID: row.AgentID,
ModuleID: row.ModuleID,
Status: row.Status,
ConfigValues: row.ConfigValues,
AssignedPorts: row.AssignedPorts,
ARKClusterID: clusterMembers[row.InstanceID],
})
}
sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
writeJSON(w, http.StatusOK, map[string]any{"instances": out})
}
// adminArkClusterRebind re-runs recreateArkWithClusterMount against the
// instance's currently-registered cluster. Use case: container drifted
// off the bind mount (recreate path missed MountOverrides), and we want
// to re-attach without changing any other state.
func (h *httpServer) adminArkClusterRebind(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
if row.ModuleID != "ark-sa" {
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("instance %q is module %q, not ark-sa", id, row.ModuleID))
return
}
h.arkClusters.mu.Lock()
reg, lerr := h.arkClusters.load()
h.arkClusters.mu.Unlock()
if lerr != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
cid := reg.Members[id]
if cid == "" {
writeError(w, http.StatusBadRequest, "not_clustered", "instance is not registered as a cluster member; use the dashboard to set up/join a cluster first")
return
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.recreateArkWithClusterMount(ctx, row, row.AgentID, cid); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: ark-cluster-rebind", "instance", id, "cluster", cid, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"cluster_id": cid,
})
}
type adminSetConfigReq struct {
Set map[string]string `json:"set"`
Delete []string `json:"delete"`
}
// adminSetConfig merges Set into config_values and removes Delete keys,
// then runs the env-config recreate dance preserving ports + cluster
// mount overrides.
func (h *httpServer) adminSetConfig(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req adminSetConfigReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if len(req.Set) == 0 && len(req.Delete) == 0 {
writeError(w, http.StatusBadRequest, "no_changes", "set and delete are both empty")
return
}
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
merged := map[string]string{}
for k, v := range row.ConfigValues {
merged[k] = v
}
for k, v := range req.Set {
merged[k] = v
}
for _, k := range req.Delete {
delete(merged, k)
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.adminRecreateInstance(ctx, row, merged, h.mountOverridesFor(id)); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: set-config", "instance", id, "set_keys", mapKeys(req.Set), "delete_keys", req.Delete, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"config_values": merged,
})
}
type adminRecreateReq struct {
MountOverrides map[string]string `json:"mount_overrides"`
}
// adminRecreate re-runs the stop→delete-preserve→create→start dance with
// no config changes, optionally with custom MountOverrides. If the body
// is empty or omits mount_overrides, the cluster-derived overrides
// (mountOverridesFor) are used — same as every regular recreate path.
func (h *httpServer) adminRecreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req adminRecreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
mounts := req.MountOverrides
if mounts == nil {
mounts = h.mountOverridesFor(id)
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.adminRecreateInstance(ctx, row, row.ConfigValues, mounts); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: recreate", "instance", id, "mount_overrides", mounts, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"mount_overrides": mounts,
})
}
// adminInstallBaseMods triggers the agent's base-mod installer for the
// given instance — Allocs Server Fixes + PrismaCore, downloaded fresh
// from authoritative sources (illy.bz + GitHub). TFP_* and xMarkers are
// shipped by the SteamCMD install and don't need re-download. Custom
// mods (RefugeBot, AGF-VP, etc.) are not touched. Useful when an
// operator wants to ensure the standard base-mod set is present on a
// freshly-installed instance that didn't get warm-seeded from a sibling.
//
// Implementation note: routes through the existing Update RPC machinery
// using the special provider id "_basemods" — the agent dispatcher
// short-circuits that to installBaseMods() instead of running the
// module's normal updater. Saves us a new proto round-trip.
func (h *httpServer) adminInstallBaseMods(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
if row.ModuleID != "7dtd" {
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("base-mods installer is 7DTD-only; instance %q is module %q", id, row.ModuleID))
return
}
conn := h.registry.get(row.AgentID)
if conn == nil {
writeError(w, http.StatusBadGateway, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
return
}
// Fire-and-forget: Update RPC produces an UpdateResult routed via
// AgentEvent (not CommandResult via pending registry), so awaitAgentCall
// would block forever. The agent's _basemods short-circuit runs async
// and emits the same UPDATING / install-finished pulses as a real
// update — operators see progress on the instance card or via
// `journalctl -u panel-agent.service | grep install-base-mods`.
corrID := newCorrelationID("update-basemods")
env := &panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_Update{
Update: &panelv1.UpdateRequest{InstanceId: id, ProviderId: "_basemods"},
},
}
if err := conn.Send(env); err != nil {
writeError(w, http.StatusBadGateway, "agent_call", err.Error())
return
}
h.log.Info("admin: install-base-mods kicked", "instance", id, "remote", r.RemoteAddr, "corr_id", corrID)
writeJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"instance_id": id,
"correlation_id": corrID,
"note": "base-mods install kicked off async; watch agent log for 'install-base-mods' lines (~30-60s)",
})
}
// ---- shared recreate helper ----
// adminRecreateInstance is the admin-side equivalent of
// recreateWithEnvConfig + changePorts: stop (if running), delete-preserve,
// upsert with the new config, recreate with the given mount overrides
// and original ports, restart if it was running before.
func (h *httpServer) adminRecreateInstance(ctx context.Context, row *db.InstanceRow, mergedCV map[string]string, mountOverrides map[string]string) error {
conn := h.registry.get(row.AgentID)
if conn == nil {
return fmt.Errorf("agent %q is not connected", row.AgentID)
}
wasRunning := row.Status == "running"
if wasRunning {
if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := h.db.UpsertInstance(ctx, db.InstanceRow{
InstanceID: row.InstanceID,
AgentID: row.AgentID,
ModuleID: row.ModuleID,
ModuleVersion: row.ModuleVersion,
RunMode: row.RunMode,
Status: "creating",
ConfigValues: mergedCV,
AssignedPorts: row.AssignedPorts,
}); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Copy so we can inject control sentinels without mutating mergedCV.
// This recreate PRESERVES the install volume — tell the agent to skip the
// create-time warm-seed + first-update, which would otherwise wipe /game
// and re-seed from a sibling, silently dropping a 7DTD server's custom Mods.
createCV := make(map[string]string, len(mergedCV)+2)
for k, v := range mergedCV {
createCV[k] = v
}
createCV["_PANEL_SKIP_REINSTALL"] = "true"
if !wasRunning {
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
MountOverrides: mountOverrides,
},
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
func (h *httpServer) loadInstanceForAdmin(ctx context.Context, id string) (*db.InstanceRow, error) {
if id == "" {
return nil, fmt.Errorf("instance id required")
}
rows, err := h.db.ListInstances(ctx, "")
if err != nil {
return nil, err
}
for i := range rows {
if rows[i].InstanceID == id {
return &rows[i], nil
}
}
return nil, fmt.Errorf("instance %q not found", id)
}
func mapKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,99 @@
package main
// ARK auto-save restore endpoint. Sister to the existing files.go ops,
// but split into its own file because the semantics are ARK-specific:
// the agent handler walks the SavedArks/<subdir>/ layout, refuses if the
// container is running, and preserves the previously-active save by
// renaming it aside (NEVER deleting) so even abandoned attempts are
// recoverable.
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// arkSaveRestoreAwait covers stop-aware file ops on a multi-GB save dir
// over Docker's CopyTar pipe. 2 minutes is well past the worst case for
// a few-hundred-MB rename + cp on local SSD.
const arkSaveRestoreAwait = 2 * 60 * time.Second
func (h *httpServer) arkSaveRestore(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", "agent not connected")
return
}
var req struct {
SavedArksSubdir string `json:"saved_arks_subdir"`
TargetFilename string `json:"target_filename"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "read", err.Error())
return
}
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
subdir := strings.TrimSpace(req.SavedArksSubdir)
target := strings.TrimSpace(req.TargetFilename)
if subdir == "" || target == "" {
writeError(w, http.StatusBadRequest, "missing_fields", "saved_arks_subdir and target_filename are required")
return
}
corrID := newCorrelationID("arkrestore")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_ArkSaveRestore{
ArkSaveRestore: &panelv1.ArkSaveRestoreRequest{
InstanceId: id,
SavedArksSubdir: subdir,
TargetFilename: target,
},
},
}); err != nil {
writeError(w, http.StatusBadGateway, "forward", err.Error())
return
}
env, err := h.pending.Await(r.Context(), corrID, arkSaveRestoreAwait)
if err != nil {
writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
return
}
res := env.GetArkSaveRestoreResult()
if res == nil {
writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing ark_save_restore_result")
return
}
if res.Error != "" {
// Agent's "refusing while running" message is operator-visible,
// surface as 409 so the UI can show "stop the server first" inline.
status := http.StatusBadRequest
if strings.Contains(strings.ToLower(res.Error), "refusing to restore while server is running") {
status = http.StatusConflict
}
writeError(w, status, "ark_restore", res.Error)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"aside_filename": res.AsideFilename,
"installed_filename": res.InstalledFilename,
})
}
+680
View File
@@ -0,0 +1,680 @@
package main
// ARK: Survival Ascended cluster manager.
//
// AMP-style one-click clustering: an ASA server can "Setup Cluster" to
// create a new shared cluster directory, then other ASA servers on the
// same agent can "Join Cluster" to mount the same directory. Under the
// hood we:
// 1. Swap the instance's default per-instance `panel-<id>-cluster`
// named volume for a shared `panel-ark-cluster-<clusterID>` volume
// (via InstanceCreate.mount_overrides).
// 2. Set the instance's CLUSTER_ID env var (via ConfigValues) so the
// acekorneya image passes `-clusterid=<id>` to the ASA binary.
//
// The instance's container has to be recreated for the volume swap to
// take effect (Docker can't add mounts to a live container), so the
// join/leave flow mirrors the existing changePorts dance: stop → delete
// (preserve volumes) → recreate with override → start.
//
// Membership state is derived authoritatively from one JSON file:
// `$data_dir/ark-clusters.json`. Keeping it outside the Postgres schema
// avoids a migration for a feature that isn't that load-bearing. If the
// JSON disappears or gets corrupted, clusters still *work* (the env var
// + shared volume are set on each member's container spec) — the UI just
// can't render the relationship until a fresh "Setup Cluster" happens.
//
// `ARK_CLUSTER_DIR_CONTAINER_PATH` is the single source of truth for
// where ARK writes cluster transfer data inside the container. If the
// module.yaml changes, this constant must match or the mount override
// will land at the wrong container path.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
// Must match the `container:` field of the cluster volume entry in
// `modules/ark-sa/module.yaml`. Kept here as the single place the
// controller cares about it.
arkClusterDirContainerPath = "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/clusters"
arkClusterRegistryFile = "ark-clusters.json"
// Max time we'll spend on a single stop/delete/create/start cycle
// when (un)joining a cluster. SteamCMD-free so this should finish
// fast; generous budget covers Docker Desktop cold-starts.
arkClusterRecreateTimeout = 3 * time.Minute
)
// arkClusterRegistry is the JSON-on-disk view. Cluster membership is also
// implied by the CLUSTER_ID env var on each instance's container — the
// registry exists purely to persist human-readable names and timestamps
// that don't have another home.
type arkClusterRegistry struct {
Clusters []arkClusterRecord `json:"clusters"`
Members map[string]string `json:"members"` // instance_id → cluster_id
}
type arkClusterRecord struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type arkClusterStore struct {
mu sync.Mutex
path string
}
func newArkClusterStore(dataDir string) *arkClusterStore {
return &arkClusterStore{path: filepath.Join(dataDir, arkClusterRegistryFile)}
}
func (s *arkClusterStore) load() (arkClusterRegistry, error) {
reg := arkClusterRegistry{Members: map[string]string{}}
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return reg, nil
}
return reg, err
}
if err := json.Unmarshal(b, &reg); err != nil {
return reg, err
}
if reg.Members == nil {
reg.Members = map[string]string{}
}
return reg, nil
}
func (s *arkClusterStore) save(reg arkClusterRegistry) error {
sort.Slice(reg.Clusters, func(i, j int) bool { return reg.Clusters[i].CreatedAt.Before(reg.Clusters[j].CreatedAt) })
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(reg, "", " ")
if err != nil {
return err
}
// Write+rename for atomicity — a crashed half-write would otherwise
// leave the registry unreadable, and we'd lose names.
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// ---- HTTP handlers ----
// handleArkClustersList returns every known cluster with its member list.
// Filters non-ARK instances defensively even though membership only gets
// recorded for ark-sa.
func (h *httpServer) handleArkClustersList(w http.ResponseWriter, r *http.Request) {
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
// Invert members map for fast per-cluster lookup.
byCluster := map[string][]string{}
for inst, cid := range reg.Members {
byCluster[cid] = append(byCluster[cid], inst)
}
out := make([]map[string]any, 0, len(reg.Clusters))
for _, c := range reg.Clusters {
members := byCluster[c.ID]
sort.Strings(members)
out = append(out, map[string]any{
"id": c.ID,
"name": c.Name,
"created_at": c.CreatedAt.UTC().Format(time.RFC3339),
"members": members,
})
}
writeJSON(w, http.StatusOK, map[string]any{"clusters": out})
}
// handleArkClusterGet returns the cluster info for a specific instance
// (or null if it isn't clustered). Used by the ARK modal to decide
// whether to show "Setup Cluster" vs "Leave Cluster".
//
// Also returns a `mount_drift` flag — true when the registry says the
// instance is in a cluster but the agent has no record of the recreate
// having succeeded (== container still has the per-instance cluster
// volume instead of the shared bind mount). Lets the UI surface a
// "this cluster bond didn't take — re-join to repair" warning.
func (h *httpServer) handleArkClusterGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
cid := reg.Members[id]
if cid == "" {
writeJSON(w, http.StatusOK, map[string]any{"cluster": nil})
return
}
var rec *arkClusterRecord
for i := range reg.Clusters {
if reg.Clusters[i].ID == cid {
rec = &reg.Clusters[i]
break
}
}
if rec == nil {
writeJSON(w, http.StatusOK, map[string]any{"cluster": map[string]any{"id": cid, "name": "(missing record)"}})
return
}
// Siblings = other members of the same cluster.
siblings := []string{}
for inst, mcid := range reg.Members {
if mcid == cid && inst != id {
siblings = append(siblings, inst)
}
}
sort.Strings(siblings)
// Drift detection: read the persisted DB row and check whether
// CLUSTER_ID matches what the registry expects. If the recreate
// dance was rolled back / failed, the row's ConfigValues won't
// have CLUSTER_ID set even though the registry says it should.
mountDrift := false
rows, _ := h.db.ListInstances(r.Context(), "")
for _, row := range rows {
if row.InstanceID == id {
cv := row.ConfigValues
if cv == nil || cv["CLUSTER_ID"] != cid {
mountDrift = true
}
break
}
}
writeJSON(w, http.StatusOK, map[string]any{
"cluster": map[string]any{
"id": rec.ID,
"name": rec.Name,
"created_at": rec.CreatedAt.UTC().Format(time.RFC3339),
"siblings": siblings,
"mount_drift": mountDrift,
},
})
}
type arkClusterActionReq struct {
// For "create new" flow: operator-supplied name (optional).
Name string `json:"name,omitempty"`
// For "join existing" flow: target cluster id.
ClusterID string `json:"cluster_id,omitempty"`
}
// handleArkClusterSetup creates a brand new cluster with the target
// instance as its first member. Generates a cluster id, writes the
// registry, and triggers the recreate dance with the new mount override.
func (h *httpServer) handleArkClusterSetup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkClusterActionReq
_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
clusterID := "cl_" + randomHex(6)
name := req.Name
if name == "" {
name = "ARK Cluster " + clusterID[3:]
}
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
if err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
// Kick the instance out of any prior cluster first (no-op if none).
prior := reg.Members[id]
delete(reg.Members, id)
reg.Clusters = append(reg.Clusters, arkClusterRecord{ID: clusterID, Name: name, CreatedAt: time.Now()})
reg.Members[id] = clusterID
// If the prior cluster was orphaned (only-member instance just
// left) drop the record too, otherwise operators end up with a
// cloud of empty clusters.
if prior != "" {
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, clusterID); err != nil {
// Roll back the registry write — without this, a failed recreate
// leaves the instance "in" the cluster on disk while the actual
// container still has the old per-instance volume. Future joins
// of OTHER instances would then operate on a phantom registry.
s.mu.Lock()
if rb, lerr := s.load(); lerr == nil {
delete(rb.Members, id)
if prior != "" {
stillUsed := false
for _, v := range rb.Members {
if v == prior {
stillUsed = true
break
}
}
if stillUsed {
rb.Members[id] = prior
}
}
// Drop the new cluster we just added if no one else is using it.
used := false
for _, v := range rb.Members {
if v == clusterID {
used = true
break
}
}
if !used {
rb.Clusters = filterClusters(rb.Clusters, clusterID)
}
_ = s.save(rb)
}
s.mu.Unlock()
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"cluster_id": clusterID,
"cluster_name": name,
"instance_id": id,
})
}
// handleArkClusterJoin adds the instance to an existing cluster. Same
// recreate dance — the mount override points at the same shared volume
// that every other member of the cluster already uses.
func (h *httpServer) handleArkClusterJoin(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkClusterActionReq
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil || req.ClusterID == "" {
writeError(w, http.StatusBadRequest, "bad_json", "cluster_id is required")
return
}
s := h.arkClusters
s.mu.Lock()
reg, lerr := s.load()
if lerr != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
var exists bool
for _, c := range reg.Clusters {
if c.ID == req.ClusterID {
exists = true
break
}
}
if !exists {
s.mu.Unlock()
writeError(w, http.StatusNotFound, "not_found", "no such cluster")
return
}
prior := reg.Members[id]
reg.Members[id] = req.ClusterID
if prior != "" && prior != req.ClusterID {
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, req.ClusterID); err != nil {
// Same rollback contract as Setup — never leave a phantom membership.
s.mu.Lock()
if rb, lerr := s.load(); lerr == nil {
if prior == "" {
delete(rb.Members, id)
} else {
rb.Members[id] = prior
}
_ = s.save(rb)
}
s.mu.Unlock()
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"cluster_id": req.ClusterID,
"instance_id": id,
})
}
// handleArkClusterLeave removes the instance from its cluster and
// recreates the container with the default per-instance cluster volume
// (no mount override, no CLUSTER_ID env). The shared cluster volume is
// left intact on disk so other members can keep using it; if this was
// the last member, we drop the cluster record from the registry.
func (h *httpServer) handleArkClusterLeave(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
s := h.arkClusters
s.mu.Lock()
reg, lerr := s.load()
if lerr != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
prior := reg.Members[id]
if prior == "" {
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "already_standalone": true})
return
}
delete(reg.Members, id)
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, ""); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "instance_id": id})
}
// ---- helpers ----
func filterClusters(list []arkClusterRecord, drop string) []arkClusterRecord {
out := make([]arkClusterRecord, 0, len(list))
for _, c := range list {
if c.ID != drop {
out = append(out, c)
}
}
return out
}
// removeInstanceFromCluster scrubs an instance's cluster membership and,
// if it was the last member of its cluster, drops the cluster record too.
// Safe to call for any instance_id (no-op if not in registry). Returns
// the cluster_id the instance was a member of (empty if it wasn't), so
// callers can log or react to the orphaning.
//
// Called on instance delete to prevent stale "already joined" state from
// leaking into a fresh instance with the same id.
func (s *arkClusterStore) removeInstanceFromCluster(instanceID string) (clusterID string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
reg, err := s.load()
if err != nil {
return "", err
}
cid, ok := reg.Members[instanceID]
if !ok {
return "", nil
}
delete(reg.Members, instanceID)
stillUsed := false
for _, v := range reg.Members {
if v == cid {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, cid)
}
if err := s.save(reg); err != nil {
return cid, err
}
return cid, nil
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// loadArkInstance fetches the instance row and confirms module_id is ark-sa.
func (h *httpServer) loadArkInstance(ctx context.Context, id string) (*db.InstanceRow, error) {
rows, err := h.db.ListInstances(ctx, "")
if err != nil {
return nil, err
}
for i := range rows {
if rows[i].InstanceID == id {
if rows[i].ModuleID != "ark-sa" {
return nil, fmt.Errorf("instance %q is module %q, not ark-sa", id, rows[i].ModuleID)
}
return &rows[i], nil
}
}
return nil, fmt.Errorf("instance %q not found", id)
}
// recreateArkWithClusterMount stops, deletes-with-preserve, and recreates
// the instance with (or without) the cluster mount override + CLUSTER_ID
// env. Joining a cluster passes clusterID != ""; leaving passes "". If
// the instance was running before, it's started again at the end.
func (h *httpServer) recreateArkWithClusterMount(ctx context.Context, row *db.InstanceRow, agentID, clusterID string) error {
conn := h.registry.get(agentID)
if conn == nil {
return fmt.Errorf("agent %q not connected", agentID)
}
wasRunning := row.Status == "running"
cctx, cancel := context.WithTimeout(ctx, arkClusterRecreateTimeout)
defer cancel()
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
// Build the updated config_values for persistence + the create RPC.
// We preserve whatever the operator previously set (server name,
// admin password, etc.) and only touch the cluster-related keys.
cfg := map[string]string{}
for k, v := range row.ConfigValues {
cfg[k] = v
}
if clusterID != "" {
cfg["CLUSTER_ID"] = clusterID
} else {
delete(cfg, "CLUSTER_ID")
}
// CUSTOM_SERVER_ARGS used to set -ClusterDirOverride here as a defensive
// override. Removed 2026-04-28: the acekorneya image's launch_ASA.sh
// expands $CUSTOM_SERVER_ARGS unquoted, which word-splits paths
// containing spaces into multiple argv elements (the wine-virtualized
// install path always has spaces). The split made ARK silently treat
// only the first chunk as the cluster path, breaking transfers. The
// bind-mount target is the same as ARK's default cluster path
// (Saved/clusters), so the override was never actually needed —
// removing it makes ARK use its default, which IS the shared bind mount.
delete(cfg, "CUSTOM_SERVER_ARGS")
row.ConfigValues = cfg
row.Status = "creating"
if err := h.db.UpsertInstance(cctx, *row); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
// Preserve panel-allocated host ports across the recreate so the agent's
// auto-bumper doesn't re-pick from manifest defaults and collide with
// sibling instances on the same agent.
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Suppress agent auto-start if the instance was stopped before this
// cluster recreate. Cluster join/leave shouldn't silently boot a
// stopped server.
createCV := cfg
if !wasRunning {
createCV = make(map[string]string, len(cfg)+1)
for k, v := range cfg {
createCV[k] = v
}
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
create := &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
}
if clusterID != "" {
// Bind mount to a host-visible folder under the agent's data
// root — matches AMP's "virtual path" UX. Operators can browse
// `data/arkcluster/<cluster_id>/` on the host to inspect, back
// up, or manually seed cluster transfers. `$AGENT_DATA_ROOT`
// is expanded agent-side into whatever data dir the agent was
// started with (defaults to `./data`).
create.MountOverrides = map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + clusterID,
}
}
if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: create,
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
// arkClusterMountOverridesFor returns the mount_overrides map needed to
// keep instanceID attached to its shared cluster bind mount across a
// container recreate. Returns nil when the instance isn't a cluster
// member (or when the registry is unreadable — caller treats nil as
// "no override needed", same as a non-clustered instance).
//
// Every recreate-dance path (env-config Apply, port change, mod toggle,
// cluster join/leave) MUST consult this and pass the result as the
// InstanceCreate's MountOverrides; otherwise the recreate replaces the
// shared bind mount with the manifest-default per-instance volume and
// silently disconnects the server from the cluster.
func (s *arkClusterStore) mountOverridesFor(instanceID string) map[string]string {
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
return nil
}
cid := reg.Members[instanceID]
if cid == "" {
return nil
}
return map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
}
}
+769
View File
@@ -0,0 +1,769 @@
package main
// ARK: Survival Ascended mod manager.
//
// ASA ships its own built-in CurseForge downloader — the dedicated
// server reads `-mods=<comma-id-list>` / `-passivemods=<comma-id-list>`
// from the launch command and pulls each mod from CurseForge on first
// start. This is fundamentally different from DayZ: no SteamCMD, no
// sidecar, no async job queue. All we do on the panel side is:
//
// 1. Maintain the instance's `MOD_IDS` (active, client-visible) and
// `PASSIVE_MODS` (server-side balance/tools) env vars via the
// persisted `config_values` column.
// 2. Swap the container via the usual stop → delete (preserve
// volumes) → recreate → start dance when the list changes, so
// the new env takes effect.
//
// CurseForge's official API requires an account + key. Their website
// itself is cloudflare-protected. The community-run proxy
// `api.curse.tools` exposes the same JSON, no key required, and returns
// everything we need (title, summary, logo, screenshots, authors, file
// sizes, popularity rank). The panel funnels all mod searches + detail
// lookups through it — the acekorneya image still downloads binaries
// directly from CurseForge CDN on server start.
//
// ARK:SA's CurseForge game id is 83374.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
arkCurseGameID = "83374"
arkCurseToolsSearch = "https://api.curse.tools/v1/cf/mods/search"
arkCurseToolsDetail = "https://api.curse.tools/v1/cf/mods/"
// sortField=6 (LastUpdated) → results prioritise actively-maintained
// mods, which is what most server operators want when browsing. The
// user can refine the query text to pull up obscure mods instead.
arkCurseToolsSortField = "6"
)
type arkModsListResp struct {
Mods []arkModEntry `json:"mods"`
Active string `json:"active_csv"` // comma-joined active mod ids
Passive string `json:"passive_csv"` // comma-joined passive mod ids
}
type arkModEntry struct {
ID string `json:"id"` // CurseForge project id as a string
Passive bool `json:"passive"` // true if in -passivemods= list
Order int `json:"order"` // position in the CSV (0-based)
Title string `json:"title,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
Summary string `json:"summary,omitempty"`
Author string `json:"author,omitempty"`
}
type arkInstallReq struct {
ModID string `json:"mod_id"` // numeric CF project id OR full URL
Passive bool `json:"passive,omitempty"`
}
// ---- list / install / uninstall ----
// handleArkModsList returns the instance's current mod lists with
// hydrated metadata from CurseForge. The UI renders this as the
// "Installed mods" section beneath the hero search.
func (h *httpServer) handleArkModsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
// Hydrate metadata in one batched call. curse.tools' search endpoint
// can match by ids via the `modIds` param, but the simplest cross-
// compat path is to fetch each mod's detail concurrently — ASA mod
// lists are almost always <20 entries in practice.
entries := make([]arkModEntry, 0, len(active)+len(passive))
for i, m := range active {
entries = append(entries, arkModEntry{ID: m, Order: i})
}
for i, m := range passive {
entries = append(entries, arkModEntry{ID: m, Order: i, Passive: true})
}
if len(entries) > 0 {
hydrateArkMods(r.Context(), entries)
}
writeJSON(w, http.StatusOK, arkModsListResp{
Mods: entries,
Active: strings.Join(active, ","),
Passive: strings.Join(passive, ","),
})
}
// handleArkModInstall appends a mod to the active (or passive) list
// and recreates the container. Duplicate adds are no-ops (returns
// ok=true, duplicate=true without triggering a recreate).
func (h *httpServer) handleArkModInstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkInstallReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
modID, err := parseArkModID(req.ModID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_mod_id", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
if containsString(active, modID) || containsString(passive, modID) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "duplicate": true, "mod_id": modID})
return
}
if req.Passive {
passive = append(passive, modID)
} else {
active = append(active, modID)
}
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
// Best-effort: fetch title for the success toast.
title := modID
if d, derr := fetchArkModDetail(modID); derr == nil && d.Name != "" {
title = d.Name
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"mod_id": modID,
"title": title,
"passive": req.Passive,
})
}
// handleArkModUninstall removes a mod from whichever list it lives in
// and recreates the container.
func (h *httpServer) handleArkModUninstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
modID := strings.TrimSpace(r.PathValue("modId"))
if modID == "" {
writeError(w, http.StatusBadRequest, "bad_mod_id", "mod id is required")
return
}
active := removeString(splitModCSV(row.ConfigValues["MOD_IDS"]), modID)
passive := removeString(splitModCSV(row.ConfigValues["PASSIVE_MODS"]), modID)
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "mod_id": modID})
}
// handleArkModsReorder accepts full replacement lists (active + passive)
// and recreates once. Used by the UI's drag-to-reorder and the
// "move between active/passive" affordance.
func (h *httpServer) handleArkModsReorder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req struct {
Active []string `json:"active"`
Passive []string `json:"passive"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 32*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
active := dedupStrings(req.Active)
passive := dedupStrings(req.Passive)
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleArkModsBatch applies multiple mod changes in a single recreate.
// Body: {adds:[{mod_id,passive}...], removes:[mod_id...], active_order:[...]?, passive_order:[...]?}.
// - adds with duplicate ids are silently skipped
// - removes are applied first, then adds, then ordering (if provided)
// - exactly one stop→delete→create→start cycle on the agent
//
// This replaces N round-trips × N recreates from per-mod install/uninstall
// for cases where the operator stages a batch of changes in the UI.
func (h *httpServer) handleArkModsBatch(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req struct {
Adds []arkInstallReq `json:"adds"`
Removes []string `json:"removes"`
ActiveOrder []string `json:"active_order"`
PassiveOrder []string `json:"passive_order"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
// 1) Removes first.
for _, raw := range req.Removes {
mid := strings.TrimSpace(raw)
if mid == "" {
continue
}
active = removeString(active, mid)
passive = removeString(passive, mid)
}
// 2) Adds (parsed + deduped against the lists post-remove).
addedTitles := make([]string, 0, len(req.Adds))
for _, a := range req.Adds {
modID, err := parseArkModID(a.ModID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_mod_id", fmt.Sprintf("%s: %s", a.ModID, err.Error()))
return
}
if containsString(active, modID) || containsString(passive, modID) {
continue
}
if a.Passive {
passive = append(passive, modID)
} else {
active = append(active, modID)
}
addedTitles = append(addedTitles, modID)
}
// 3) Ordering — if the client sent explicit order arrays, use them as
// the final order, but only honour ids that are actually in the
// current lists (defensive against stale state). Anything missing
// from the order falls through to the end in arrival order.
if len(req.ActiveOrder) > 0 {
active = applyExplicitOrder(active, req.ActiveOrder)
}
if len(req.PassiveOrder) > 0 {
passive = applyExplicitOrder(passive, req.PassiveOrder)
}
active = dedupStrings(active)
passive = dedupStrings(passive)
// Skip the recreate entirely if nothing actually changed.
curA := splitModCSV(row.ConfigValues["MOD_IDS"])
curP := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
if stringSlicesEqual(curA, active) && stringSlicesEqual(curP, passive) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "no_changes": true})
return
}
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"active": active,
"passive": passive,
"added": addedTitles,
"removed_count": len(req.Removes),
})
}
// applyExplicitOrder returns `current` reordered to match the order
// implied by `desired`, with any current-but-not-in-desired entries
// appended at the end in their original position.
func applyExplicitOrder(current, desired []string) []string {
in := make(map[string]bool, len(current))
for _, s := range current {
in[s] = true
}
seen := make(map[string]bool, len(desired))
out := make([]string, 0, len(current))
for _, d := range desired {
if in[d] && !seen[d] {
out = append(out, d)
seen[d] = true
}
}
for _, s := range current {
if !seen[s] {
out = append(out, s)
seen[s] = true
}
}
return out
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// recreateArkWithMods writes the updated MOD_IDS/PASSIVE_MODS env vars
// into config_values and triggers the standard recreate dance. Preserves
// the instance's cluster membership + any other config_values.
func (h *httpServer) recreateArkWithMods(ctx context.Context, row *db.InstanceRow, agentID string, active, passive []string) error {
conn := h.registry.get(agentID)
if conn == nil {
return fmt.Errorf("agent %q not connected", agentID)
}
wasRunning := row.Status == "running"
cctx, cancel := context.WithTimeout(ctx, arkClusterRecreateTimeout)
defer cancel()
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
cfg := map[string]string{}
for k, v := range row.ConfigValues {
cfg[k] = v
}
cfg["MOD_IDS"] = strings.Join(active, ",")
cfg["PASSIVE_MODS"] = strings.Join(passive, ",")
row.ConfigValues = cfg
row.Status = "creating"
if err := h.db.UpsertInstance(cctx, *row); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
// Preserve panel-allocated host ports across the recreate so the agent's
// auto-bumper doesn't re-pick from manifest defaults and collide with
// sibling instances on the same agent.
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Suppress agent auto-start if the instance was stopped before this
// mod-config recreate. Operator changing mods on a stopped server
// shouldn't see it auto-boot afterwards.
createCV := cfg
if !wasRunning {
createCV = make(map[string]string, len(cfg)+1)
for k, v := range cfg {
createCV[k] = v
}
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
// Preserve cluster-mount override if the instance was in a cluster.
create := &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
}
if cid := cfg["CLUSTER_ID"]; cid != "" {
create.MountOverrides = map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
}
}
if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: create,
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
// ---- CurseForge proxy (search + detail) ----
// handleArkModSearch proxies a CurseForge search via api.curse.tools.
// We normalise the response shape so the frontend has the exact same
// fields whether it's browsing search results or fetching a detail
// card: id, name, summary, logo_url, author, download_count, updated.
func (h *httpServer) handleArkModSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "bad_query", "q is required")
return
}
u, _ := url.Parse(arkCurseToolsSearch)
qs := u.Query()
qs.Set("gameId", arkCurseGameID)
qs.Set("searchFilter", q)
qs.Set("pageSize", "30")
qs.Set("sortField", arkCurseToolsSortField)
qs.Set("sortOrder", "desc")
u.RawQuery = qs.Encode()
items, err := curseToolsFetchSearch(u.String())
if err != nil {
writeError(w, http.StatusBadGateway, "curse", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "query": q})
}
// handleArkModDetail fetches a single mod's full metadata — including
// the long HTML description rendered by the mod info modal.
func (h *httpServer) handleArkModDetail(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
writeError(w, http.StatusBadRequest, "bad_id", "id is required")
return
}
modID, err := parseArkModID(id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_id", err.Error())
return
}
d, err := fetchArkModDetail(modID)
if err != nil {
writeError(w, http.StatusBadGateway, "curse", err.Error())
return
}
// Fetch the rendered description separately — the main detail doc
// carries `summary` (one paragraph) but not the full HTML body.
desc, _ := fetchArkModDescription(modID)
out := d.toUI()
out["description_html"] = desc
writeJSON(w, http.StatusOK, out)
}
// ---- helpers ----
// curseToolsMod is the subset of the CurseForge mod JSON we care about.
type curseToolsMod struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Status int `json:"status"`
DownloadCount int64 `json:"downloadCount"`
GameRank int `json:"gamePopularityRank"`
DateModified string `json:"dateModified"`
DateReleased string `json:"dateReleased"`
Authors []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"authors"`
Logo struct {
URL string `json:"url"`
} `json:"logo"`
Screenshots []struct {
URL string `json:"url"`
} `json:"screenshots"`
LatestFiles []struct {
FileLength int64 `json:"fileLength"`
DisplayName string `json:"displayName"`
DateReleased string `json:"fileDate"`
} `json:"latestFiles"`
Categories []struct {
Name string `json:"name"`
} `json:"categories"`
Links struct {
WebsiteURL string `json:"websiteUrl"`
} `json:"links"`
RatingDetails struct {
Rating float64 `json:"rating"`
TotalRatings int `json:"totalRatings"`
} `json:"ratingDetails"`
IsAvailable bool `json:"isAvailable"`
}
type curseToolsSearchResp struct {
Data []curseToolsMod `json:"data"`
}
type curseToolsDetailResp struct {
Data curseToolsMod `json:"data"`
}
type curseToolsDescriptionResp struct {
Data string `json:"data"`
}
func (m *curseToolsMod) toUI() map[string]any {
var latestSize int64
if len(m.LatestFiles) > 0 {
latestSize = m.LatestFiles[0].FileLength
}
author := ""
if len(m.Authors) > 0 {
author = m.Authors[0].Name
}
cats := make([]string, 0, len(m.Categories))
for _, c := range m.Categories {
cats = append(cats, c.Name)
}
return map[string]any{
"id": strconv.Itoa(m.ID),
"name": m.Name,
"slug": m.Slug,
"summary": m.Summary,
"logo_url": m.Logo.URL,
"author": author,
"download_count": m.DownloadCount,
"rank": m.GameRank,
"updated": m.DateModified,
"released": m.DateReleased,
"latest_size": latestSize,
"categories": cats,
"website": m.Links.WebsiteURL,
"rating": m.RatingDetails.Rating,
"ratings_total": m.RatingDetails.TotalRatings,
"is_available": m.IsAvailable,
}
}
func curseToolsFetchSearch(u string) ([]map[string]any, error) {
b, err := curseToolsGet(u)
if err != nil {
return nil, err
}
var r curseToolsSearchResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("decode search: %w", err)
}
out := make([]map[string]any, 0, len(r.Data))
for i := range r.Data {
out = append(out, r.Data[i].toUI())
}
return out, nil
}
func fetchArkModDetail(modID string) (*curseToolsMod, error) {
b, err := curseToolsGet(arkCurseToolsDetail + modID)
if err != nil {
return nil, err
}
var r curseToolsDetailResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("decode detail: %w", err)
}
return &r.Data, nil
}
func fetchArkModDescription(modID string) (string, error) {
b, err := curseToolsGet(arkCurseToolsDetail + modID + "/description")
if err != nil {
return "", err
}
var r curseToolsDescriptionResp
if err := json.Unmarshal(b, &r); err != nil {
return "", err
}
return r.Data, nil
}
var arkCurseClient = &http.Client{Timeout: 15 * time.Second}
func curseToolsGet(u string) ([]byte, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
// Real-browser UA — curse.tools passes through, and Cloudflare on
// CurseForge CDN is noticeably less aggressive with a common UA.
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
resp, err := arkCurseClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("curse.tools HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
}
// hydrateArkMods fills in title/logo/summary/author for each installed
// mod entry. Best-effort: if any fetch fails the entry keeps its id.
func hydrateArkMods(ctx context.Context, entries []arkModEntry) {
type result struct {
idx int
d *curseToolsMod
}
ch := make(chan result, len(entries))
for i := range entries {
i := i
go func() {
d, err := fetchArkModDetail(entries[i].ID)
if err != nil {
ch <- result{i, nil}
return
}
ch <- result{i, d}
}()
}
done := 0
deadline := time.NewTimer(8 * time.Second)
defer deadline.Stop()
for done < len(entries) {
select {
case r := <-ch:
done++
if r.d == nil {
continue
}
entries[r.idx].Title = r.d.Name
entries[r.idx].LogoURL = r.d.Logo.URL
entries[r.idx].Summary = r.d.Summary
if len(r.d.Authors) > 0 {
entries[r.idx].Author = r.d.Authors[0].Name
}
case <-ctx.Done():
return
case <-deadline.C:
return
}
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].Passive != entries[j].Passive {
return !entries[i].Passive // active first
}
return entries[i].Order < entries[j].Order
})
}
var arkModIDRE = regexp.MustCompile(`(?:[?&/]|^)(\d{4,12})`)
// parseArkModID accepts: raw numeric id OR full CurseForge URL
// (https://www.curseforge.com/ark-survival-ascended/mods/<slug>/N).
// Returns the numeric project id as a string.
func parseArkModID(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", errors.New("mod id is required")
}
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return s, nil
}
m := arkModIDRE.FindStringSubmatch(s)
if len(m) > 1 {
return m[1], nil
}
return "", fmt.Errorf("couldn't extract a CurseForge mod id from %q", s)
}
func splitModCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func containsString(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
func removeString(xs []string, s string) []string {
out := make([]string, 0, len(xs))
for _, x := range xs {
if x != s {
out = append(out, x)
}
}
return out
}
func dedupStrings(xs []string) []string {
seen := map[string]bool{}
out := []string{}
for _, x := range xs {
x = strings.TrimSpace(x)
if x == "" || seen[x] {
continue
}
seen[x] = true
out = append(out, x)
}
return out
}
+391
View File
@@ -0,0 +1,391 @@
package main
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"golang.org/x/crypto/argon2"
"github.com/dbledeez/panel/controller/internal/db"
)
// argon2id parameters — chosen to keep login latency ≈ ~50-100 ms on
// typical hardware while resisting offline cracking. Stored inline with
// each hash (PHC string format) so we can bump them later.
const (
argonTime = 3
argonMemory = 64 * 1024 // 64 MiB
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
const (
sessionCookieName = "panel_session"
sessionTTL = 24 * time.Hour
)
// sessionUserKey is the context key the auth middleware uses to stash the
// authenticated user on an incoming request.
type sessionUserKey struct{}
// auth holds the bits the auth handlers need. Kept in the main package so
// it can share types with httpServer without any dependency rewiring.
type auth struct {
log *slog.Logger
db *db.DB
publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to
}
// ---- Password hashing (argon2id, PHC format) ----
// hashPassword produces a PHC-format string safe for DB storage:
//
// $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
func hashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("rand salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf(
"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// verifyPassword is constant-time. Returns (true, nil) on match,
// (false, nil) on mismatch, (false, err) on bad hash format.
func verifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=65536,t=3,p=2", salt, hash]
if len(parts) != 6 || parts[1] != "argon2id" {
return false, fmt.Errorf("not an argon2id hash")
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, fmt.Errorf("params: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("salt: %w", err)
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("hash: %w", err)
}
computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(expected, computed) == 1, nil
}
// ---- Session tokens ----
func newSessionToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Take the first IP in the list.
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
// ---- Admin bootstrap ----
// bootstrapAdmin creates a default admin account if the users table is
// empty. The password is generated and printed to the controller's log
// (operator is expected to log in and change it via the UI on first run).
//
// Pass a non-empty envPassword ($PANEL_ADMIN_PASSWORD) to use a stable
// password instead of a random one — useful for dev / CI.
//
// initialSteamID (17-digit SteamID64, optional):
//
// - If the users table is empty and this is non-empty, the bootstrap
// admin gets this Steam ID linked immediately, enabling
// "Sign in with Steam" on first boot.
// - If an admin already exists AND doesn't have a steam_id linked yet,
// we retro-link the first admin to this Steam ID. Useful when the
// operator is adding Steam login to an already-running panel.
// - Ignored if the first admin already has a steam_id (don't clobber).
// bootstrapAdmin creates the first admin. envEmail (from PANEL_ADMIN_EMAIL)
// lets the operator choose their login; blank falls back to the default
// admin@panel.local so existing installs are unaffected.
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envEmail, envPassword, initialSteamID string) error {
adminEmail := strings.TrimSpace(strings.ToLower(envEmail))
if adminEmail == "" {
adminEmail = "admin@panel.local"
}
initialSteamID = strings.TrimSpace(initialSteamID)
if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) {
log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID)
initialSteamID = ""
}
n, err := database.CountUsers(ctx)
if err != nil {
return err
}
if n > 0 {
// Not our first boot — if the operator added --initial-admin-steam-id
// after the fact, retro-link it to the first admin when that admin
// doesn't yet have a steam_id. Otherwise do nothing.
if initialSteamID == "" {
return nil
}
first, err := database.GetFirstAdmin(ctx)
if err != nil {
log.Warn("could not find first admin to retro-link steam id", "err", err)
return nil
}
if first.SteamID != "" {
if first.SteamID != initialSteamID {
log.Warn("first admin already has a different steam_id; leaving it alone",
"existing", first.SteamID, "requested", initialSteamID)
}
return nil
}
if err := database.LinkSteamIDToUser(ctx, first.ID, initialSteamID); err != nil {
return fmt.Errorf("link initial steam id: %w", err)
}
log.Warn("═══════════════════════════════════════════════════════════════════════════")
log.Warn("STEAM LINK — linked existing admin account to your Steam ID",
"email", first.Email, "steam_id", initialSteamID)
log.Warn(" Sign in with Steam is now active on /login")
log.Warn("═══════════════════════════════════════════════════════════════════════════")
return nil
}
password := envPassword
generated := password == ""
if generated {
pw, err := newSessionToken()
if err != nil {
return err
}
password = pw[:16] // 16 hex chars is enough entropy for dev
}
hash, err := hashPassword(password)
if err != nil {
return err
}
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
if err != nil {
return err
}
if initialSteamID != "" {
if err := database.LinkSteamIDToUser(ctx, userID, initialSteamID); err != nil {
log.Warn("create-user succeeded but steam link failed", "err", err)
}
}
const banner = "═══════════════════════════════════════════════════════════════════════════"
log.Warn(banner)
log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
log.Warn(" email: " + adminEmail)
if generated {
log.Warn(" password: " + password)
log.Warn(" ^^^ log in once and change it via the UI ^^^")
} else {
log.Warn(" password: (supplied via PANEL_ADMIN_PASSWORD env)")
}
if initialSteamID != "" {
log.Warn(" steam_id: " + initialSteamID + " (Sign in with Steam is active)")
}
log.Warn(banner)
return nil
}
// steamID64RE matches a 17-digit Steam community ID.
var steamID64RE = regexp.MustCompile(`^\d{17}$`)
// ---- Middleware ----
// requireSession enforces a valid session on every wrapped request.
// Unauthenticated API hits get 401; unauthenticated page hits get 302 /login.
func (a *auth) requireSession(isAPI bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := a.userFromRequest(r)
if err != nil {
if isAPI {
writeError(w, http.StatusUnauthorized, "unauthenticated", "log in to continue")
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
ctx := context.WithValue(r.Context(), sessionUserKey{}, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// userFromRequest reads the session cookie and returns the user, or an
// error if the cookie is missing / expired / invalid.
func (a *auth) userFromRequest(r *http.Request) (*db.UserRow, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie")
}
u, err := a.db.GetSessionUser(r.Context(), c.Value, clientIP(r))
if err != nil {
return nil, err
}
if u.Disabled {
return nil, fmt.Errorf("account disabled")
}
return u, nil
}
// ---- Handlers ----
type loginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing", "email and password required")
return
}
u, err := a.db.GetUserByEmail(r.Context(), req.Email)
if err != nil {
if errors.Is(err, db.ErrUserNotFound) {
// Still do a bogus verify so login timing doesn't leak user enumeration.
_, _ = verifyPassword(req.Password, "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
if u.Disabled {
writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
return
}
ok, err := verifyPassword(req.Password, u.PasswordHash)
if err != nil {
writeError(w, http.StatusInternalServerError, "verify", err.Error())
return
}
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "token", err.Error())
return
}
exp := time.Now().Add(sessionTTL)
if err := a.db.CreateSession(r.Context(), token, u.ID, exp, clientIP(r)); err != nil {
writeError(w, http.StatusInternalServerError, "session", err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: exp,
})
writeJSON(w, http.StatusOK, map[string]any{
"email": u.Email,
"role": u.Role,
})
}
func (a *auth) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
_ = a.db.DeleteSession(r.Context(), c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (a *auth) handleMe(w http.ResponseWriter, r *http.Request) {
u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": u.ID,
"email": u.Email,
"role": u.Role,
"steam_id": u.SteamID,
})
}
type changePasswordReq struct {
Current string `json:"current_password"`
New string `json:"new_password"`
}
// handleChangePassword is behind requireSession — user context is guaranteed.
func (a *auth) handleChangePassword(w http.ResponseWriter, r *http.Request) {
u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
return
}
var req changePasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if len(req.New) < 8 {
writeError(w, http.StatusBadRequest, "weak_password", "new password must be at least 8 characters")
return
}
ok, err := verifyPassword(req.Current, u.PasswordHash)
if err != nil || !ok {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password incorrect")
return
}
newHash, err := hashPassword(req.New)
if err != nil {
writeError(w, http.StatusInternalServerError, "hash", err.Error())
return
}
if err := a.db.UpdateUserPassword(r.Context(), u.ID, newHash); err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+946
View File
@@ -0,0 +1,946 @@
package main
// Conan Exiles Steam Workshop mod manager — install/uninstall/list/reorder.
//
// Mirrors the DayZ pattern (controller/cmd/controller/dayzmods.go) closely
// but with three Conan-specific differences:
//
// 1. Workshop appid is 440900 (the Conan Exiles game), NOT 443030 (the
// dedicated server, which doesn't have its own workshop).
// 2. Mods are .pak files; no bikeys to copy. Install = symlink the
// workshop content directory into /game/ConanSandbox/Mods/<id> and
// add a relative .pak path to /game/ConanSandbox/Mods/modlist.txt.
// 3. The shared-volume install dir is /conan-workshop (vs DayZ's
// /dayz-workshop). Both the panel-conan-exiles container and the
// transient SteamCMD sidecar mount it, so one download serves N
// Conan instances on the same agent (dayzmods pattern).
//
// The Steam credential cache (panel-steamcmd-auth volume) is shared with
// every other workshop downloader, so a single sign-in unlocks DayZ and
// Conan together — provided the cached account owns the relevant base
// game. SteamCMD's `+workshop_download_item 440900 …` requires an account
// that owns Conan Exiles (or has access to the workshop item via free
// browsing — but most modders ship for owners only).
//
// Job lifecycle phases: queued → downloading → linking → finalizing → done
// (or → error / login_needed). Reuses the dayzModJob type and dayzModJobManager
// runner — the struct is generic enough that conan jobs ride on it without
// a parallel file. BikeysCopied stays empty for Conan jobs (no bikeys).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"path"
"sort"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
// conanWorkshopAppID is the Conan Exiles game's Steam appid (the
// dedicated server (443030) does not have its own Workshop). Steam's
// workshop_download_item, the GetPublishedFileDetails API, and the
// /workshop/browse search all key off the GAME's appid for items.
conanWorkshopAppID = "440900"
// conanWorkshopVolume is the named Docker volume that holds workshop
// content. Mounted in BOTH the SteamCMD sidecar (at
// /conan-workshop/steamapps/workshop) and every panel-conan-exiles
// container (at /game/steamapps/workshop) so the game can read what
// SteamCMD wrote without copying bytes around. Mirrors the DayZ
// pattern (modules/dayz/module.yaml + dayzmods.go) verbatim.
conanWorkshopVolume = "panel-conan-workshop"
// conanSteamCMDInstallDir is what SteamCMD's +force_install_dir is
// pointed at INSIDE THE SIDECAR. With the volume mounted at
// <conanSteamCMDInstallDir>/steamapps/workshop, SteamCMD's workshop
// downloads (which always land at <install_dir>/steamapps/workshop/
// content/<appid>/<id>/) land at the volume root.
conanSteamCMDInstallDir = "/conan-workshop"
// conanWorkshopGameMountIn is where the same volume mounts inside
// the GAME container. Different mount path from the sidecar; same
// volume content. Workshop content appears at
// /game/steamapps/workshop/content/<appid>/<workshop_id>/<file>.pak
// — under /game (a declared browseable_root), so FsList/FsSymlink
// don't need a separate /conan-workshop root declaration.
conanWorkshopGameMountIn = "/game/steamapps/workshop"
// conanModsDir is where Conan reads mod content from at boot time.
// Per Funcom-aligned hosting docs (Akliz / XGamingServer / Nodecraft,
// 2026), every .pak must live FLAT under this directory — Conan does
// NOT walk subdirectories of /game/ConanSandbox/Mods/. We satisfy
// that by symlinking each individual .pak from the shared workshop
// volume into here at install time (one symlink per .pak file, not
// per workshop_id).
conanModsDir = "/game/ConanSandbox/Mods"
// conanModlistFile is the operator-readable line-per-pak load order.
// Format (verified 2026 against Akliz + XGamingServer + Funcom wiki):
// *Filename1.pak
// *Filename2.pak
// Lines MUST start with `*` — without it Conan ignores the entry as
// "subscribe-only marker" and the mod doesn't load. Path is just the
// filename (relative to conanModsDir). One line per .pak; multi-pak
// mods (Pippi ships Pippi.pak + Pippi_Editor.pak) get multiple lines.
conanModlistFile = "/game/ConanSandbox/Mods/modlist.txt"
// conanSidecarFile tracks workshop_id ↔ {title, preview, paks} on
// disk so the panel can render the installed-mods list with rich
// metadata (titles, sizes, thumbs) without re-fetching from Steam,
// and so uninstall can find which symlinks belong to which workshop
// item. Lives next to modlist.txt; ignored by Conan (the dot-prefix
// keeps it out of pak loading and the file is hidden in most pak
// scanners). Format is JSON; see conanSidecarEntry for the shape.
conanSidecarFile = "/game/ConanSandbox/Mods/.panel-conan-mods.json"
)
// conanSidecarEntry is one workshop item's recorded metadata + the .pak
// filenames that belong to it. The sidecar map is keyed by workshop_id;
// every value is the entry for that id.
type conanSidecarEntry struct {
Title string `json:"title,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Size int64 `json:"size,omitempty"`
Updated int64 `json:"updated,omitempty"`
Description string `json:"description,omitempty"`
Paks []string `json:"paks"`
InstalledAt int64 `json:"installed_at,omitempty"` // unix seconds
}
type conanSidecar map[string]*conanSidecarEntry
// conanMod is one entry in the installed-mods list rendered by the UI.
// Same shape as dayzMod — separate type so callers don't conflate them
// even though they share most fields.
type conanMod struct {
WorkshopID string `json:"workshop_id"`
Filename string `json:"filename"` // e.g. "Savage_Wilds.pak" or "Pippi.pak"
Order int `json:"order"`
// Optional metadata hydrated from Steam's GetPublishedFileDetails:
Title string `json:"title,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Size int64 `json:"size,omitempty"`
Updated int64 `json:"updated,omitempty"` // unix seconds
Description string `json:"description,omitempty"`
}
type conanInstallReq struct {
WorkshopID string `json:"workshop_id"` // numeric id OR full Steam URL; parsed below
}
// ---- list ----
// handleConanModsList reads modlist.txt + the sidecar JSON, joins them so
// each .pak entry carries the workshop_id it belongs to, and returns the
// list in load order. The sidecar is the source of truth for grouping +
// metadata; modlist.txt is the source of truth for load order. If they
// drift (operator hand-edited modlist.txt), the modlist wins for ordering
// and any unknown .pak filenames are returned with empty workshop_id so
// the UI can flag them as "manually added".
func (h *httpServer) handleConanModsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
paks := h.conanReadModlist(r.Context(), conn, id)
sidecar := h.conanReadSidecar(r.Context(), conn, id)
// Build pak → workshop_id reverse index from the sidecar so we can
// tag each modlist.txt entry with its owning workshop_id.
pakToWS := map[string]string{}
for wsID, e := range sidecar {
if e == nil {
continue
}
for _, p := range e.Paks {
pakToWS[p] = wsID
}
}
out := make([]conanMod, 0, len(paks))
for i, p := range paks {
entry := conanMod{
Filename: p,
Order: i,
}
if wsID := pakToWS[p]; wsID != "" {
entry.WorkshopID = wsID
if e := sidecar[wsID]; e != nil {
entry.Title = e.Title
entry.PreviewURL = e.PreviewURL
entry.Size = e.Size
entry.Updated = e.Updated
entry.Description = e.Description
}
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"mods": out})
}
// ---- install (async) ----
// handleConanModInstall kicks off an async install and returns {job_id}
// in <200 ms. UI polls /conan/jobs to track progress. Mirrors DayZ's
// async pattern so a 2 GB Pippi download doesn't tie up a browser
// request.
func (h *httpServer) handleConanModInstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req conanInstallReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
workshopID, perr := parseWorkshopID(req.WorkshopID)
if perr != nil {
writeError(w, http.StatusBadRequest, "bad_workshop_id", perr.Error())
return
}
// Pre-fetch metadata. Lets the UI render the friendly name + size on
// the queued card before the download even starts.
var title string
var size, updated int64
if d, derr := steamWorkshopDetail(workshopID); derr == nil && d != nil {
title, size, updated = d.Title, d.Size, d.TimeUpdated
}
// Steam credential check up-front so the UI can pop the sign-in
// modal without burning a job slot on a guaranteed-401.
user, _, ok := h.getSteamLoginForUpdate(r.Context())
if !ok {
writeJSON(w, http.StatusOK, map[string]any{
"steam_login_required": true,
"reason": "Conan Workshop downloads need a Steam account that owns Conan Exiles. Sign in once — the token is cached for next time.",
})
return
}
// Reject duplicate concurrent installs of the same mod on the same
// instance — operator double-click guard.
for _, existing := range h.dayzJobs.listForInstance(id) {
existing.mu.Lock()
dup := existing.WorkshopID == workshopID &&
(existing.Phase == phaseQueued || existing.Phase == phaseDownloading ||
existing.Phase == phaseLinking || existing.Phase == phaseFinalizing)
existingID := existing.ID
existing.mu.Unlock()
if dup {
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": existingID,
"duplicate": true,
})
return
}
}
// FolderName is unused for Conan (we symlink by workshop_id directly),
// but the dayzModJob struct requires a non-empty value to render in
// the UI. Stick the workshop_id there.
job := h.dayzJobs.create(id, workshopID, workshopID, false)
job.setResult(title, size, updated)
job.appendLine(fmt.Sprintf("queued: %s (id=%s)", title, workshopID))
h.log.Info("conan mod install queued",
"job_id", job.ID, "instance_id", id, "workshop_id", workshopID, "title", title)
// Detach goroutine — context.Background, NOT r.Context — so the HTTP
// request can return immediately and the operator can close the tab.
go h.runConanModInstallJob(context.Background(), job, agentID, user)
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": job.ID,
"workshop_id": workshopID,
"title": title,
})
}
// runConanModInstallJob is the install pipeline. Three phases: download
// (SteamCMD sidecar), link (FsSymlinkRequest to agent), finalize (rewrite
// modlist.txt). Each phase updates job state for the UI poller.
func (h *httpServer) runConanModInstallJob(ctx context.Context, job *dayzModJob, agentID, user string) {
defer func() {
if rec := recover(); rec != nil {
h.log.Error("conan mod install goroutine panic", "job_id", job.ID, "recover", rec)
job.setError(fmt.Sprintf("internal error: %v", rec), false)
}
}()
// ---- phase: download ----
job.setPhase(phaseDownloading)
err := runSteamCMDConanWorkshopDownloadStreaming(ctx, h.log, user, job, jobLineSink(job))
if err != nil {
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) {
job.setLoginNeeded("Your Steam session expired. Sign in again to refresh the cached token.")
return
}
permanent := errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own Conan Exiles")
friendly := friendlyConanDownloadError(err)
job.appendLine("download failed: " + friendly)
job.setError(friendly, permanent)
return
}
// ---- phase: linking ----
job.setPhase(phaseLinking)
job.appendLine("requesting agent: list workshop content + create per-pak symlinks")
conn := h.registry.get(agentID)
if conn == nil {
job.setError("agent disconnected during download", false)
return
}
// The workshop content dir, as seen from inside the GAME container,
// is /game/steamapps/workshop/content/440900/<id>/ (the volume mount
// point inside the game; see module.yaml). We send FsList there.
// The SAME volume content is at /conan-workshop/steamapps/workshop/
// content/440900/<id>/ from the SteamCMD sidecar's perspective —
// that's where the download wrote to.
workshopContentDir := path.Join(conanWorkshopGameMountIn, "content", conanWorkshopAppID, job.WorkshopID)
pakFiles, lerr := h.conanListPakFiles(ctx, conn, job.InstanceID, workshopContentDir)
if lerr != nil {
job.setError("agent list workshop dir: "+lerr.Error(), false)
return
}
if len(pakFiles) == 0 {
job.setError("workshop item downloaded but contains no .pak files (is it really a Conan mod?)", true)
return
}
job.appendLine(fmt.Sprintf("found %d pak file%s: %s",
len(pakFiles), plural(len(pakFiles)), strings.Join(pakFiles, ", ")))
// Conan reads .pak files FLAT from /game/ConanSandbox/Mods/ — it
// does not walk subdirectories. So one symlink per .pak file:
// /game/ConanSandbox/Mods/<filename>.pak
// → /conan-workshop/steamapps/workshop/content/440900/<id>/<filename>.pak
// On collision (two mods ship the same .pak filename) the second
// install would overwrite the first; we surface this as a soft
// warning rather than a hard error so the operator can rename one.
for _, f := range pakFiles {
linkPath := path.Join(conanModsDir, f)
target := path.Join(workshopContentDir, f)
corrID := newCorrelationID("conan_symlink")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsSymlink{
FsSymlink: &panelv1.FsSymlinkRequest{
InstanceId: job.InstanceID,
Target: target,
LinkPath: linkPath,
},
},
}); err != nil {
job.setError(fmt.Sprintf("forward symlink %s to agent: %s", f, err.Error()), false)
return
}
awaitCtx, awaitCancel := context.WithTimeout(ctx, 30*time.Second)
env, err := h.pending.Await(awaitCtx, corrID, 30*time.Second)
awaitCancel()
if err != nil {
job.setError(fmt.Sprintf("agent didn't reply to symlink %s: %s", f, err.Error()), false)
return
}
if res := env.GetFsSymlinkResult(); res == nil {
job.setError(fmt.Sprintf("agent returned empty symlink reply for %s", f), false)
return
} else if res.Error != "" {
job.setError(fmt.Sprintf("agent symlink %s: %s", f, res.Error), false)
return
}
}
job.appendLine(fmt.Sprintf("symlinked %d pak%s into %s", len(pakFiles), plural(len(pakFiles)), conanModsDir))
job.setAgentResult(pakFiles, workshopContentDir)
// ---- phase: finalizing ----
job.setPhase(phaseFinalizing)
// Update the sidecar JSON FIRST: list endpoint reads it, modlist
// uses it for grouping. We pull current state, merge the new entry,
// write it back.
sidecar := h.conanReadSidecar(ctx, conn, job.InstanceID)
if sidecar == nil {
sidecar = conanSidecar{}
}
// Pull rich metadata. Title was set during queue but we may have
// gotten less than full detail — fetching now gives us description
// + preview for the UI without a separate Steam call later.
title, previewURL, size, updated := "", "", int64(0), int64(0)
description := ""
if d, derr := steamWorkshopDetail(job.WorkshopID); derr == nil && d != nil {
title, previewURL, size, updated = d.Title, d.PreviewURL, d.Size, d.TimeUpdated
description = d.Description
}
sidecar[job.WorkshopID] = &conanSidecarEntry{
Title: title,
PreviewURL: previewURL,
Size: size,
Updated: updated,
Description: description,
Paks: pakFiles,
InstalledAt: time.Now().Unix(),
}
if err := h.conanWriteSidecar(ctx, conn, job.InstanceID, sidecar); err != nil {
// Non-fatal: modlist.txt and symlinks still work; just the UI's
// grouping/metadata fall back to "manually added" rendering.
job.appendLine("warning: failed to update sidecar metadata: " + err.Error())
}
// Append entries to modlist.txt with the *Filename.pak format Conan
// requires. Don't disturb existing entries (operator may have
// hand-added paks); just add the new ones at the end if not already
// present.
if err := h.conanAppendModlist(ctx, conn, job.InstanceID, pakFiles); err != nil {
job.setError("update modlist.txt: "+err.Error(), false)
return
}
job.appendLine(fmt.Sprintf("added %d entr%s to modlist.txt (with required *prefix)",
len(pakFiles), map[bool]string{true: "ies", false: "y"}[len(pakFiles) != 1]))
job.setPercent(100)
job.setPhase(phaseDone)
h.log.Info("conan mod install done", "job_id", job.ID, "workshop_id", job.WorkshopID,
"pak_count", len(pakFiles), "paks", pakFiles)
}
// ---- uninstall ----
func (h *httpServer) handleConanModUninstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
wsID := r.PathValue("workshopId")
if wsID == "" {
writeError(w, http.StatusBadRequest, "bad_id", "workshop_id path segment is required")
return
}
// 1. Look up which .pak symlinks belong to this workshop_id (via the
// sidecar). If the sidecar lost track somehow, fall back to
// leaving the symlinks (operator can hand-clean) and just clear
// the modlist entries we know.
sidecar := h.conanReadSidecar(r.Context(), conn, id)
var paks []string
if e := sidecar[wsID]; e != nil {
paks = e.Paks
}
// 2. Remove each individual .pak symlink. Each symlink only points
// into the shared workshop volume; we never recurse into the
// workshop content itself (which other instances may still need).
removed := []string{}
for _, p := range paks {
corrID := newCorrelationID("conan_unlink")
linkPath := path.Join(conanModsDir, p)
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsDelete{
FsDelete: &panelv1.FsDeleteRequest{
InstanceId: id,
Path: linkPath,
Recursive: false,
},
},
}); err != nil {
h.log.Warn("conan uninstall: forward delete failed", "pak", p, "err", err)
continue
}
env, err := h.pending.Await(r.Context(), corrID, 15*time.Second)
if err != nil {
h.log.Warn("conan uninstall: agent reply timeout", "pak", p, "err", err)
continue
}
if res := env.GetFsDeleteResult(); res != nil && res.Error != "" && !strings.Contains(res.Error, "no such file") {
h.log.Warn("conan uninstall: agent delete error", "pak", p, "err", res.Error)
continue
}
removed = append(removed, p)
}
// 3. Drop modlist.txt lines whose filename matches one of `paks`.
// If sidecar was empty we're a no-op here, which is correct.
if len(paks) > 0 {
if err := h.conanRemoveFromModlist(r.Context(), conn, id, paks); err != nil {
h.log.Warn("conan mod uninstall: modlist update failed", "instance_id", id, "workshop_id", wsID, "err", err)
}
}
// 4. Drop the workshop_id from the sidecar.
if sidecar[wsID] != nil {
delete(sidecar, wsID)
if err := h.conanWriteSidecar(r.Context(), conn, id, sidecar); err != nil {
h.log.Warn("conan mod uninstall: sidecar update failed", "instance_id", id, "err", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"workshop_id": wsID,
"removed_paks": removed,
})
}
// ---- reorder ----
type conanReorderReq struct {
// Either WorkshopIDs (groups by mod) or full Entries (raw modlist
// lines). UI sends WorkshopIDs; entries are derived from the current
// file order so all .paks for one mod stay grouped.
WorkshopIDs []string `json:"workshop_ids"`
}
func (h *httpServer) handleConanModReorder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req conanReorderReq
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
current := h.conanReadModlist(r.Context(), conn, id)
sidecar := h.conanReadSidecar(r.Context(), conn, id)
// Group current paks by workshop_id (via sidecar) so a multi-pak
// mod's internal pak order is preserved when the operator reorders
// at the workshop_id granularity. Paks not tied to any sidecar entry
// (operator-hand-added mods) are bucketed under "" and appended at
// the end.
pakToWS := map[string]string{}
for wsID, e := range sidecar {
if e == nil {
continue
}
for _, p := range e.Paks {
pakToWS[p] = wsID
}
}
byWS := map[string][]string{}
for _, p := range current {
wsID := pakToWS[p]
byWS[wsID] = append(byWS[wsID], p)
}
out := []string{}
seen := map[string]bool{}
for _, wsID := range req.WorkshopIDs {
if seen[wsID] {
continue
}
seen[wsID] = true
out = append(out, byWS[wsID]...)
delete(byWS, wsID)
}
// Append any leftovers (mods in the file but not in the reorder
// request — operator hand-added or sidecar-orphaned). Stable order
// by workshop_id so reorders are deterministic; "" bucket last.
leftoverKeys := make([]string, 0, len(byWS))
for k := range byWS {
if k != "" {
leftoverKeys = append(leftoverKeys, k)
}
}
sort.Strings(leftoverKeys)
for _, k := range leftoverKeys {
out = append(out, byWS[k]...)
}
out = append(out, byWS[""]...) // hand-added paks last
if err := h.conanWriteModlist(r.Context(), conn, id, out); err != nil {
writeError(w, http.StatusBadGateway, "write_modlist", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": out})
}
// ---- job polling endpoints ----
func (h *httpServer) handleConanModJobList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobs := h.dayzJobs.listForInstance(id)
snaps := make([]map[string]any, 0, len(jobs))
for _, j := range jobs {
snaps = append(snaps, j.snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": snaps})
}
func (h *httpServer) handleConanModJobGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
job := h.dayzJobs.get(r.PathValue("jobId"))
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
writeJSON(w, http.StatusOK, job.snapshot())
}
func (h *httpServer) handleConanModJobDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobID := r.PathValue("jobId")
job := h.dayzJobs.get(jobID)
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
h.dayzJobs.remove(jobID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": jobID})
}
// ---- modlist.txt helpers ----
// conanReadModlist returns the .pak filenames in load order. Each line
// must be of the form "*Filename.pak"; the leading * is stripped before
// returning. Blank lines and # comments are skipped. We also tolerate
// (legacy / hand-edited) entries WITHOUT the asterisk and entries with
// a path prefix (which we strip down to the basename) — the panel will
// normalise on next write.
func (h *httpServer) conanReadModlist(ctx context.Context, conn *agentConn, instanceID string) []string {
res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanModlistFile)
if err != nil || res == nil || res.Error != "" {
return nil
}
out := []string{}
scanner := bufio.NewScanner(bytes.NewReader(res.Content))
for scanner.Scan() {
raw := strings.TrimSpace(scanner.Text())
if raw == "" || strings.HasPrefix(raw, "#") {
continue
}
line := strings.TrimPrefix(raw, "*")
// Normalise path separators + strip directory prefix so a legacy
// entry "*<workshop_id>/Mod.pak" still groups by the underlying
// pak name. Conan-correct entries are bare filenames already.
line = strings.ReplaceAll(line, "\\", "/")
if i := strings.LastIndex(line, "/"); i >= 0 {
line = line[i+1:]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
out = append(out, line)
}
return out
}
// conanAppendModlist adds each new pak filename in order, deduped.
func (h *httpServer) conanAppendModlist(ctx context.Context, conn *agentConn, instanceID string, newPaks []string) error {
current := h.conanReadModlist(ctx, conn, instanceID)
have := map[string]bool{}
rendered := make([]string, 0, len(current)+len(newPaks))
for _, p := range current {
have[p] = true
rendered = append(rendered, p)
}
for _, p := range newPaks {
if have[p] {
continue
}
have[p] = true
rendered = append(rendered, p)
}
return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}
// conanRemoveFromModlist drops every line whose filename appears in the
// supplied set (case-insensitive on the filename itself, since Wine on
// Linux normalises pak-file lookups case-insensitively).
func (h *httpServer) conanRemoveFromModlist(ctx context.Context, conn *agentConn, instanceID string, removePaks []string) error {
current := h.conanReadModlist(ctx, conn, instanceID)
drop := map[string]bool{}
for _, p := range removePaks {
drop[strings.ToLower(p)] = true
}
rendered := make([]string, 0, len(current))
for _, p := range current {
if drop[strings.ToLower(p)] {
continue
}
rendered = append(rendered, p)
}
return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}
// conanWriteModlist rewrites modlist.txt with the supplied .pak file
// names, each prefixed with `*` (REQUIRED by Conan — without it the
// dedicated server treats the entry as a no-op marker, see Funcom +
// Akliz + XGamingServer docs 2026). A header comment helps operators
// eyeballing the file recognise it as panel-managed.
func (h *httpServer) conanWriteModlist(ctx context.Context, conn *agentConn, instanceID string, paks []string) error {
var body bytes.Buffer
body.WriteString("# panel-managed mod list — one *Filename.pak per line.\n")
body.WriteString("# The leading asterisk is REQUIRED — without it Conan ignores the entry.\n")
body.WriteString("# Blank lines and '#' comments ignored. Edits via the panel UI Mods\n")
body.WriteString("# tab will overwrite this file; manual edits survive only until then.\n")
for _, p := range paks {
body.WriteString("*")
body.WriteString(p)
body.WriteByte('\n')
}
return h.dayzXmlWriteFile(ctx, conn, instanceID, conanModlistFile, body.Bytes())
}
// conanReadSidecar loads the workshop_id ↔ {metadata, paks} map from
// /game/ConanSandbox/Mods/.panel-conan-mods.json. Returns an empty map
// on any error so callers can treat "fresh state" and "lost sidecar"
// the same way.
func (h *httpServer) conanReadSidecar(ctx context.Context, conn *agentConn, instanceID string) conanSidecar {
res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanSidecarFile)
if err != nil || res == nil || res.Error != "" || len(res.Content) == 0 {
return conanSidecar{}
}
out := conanSidecar{}
if err := json.Unmarshal(res.Content, &out); err != nil {
h.log.Warn("conan sidecar parse failed", "instance_id", instanceID, "err", err)
return conanSidecar{}
}
return out
}
// conanWriteSidecar persists the workshop_id map. Pretty-prints with
// 2-space indent so an operator inspecting the file by hand can read
// the layout.
func (h *httpServer) conanWriteSidecar(ctx context.Context, conn *agentConn, instanceID string, s conanSidecar) error {
body, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
return h.dayzXmlWriteFile(ctx, conn, instanceID, conanSidecarFile, body)
}
// ---- pak filename discovery ----
// conanListPakFiles asks the agent to list `dir` (inside the instance
// container) and returns just the .pak filenames. Sorted for stable load
// order across operators.
func (h *httpServer) conanListPakFiles(ctx context.Context, conn *agentConn, instanceID, dir string) ([]string, error) {
corrID := newCorrelationID("conan_list_pak")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsList{
FsList: &panelv1.FsListRequest{
InstanceId: instanceID,
Path: dir,
},
},
}); err != nil {
return nil, fmt.Errorf("forward list to agent: %w", err)
}
awaitCtx, awaitCancel := context.WithTimeout(ctx, 15*time.Second)
env, err := h.pending.Await(awaitCtx, corrID, 15*time.Second)
awaitCancel()
if err != nil {
return nil, err
}
res := env.GetFsListResult()
if res == nil {
return nil, errors.New("agent returned empty fs_list reply")
}
if res.Error != "" {
return nil, errors.New(res.Error)
}
out := []string{}
for _, e := range res.Entries {
if e == nil || e.IsDir {
continue
}
if !strings.HasSuffix(strings.ToLower(e.Name), ".pak") {
continue
}
out = append(out, e.Name)
}
sort.Strings(out)
return out, nil
}
// ---- SteamCMD download (Conan-flavoured) ----
// runSteamCMDConanWorkshopDownloadStreaming streams stdout into onLine
// while running a SteamCMD sidecar to download one Conan workshop item
// into the shared panel-conan-workshop volume. Same retry-on-timeout
// pattern as the DayZ flavour, with Conan-specific error classification
// (the "doesn't own DayZ" line becomes "doesn't own Conan Exiles").
func runSteamCMDConanWorkshopDownloadStreaming(ctx context.Context, log Logger, user string, job *dayzModJob, onLine func(string)) error {
if user == "" {
return errTokenMissing
}
const maxAttempts = 4
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
onLine(fmt.Sprintf("retry in %s (attempt %d/%d) — last: %v", backoff, attempt, maxAttempts, lastErr))
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
onLine(fmt.Sprintf("__attempt__:%d", attempt))
err := runSteamCMDConanWorkshopDownloadOnceStreaming(ctx, log, user, job.WorkshopID, onLine)
if err == nil {
return nil
}
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own Conan Exiles") {
return err
}
lastErr = err
}
return fmt.Errorf("conan workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}
func runSteamCMDConanWorkshopDownloadOnceStreaming(ctx context.Context, log Logger, user, workshopID string, onLine func(string)) error {
dockerBin := locateDockerBinary()
cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
args := []string{
"run", "--rm",
"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
// Mount the workshop volume DEEP — at
// <install_dir>/steamapps/workshop — so SteamCMD's standard
// workshop write path (<install_dir>/steamapps/workshop/content/
// <appid>/<id>/) lands at the volume root. The game container
// mounts the same volume at /game/steamapps/workshop, which
// makes the content visible there as
// /game/steamapps/workshop/content/<appid>/<id>/. Verified
// pattern — same as DayZ.
"-v", conanWorkshopVolume + ":" + conanSteamCMDInstallDir + "/steamapps/workshop",
"steamcmd/steamcmd:latest",
"+force_install_dir", conanSteamCMDInstallDir,
"+login", user,
"+workshop_download_item", conanWorkshopAppID, workshopID, "validate",
"+quit",
}
cmd := exec.CommandContext(cctx, dockerBin, args...)
var full bytes.Buffer
lw := &lineWriter{
full: &full,
cb: func(line string) {
onLine(ansiEscapeRE.ReplaceAllString(line, ""))
},
}
cmd.Stdout = lw
cmd.Stderr = lw
err := cmd.Run()
lw.flush()
plain := ansiEscapeRE.ReplaceAllString(full.String(), "")
log.Info("steamcmd conan workshop_download output", "workshop_id", workshopID,
"exit_err", err, "tail", lastFewLines(plain, 20))
switch {
case strings.Contains(plain, "Success. Downloaded item"):
return nil
case strings.Contains(plain, "failed (File Not Found)"),
strings.Contains(plain, "failed (Invalid App ID)"):
return errWorkshopNotFound
case strings.Contains(plain, "Logon failure"),
strings.Contains(plain, "Invalid Password"),
strings.Contains(plain, "No cached credentials"),
strings.Contains(plain, "cached credentials are invalid"),
strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
strings.Contains(plain, "FAILED (Access Denied)"):
return errTokenStale
case strings.Contains(plain, "failed (Access Denied)"):
return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
return fmt.Errorf("Steam account doesn't own Conan Exiles (or item access denied): %s", lastFewLines(plain, 6))
case strings.Contains(plain, "Timeout downloading item"):
return fmt.Errorf("steamcmd timeout mid-download (will retry)")
case err != nil:
return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
}
return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}
func friendlyConanDownloadError(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, errWorkshopNotFound):
return "Workshop item not found — ID is wrong or the mod was delisted."
case errors.Is(err, errTokenStale):
return "Steam session expired — sign in again to refresh the cached token."
case errors.Is(err, errTokenMissing):
return "No Steam username configured — complete the Steam login first."
case strings.Contains(err.Error(), "Access Denied"):
return "Access denied — this mod is restricted for your Steam account (region-lock or private item)."
case strings.Contains(err.Error(), "own Conan Exiles"):
return "Your Steam account doesn't own Conan Exiles — use an account that has the game."
case strings.Contains(err.Error(), "timeout"):
return "SteamCMD timed out after 4 retries. Try again in a few minutes — Steam CDN is flaky."
}
msg := err.Error()
if i := strings.Index(msg, "\n"); i > 0 {
msg = msg[:i]
}
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return msg
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSameOriginHost(t *testing.T) {
cases := []struct {
origin, host string
want bool
}{
{"https://panel.example.com", "panel.example.com", true},
{"http://panel.example.com:8080", "panel.example.com:8080", true},
{"https://PANEL.example.com", "panel.example.com", true},
{"https://evil.com", "panel.example.com", false},
{"https://panel.example.com.evil.com", "panel.example.com", false},
{"null", "panel.example.com", false},
{"", "panel.example.com", false},
}
for _, c := range cases {
if got := sameOriginHost(c.origin, c.host); got != c.want {
t.Errorf("sameOriginHost(%q, %q) = %v, want %v", c.origin, c.host, got, c.want)
}
}
}
func TestWithCORSReflection(t *testing.T) {
h := withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Same-origin browser request: origin reflected.
r := httptest.NewRequest("GET", "http://panel.local/api/agents", nil)
r.Host = "panel.local"
r.Header.Set("Origin", "https://panel.local")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://panel.local" {
t.Errorf("same-origin ACAO = %q, want reflected origin", got)
}
// Cross-origin: no ACAO header at all.
r = httptest.NewRequest("GET", "http://panel.local/api/agents", nil)
r.Host = "panel.local"
r.Header.Set("Origin", "https://evil.com")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("cross-origin ACAO = %q, want empty", got)
}
// No Origin (curl/panelctl): no ACAO, request still passes through.
r = httptest.NewRequest("OPTIONS", "http://panel.local/api/agents", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNoContent {
t.Errorf("OPTIONS status = %d, want 204", w.Code)
}
if got := w.Header().Get("Cache-Control"); got == "" {
t.Error("Cache-Control no-store stamp missing")
}
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
package main
// In-memory async-job system for DayZ Workshop mod installs.
//
// Why: a single mod can be 2+ GB. Tying the install to the POST /dayz/mods
// request means the browser (and any curl client) disconnects long before
// steamcmd finishes — and when the request context cancels we'd kill the
// download. This package-local job runner lets POST return immediately with
// a job_id, then the UI polls GET /dayz/mods/jobs while steamcmd writes
// progress lines into the job's log buffer. Jobs survive browser-close but
// not controller-restart (by design: steamcmd's on-disk chunk hashes resume
// after a restart, so the worst case is one more retry).
import (
"sort"
"sync"
"time"
)
// dayzModJobPhase is the coarse-grained status a user cares about. We avoid
// exposing internal attempt counters as "phase" — those live as side data.
type dayzModJobPhase string
const (
phaseQueued dayzModJobPhase = "queued"
phaseDownloading dayzModJobPhase = "downloading" // steamcmd is running
phaseLinking dayzModJobPhase = "linking" // agent is creating symlink + copying bikeys
phaseFinalizing dayzModJobPhase = "finalizing" // writing panel-mods.txt
phaseDone dayzModJobPhase = "done"
phaseError dayzModJobPhase = "error"
phaseLoginNeeded dayzModJobPhase = "login_needed" // cached Steam token expired
)
// dayzModJob is one mod-install's state. All reads/writes MUST go through
// the helper methods which take the mutex.
type dayzModJob struct {
ID string
InstanceID string
WorkshopID string
FolderName string
ServerMod bool
mu sync.Mutex
Phase dayzModJobPhase
Attempt int // 1-based; current or final SteamCMD attempt
// Rolling log of the most recent N lines from steamcmd + agent. UI
// renders the tail in a small mono block under the progress bar.
Lines []string
// Percent is best-effort from steamcmd's "progress: N.NN" lines; if
// we can't parse it, UI shows an indeterminate bar.
Percent float64
StartedAt time.Time
EndedAt time.Time
// Populated on terminal phases.
Error string // empty unless Phase == error/login_needed
Permanent bool // true when retrying would definitely fail again (not-found, access-denied, stale-token)
Title string // from GetPublishedFileDetails
Size int64
Updated int64
BikeysCopied []string
ResolvedModPath string
}
const dayzModJobLineBufCap = 200 // tail lines retained
func (j *dayzModJob) snapshot() map[string]any {
j.mu.Lock()
defer j.mu.Unlock()
lines := append([]string(nil), j.Lines...)
terminal := j.Phase == phaseDone || j.Phase == phaseError || j.Phase == phaseLoginNeeded
// can_retry: the UI's Retry button is only useful when trying again
// MIGHT succeed. Login-needed is a special case handled by auto-popping
// the sign-in modal, not by the Retry button.
canRetry := j.Phase == phaseError && !j.Permanent
return map[string]any{
"id": j.ID,
"instance_id": j.InstanceID,
"workshop_id": j.WorkshopID,
"folder_name": j.FolderName,
"server_mod": j.ServerMod,
"phase": string(j.Phase),
"attempt": j.Attempt,
"lines": lines,
"percent": j.Percent,
"started_at": j.StartedAt.UTC().Format(time.RFC3339),
"ended_at": jobTimeOrZero(j.EndedAt),
"error": j.Error,
"permanent": j.Permanent,
"can_retry": canRetry,
"title": j.Title,
"size": j.Size,
"updated": j.Updated,
"bikeys_copied": j.BikeysCopied,
"resolved_mod_path": j.ResolvedModPath,
"terminal": terminal,
}
}
func jobTimeOrZero(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// setPhase is the basic state-machine step. Also updates EndedAt on terminal.
func (j *dayzModJob) setPhase(p dayzModJobPhase) {
j.mu.Lock()
defer j.mu.Unlock()
j.Phase = p
switch p {
case phaseDone, phaseError, phaseLoginNeeded:
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
}
}
func (j *dayzModJob) setError(msg string, permanent bool) {
j.mu.Lock()
j.Error = msg
j.Permanent = permanent
j.Phase = phaseError
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
j.mu.Unlock()
}
func (j *dayzModJob) setLoginNeeded(reason string) {
j.mu.Lock()
j.Error = reason
j.Phase = phaseLoginNeeded
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
j.mu.Unlock()
}
func (j *dayzModJob) appendLine(s string) {
j.mu.Lock()
j.Lines = append(j.Lines, s)
if len(j.Lines) > dayzModJobLineBufCap {
// Ring-buffer trim — keep newest.
j.Lines = j.Lines[len(j.Lines)-dayzModJobLineBufCap:]
}
j.mu.Unlock()
}
func (j *dayzModJob) setPercent(pct float64) {
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
j.mu.Lock()
j.Percent = pct
j.mu.Unlock()
}
func (j *dayzModJob) setAttempt(n int) {
j.mu.Lock()
j.Attempt = n
j.mu.Unlock()
}
func (j *dayzModJob) setResult(title string, size, updated int64) {
j.mu.Lock()
j.Title = title
j.Size = size
j.Updated = updated
j.mu.Unlock()
}
func (j *dayzModJob) setAgentResult(bikeys []string, resolvedPath string) {
j.mu.Lock()
j.BikeysCopied = append([]string(nil), bikeys...)
j.ResolvedModPath = resolvedPath
j.mu.Unlock()
}
// ---- manager ----
type dayzModJobManager struct {
mu sync.RWMutex
jobs map[string]*dayzModJob
}
func newDayzModJobManager() *dayzModJobManager {
m := &dayzModJobManager{jobs: make(map[string]*dayzModJob)}
go m.reaperLoop()
return m
}
func (m *dayzModJobManager) create(instanceID, workshopID, folder string, serverMod bool) *dayzModJob {
j := &dayzModJob{
ID: newCorrelationID("dzmodjob"),
InstanceID: instanceID,
WorkshopID: workshopID,
FolderName: folder,
ServerMod: serverMod,
Phase: phaseQueued,
StartedAt: time.Now(),
}
m.mu.Lock()
m.jobs[j.ID] = j
m.mu.Unlock()
return j
}
func (m *dayzModJobManager) get(id string) *dayzModJob {
m.mu.RLock()
defer m.mu.RUnlock()
return m.jobs[id]
}
// remove drops a job from the map. Safe to call on non-terminal jobs —
// the in-flight goroutine keeps running but its state updates will land
// on a detached record that no one else can observe, which is fine (the
// operator asked to dismiss it). We never actually "cancel" the download
// because steamcmd has no safe abort signal short of SIGKILL — which
// would strand partial on-disk chunks that the next retry would pick up
// anyway, so this is a controlled orphan rather than a leak.
func (m *dayzModJobManager) remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.jobs[id]; !ok {
return false
}
delete(m.jobs, id)
return true
}
// listForInstance returns jobs for a given instance, newest first.
func (m *dayzModJobManager) listForInstance(instanceID string) []*dayzModJob {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*dayzModJob, 0, len(m.jobs))
for _, j := range m.jobs {
if j.InstanceID == instanceID {
out = append(out, j)
}
}
sort.Slice(out, func(i, k int) bool { return out[i].StartedAt.After(out[k].StartedAt) })
return out
}
// reaperLoop drops completed jobs after a grace period so the in-memory
// map doesn't grow forever. Also keeps recently-failed jobs around long
// enough that the operator can read the error message (10 min).
func (m *dayzModJobManager) reaperLoop() {
t := time.NewTicker(1 * time.Minute)
defer t.Stop()
for range t.C {
m.reap()
}
}
func (m *dayzModJobManager) reap() {
const (
doneGrace = 2 * time.Minute
errorGrace = 10 * time.Minute
orphanCutoff = 2 * time.Hour // stuck non-terminal jobs
)
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for id, j := range m.jobs {
j.mu.Lock()
phase := j.Phase
ended := j.EndedAt
started := j.StartedAt
j.mu.Unlock()
switch phase {
case phaseDone:
if !ended.IsZero() && now.Sub(ended) > doneGrace {
delete(m.jobs, id)
}
case phaseError, phaseLoginNeeded:
if !ended.IsZero() && now.Sub(ended) > errorGrace {
delete(m.jobs, id)
}
default:
// Non-terminal. Reap if we think it's wedged (controller bug
// or upstream Steam hang past any sensible timeout).
if now.Sub(started) > orphanCutoff {
delete(m.jobs, id)
}
}
}
}
+274
View File
@@ -0,0 +1,274 @@
package main
// DayZ Central Economy XML merger — operator-facing endpoints that let the
// panel drop a mod's types.xml/events.xml/etc snippet into a running
// instance's mission files, preview the diff, and commit with an automatic
// timestamped backup.
//
// Canonical merge semantics + supported file list live in pkg/dayzxml.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/pkg/dayzxml"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// dayzCEFile maps a dayzxml.FileKind to the container-relative path of the
// canonical file inside a standard Chernarus mission tree. Operators using
// non-default missions can still merge via the path-override form.
type dayzCEFile struct {
Kind dayzxml.FileKind `json:"kind"`
Filename string `json:"filename"` // "types.xml"
MissionPath string `json:"mission_path"` // "db/types.xml" (relative to mission folder)
Label string `json:"label"` // "Loot items (types.xml)" — UI-friendly
}
// dayzKnownFiles enumerates the CE XML targets we support. Ordered for UI.
var dayzKnownFiles = []dayzCEFile{
{Kind: dayzxml.KindTypes, Filename: "types.xml", MissionPath: "db/types.xml",
Label: "Loot items (types.xml)"},
{Kind: dayzxml.KindEvents, Filename: "events.xml", MissionPath: "db/events.xml",
Label: "Dynamic events (events.xml)"},
{Kind: dayzxml.KindSpawnableTypes, Filename: "cfgspawnabletypes.xml", MissionPath: "cfgspawnabletypes.xml",
Label: "Spawnable attachments (cfgspawnabletypes.xml)"},
{Kind: dayzxml.KindEventSpawns, Filename: "cfgeventspawns.xml", MissionPath: "cfgeventspawns.xml",
Label: "Event spawn positions (cfgeventspawns.xml)"},
{Kind: dayzxml.KindRandomPresets, Filename: "cfgrandompresets.xml", MissionPath: "cfgrandompresets.xml",
Label: "Randomized presets (cfgrandompresets.xml)"},
{Kind: dayzxml.KindMessages, Filename: "messages.xml", MissionPath: "messages.xml",
Label: "Broadcast messages (messages.xml)"},
}
// handleDayzXmlFiles returns the list of supported CE XML files along with
// whether each one currently exists on the instance. The UI uses this to
// populate the target picker.
func (h *httpServer) handleDayzXmlFiles(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
mission := r.URL.Query().Get("mission")
if mission == "" {
mission = "dayzOffline.chernarusplus"
}
// Probe each file via a cheap FsRead to see if it exists. The panel
// usually mounts the game volume at /game; mpmissions lives there.
type fileStatus struct {
dayzCEFile
Exists bool `json:"exists"`
Size int `json:"size,omitempty"`
}
out := make([]fileStatus, 0, len(dayzKnownFiles))
for _, f := range dayzKnownFiles {
absPath := path.Join("/game/mpmissions", mission, f.MissionPath)
res, _ := h.dayzXmlReadFile(r.Context(), conn, id, absPath)
fs := fileStatus{dayzCEFile: f, Exists: res != nil && res.Error == ""}
if fs.Exists {
fs.Size = len(res.Content)
}
out = append(out, fs)
}
writeJSON(w, http.StatusOK, map[string]any{
"mission": mission,
"files": out,
})
}
// dayzXmlPreviewReq is the payload for the preview + commit endpoints.
type dayzXmlPreviewReq struct {
Mission string `json:"mission,omitempty"` // "dayzOffline.chernarusplus" (default)
Path string `json:"path,omitempty"` // full mission-relative path; overrides kind→default mapping
Kind string `json:"kind,omitempty"` // one of dayzxml.SupportedKinds() names; or infer from snippet
Snippet string `json:"snippet"` // the mod XML text
}
// handleDayzXmlPreview runs a dry-run merge and returns the Plan with
// counts + the first N entry names per bucket. No files are written.
func (h *httpServer) handleDayzXmlPreview(w http.ResponseWriter, r *http.Request) {
h.dayzXmlDoMerge(w, r, true)
}
// handleDayzXmlCommit runs the merge for real: reads base, computes merged
// bytes, writes a timestamped backup, overwrites the target file.
func (h *httpServer) handleDayzXmlCommit(w http.ResponseWriter, r *http.Request) {
h.dayzXmlDoMerge(w, r, false)
}
func (h *httpServer) dayzXmlDoMerge(w http.ResponseWriter, r *http.Request, dryRun bool) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
// Accept either application/json body (with snippet as a string) or
// raw application/xml (full body is the snippet, path/kind via query).
var req dayzXmlPreviewReq
ct := strings.ToLower(r.Header.Get("Content-Type"))
if strings.HasPrefix(ct, "application/xml") || strings.HasPrefix(ct, "text/xml") {
b, rerr := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
if rerr != nil {
writeError(w, http.StatusBadRequest, "bad_body", rerr.Error())
return
}
req.Snippet = string(b)
req.Mission = r.URL.Query().Get("mission")
req.Path = r.URL.Query().Get("path")
req.Kind = r.URL.Query().Get("kind")
} else {
if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
}
if strings.TrimSpace(req.Snippet) == "" {
writeError(w, http.StatusBadRequest, "empty_snippet", "snippet is required")
return
}
if req.Mission == "" {
req.Mission = "dayzOffline.chernarusplus"
}
// Determine the kind: explicit > infer from snippet root.
var kind dayzxml.FileKind
if req.Kind != "" {
kind = dayzxml.FileKind(req.Kind)
if _, ok := dayzxml.SpecFor(kind); !ok {
writeError(w, http.StatusBadRequest, "bad_kind", "unknown kind "+req.Kind)
return
}
} else {
k, derr := dayzxml.DetectKind([]byte(req.Snippet))
if derr != nil {
writeError(w, http.StatusBadRequest, "detect_kind", derr.Error())
return
}
kind = k
}
// Resolve the target file path.
targetPath := req.Path
if targetPath == "" {
for _, f := range dayzKnownFiles {
if f.Kind == kind {
targetPath = path.Join("/game/mpmissions", req.Mission, f.MissionPath)
break
}
}
if targetPath == "" {
writeError(w, http.StatusBadRequest, "no_default_path", "no canonical path for kind "+string(kind)+"; supply `path`")
return
}
}
// Read the existing base.
readRes, err := h.dayzXmlReadFile(r.Context(), conn, id, targetPath)
if err != nil {
writeError(w, http.StatusBadGateway, "fs_read", err.Error())
return
}
if readRes.Error != "" {
writeError(w, http.StatusNotFound, "base_missing", readRes.Error)
return
}
// Merge.
res, err := dayzxml.Merge(kind, readRes.Content, []byte(req.Snippet), dryRun)
if err != nil {
writeError(w, http.StatusBadRequest, "merge", err.Error())
return
}
resp := map[string]any{
"kind": string(kind),
"path": targetPath,
"plan": res.Plan,
}
if dryRun {
writeJSON(w, http.StatusOK, resp)
return
}
// Commit: back up base + write merged.
ts := time.Now().UTC().Format("20060102-150405")
backupPath := targetPath + ".bak." + ts
if err := h.dayzXmlWriteFile(r.Context(), conn, id, backupPath, readRes.Content); err != nil {
writeError(w, http.StatusBadGateway, "backup_failed", err.Error())
return
}
if err := h.dayzXmlWriteFile(r.Context(), conn, id, targetPath, res.Bytes); err != nil {
writeError(w, http.StatusBadGateway, "write_failed", err.Error())
return
}
resp["committed"] = true
resp["backup_path"] = backupPath
resp["bytes_written"] = len(res.Bytes)
writeJSON(w, http.StatusOK, resp)
}
// dayzXmlReadFile round-trips an FsRead through the agent gRPC bus. Returns
// the envelope result (including res.Error for missing files).
func (h *httpServer) dayzXmlReadFile(ctx context.Context, conn *agentConn, instanceID, absPath string) (*panelv1.FsReadResult, error) {
corrID := newCorrelationID("dzxml_read")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsRead{
FsRead: &panelv1.FsReadRequest{InstanceId: instanceID, Path: absPath},
},
}); err != nil {
return nil, err
}
env, err := h.pending.Await(ctx, corrID, 15*time.Second)
if err != nil {
return nil, err
}
return env.GetFsReadResult(), nil
}
func (h *httpServer) dayzXmlWriteFile(ctx context.Context, conn *agentConn, instanceID, absPath string, content []byte) error {
corrID := newCorrelationID("dzxml_write")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsWrite{
FsWrite: &panelv1.FsWriteRequest{InstanceId: instanceID, Path: absPath, Content: content},
},
}); err != nil {
return err
}
env, err := h.pending.Await(ctx, corrID, 30*time.Second)
if err != nil {
return err
}
res := env.GetFsWriteResult()
if res == nil {
return fmt.Errorf("agent returned no fs_write_result")
}
if res.Error != "" {
return fmt.Errorf("%s", res.Error)
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
)
// Discord integration for the panel — currently just the channel picker that
// Region Medic uses to choose where heal alerts post. The shared RefugeBot bot
// token lives on the controller host (default below, overridable via env) so the
// picker can enumerate the bot's channels. Posting the actual heal alerts happens
// agent-side (the medic sidecar), which has its own copy of the token.
func discordTokenPath() string {
if v := os.Getenv("PANEL_DISCORD_TOKEN_FILE"); v != "" {
return v
}
return "/home/refuge/panel/data/discord-bot-token"
}
func readDiscordToken() (string, error) {
b, err := os.ReadFile(discordTokenPath())
if err != nil {
return "", err
}
t := strings.TrimSpace(string(b))
if t == "" {
return "", fmt.Errorf("discord token file is empty")
}
return t, nil
}
// discordGet does an authenticated GET against the Discord API and decodes JSON.
func discordGet(ctx context.Context, token, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/api/v10"+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("discord %s: HTTP %d", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
type discordGuild struct {
ID string `json:"id"`
Name string `json:"name"`
}
type discordChannelRaw struct {
ID string `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Position int `json:"position"`
}
// getDiscordChannels lists the bot's text channels across every guild it's in,
// for the Region Medic alert-channel picker. Discord channel type 0 = text,
// 5 = announcement — both can receive messages.
func (h *httpServer) getDiscordChannels(w http.ResponseWriter, r *http.Request) {
token, err := readDiscordToken()
if err != nil {
writeError(w, http.StatusServiceUnavailable, "no_token", "Discord bot token not configured on the controller")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var guilds []discordGuild
if err := discordGet(ctx, token, "/users/@me/guilds", &guilds); err != nil {
writeError(w, http.StatusBadGateway, "discord", err.Error())
return
}
type chanDTO struct {
ID string `json:"id"`
Name string `json:"name"`
GuildID string `json:"guild_id"`
GuildName string `json:"guild_name"`
Label string `json:"label"`
}
out := []chanDTO{}
for _, g := range guilds {
var chans []discordChannelRaw
if err := discordGet(ctx, token, "/guilds/"+g.ID+"/channels", &chans); err != nil {
continue // skip a guild whose channels we can't read
}
sort.Slice(chans, func(i, j int) bool { return chans[i].Position < chans[j].Position })
for _, c := range chans {
if c.Type != 0 && c.Type != 5 {
continue
}
out = append(out, chanDTO{
ID: c.ID, Name: c.Name, GuildID: g.ID, GuildName: g.Name,
Label: g.Name + " / #" + c.Name,
})
}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": out})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,754 @@
## Line format: Item_x: name , data: count[-range], xdata: probability (x incrementing, if using a count-range use double quotes)
{ +LootGroup Name: empty
}
## New Escape Pod settings: Difficulty Levels
{ +LootGroup Name: EscapePodEasy
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_4: MobileAirCon, param: 1
# Item_5: OxygenGeneratorSmall, param: 1
# Item_6: OreScanner, param: 1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 3
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 250
# Item_3: Explosives, param1: 5
# Item_4: Drill, param1: 1
# Item_5: Chainsaw, param1: 1
# Item_6: BioFuel, param1: 5
# Item_7: ConstructorSurvival, param1: 1
# Item_8: PlayerBikeKit, param1: 1
# Item_9: AutoMinerCore, param1: 10
# Item_10: Medikit02, param1: 3
# Item_11: AntibioticPills, param1: 3
# Item_12: RadiationPills, param1: 2
# Item_13: EmergencyRations, param1: 4
# Item_14: WaterBottle, param1: 2
# Item_15: RespiratorCharge, param1: 5
# Item_16: SiliconIngot, param1: 200
# Item_17: CopperIngot, param1: 300
# Item_18: IronIngot, param1: 350
# Item_19: CobaltIngot, param1: 175
# Item_20: MagnesiumPowder, param1: 50
# Item_21: PromethiumPellets, param1: 350
# Item_22: EnergyCell, param1: 5
# Item_23: EVABoost, param1: 1
# Item_24: OxygenBottleLarge, param1: 5
# Item_25: OxygenGenerator, param1: 1
# Item_26: Core, param1: 1
# Item_27: GrowingPot, param1: 2
# Item_28: LightPlant01, param1: 1
# Item_29: TomatoStage1, param1: 1
# Item_30: WheatStage1, param1: 1
# Item_31: CornStage1, param1: 1
# Item_32: PumpkinStage1, param1: 1
# Item_33: PearthingStage1, param1: 1
# Item_34: DurianRoot, param1: 1
}
{ +LootGroup Name: EscapePodMedium
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 2
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 150
# Item_3: Drill, param1: 1
# Item_4: Chainsaw, param1: 1
# Item_5: BioFuel, param1: 4
# Item_6: ConstructorSurvival, param1: 1
# Item_7: OxygenGeneratorSmall, param1: 1
# Item_8: EnergyCell, param1: 3
# Item_9: RespiratorCharge, param1: 2
# Item_10: WaterBottle, param1: 2
# Item_11: Medikit02, param1: 2
# Item_12: EmergencyRations, param1: 2
# Item_13: StomachPills, param1: 1
# Item_14: SiliconIngot, param1: 50
# Item_15: CopperIngot, param1: 50
# Item_16: IronIngot, param1: 10
# Item_17: CobaltIngot, param1: 50
# Item_18: MagnesiumPowder, param1: 30
# Item_19: PromethiumPellets, param1: 50
# Item_20: Core, param1: 1
# Item_21: PlayerBikeKit, param1: 1
# Item_22: AutoMinerCore, param1: 8
# Item_23: EVABoost, param1: 1
}
{ +LootGroup Name: EscapePodHard
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 1
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 150
# Item_3: Drill, param1: 1
# Item_4: Chainsaw, param1: 1
# Item_5: BioFuel, param1: 2
# Item_6: ConstructorSurvival, param1: 1
# Item_7: OxygenGeneratorSmall, param1: 1
# Item_8: EnergyCell, param1: 3
# Item_9: EmergencyRations, param1: 1
# Item_10: PlayerBikeKit, param1: 1
# Item_11: AutoMinerCore, param1: 5
}
## Escape pod - New Start
{ +LootGroup Name: StartingEquipment_NewStart
# Count: all
# Item_0: Drill, param1: 1
# Item_1: Chainsaw, param1: 1
# Item_2: BioFuel, param1: 2
# Item_3: ConstructorSurvival, param1: 1
# Item_4: OxygenGeneratorSmall, param1: 1
}
## Random Loot
{ +LootGroup Name: BasicComponents
Count: "1,2"
Item_0: SteelPlate, param1: "1,5", param2: 0.3
Item_1: GlassPlate, param1: "1,2", param2: 0.3
Item_2: Electronics, param1: "1,2", param2: 0.2
Item_3: OpticalFiber, param1: "1,2", param2: 0.3
Item_4: EnergyMatrix, param1: "1,2", param2: 0.3
# Item_5: MetalPieces, param1: "1,5", param2: 0.3
}
{ +LootGroup Name: CompositeComponents
Count: "1,2"
Item_0: MechanicalComponents, param1: "1,5", param2: 0.4
Item_1: Motor, param1: "1,2", param2: 0.2
Item_2: Nanotubes, param1: "1,5", param2: 0.2
Item_3: Computer, param1: "1,2", param2: 0.1
Item_4: CapacitorComponent, param1: "1,2", param2: 0.01
Item_5: CobaltAlloy, param1: 1, param2: 0.01
}
# not yet dropped
{ +LootGroup Name: AdvancedComponents
Count: "1"
Item_0: FluxCoil, param1: "1,3", param2: 0.2
Item_1: Oscillator, param1: "1,2", param2: 0.2
}
{ +LootGroup Name: Ores
Count: "1,2"
Item_0: IronOre, param1: "10,20", param2: 0.4
Item_1: CobaltOre, param1: "10,20", param2: 0.4
Item_2: CopperOre, param1: "10,15", param2: 0.3
Item_3: SiliconOre, param1: "10,15", param2: 0.3
Item_4: NeodymiumOre, param1: "10,15", param2: 0.2
Item_5: TitanOre, param1: "10,15", param2: 0.2
Item_6: MagnesiumOre, param1: "10,15", param2: 0.1
Item_7: PromethiumOre, param1: "10,15", param2: 0.1
# Item_0: ErestrumOre, param1: "1,20", param2: 0.2
# Item_10: ZascosiumOre, param1: "1,20", param2: 0.2
}
{ +LootGroup Name: Ingots
Count: "1,2"
Item_0: IronIngot, param1: "10,20", param2: 0.5
Item_1: CobaltIngot, param1: "10,20", param2: 0.4
Item_2: CopperIngot, param1: "10,15", param2: 0.3
Item_3: SiliconIngot, param1: "10,15", param2: 0.2
Item_4: NeodymiumIngot, param1: "10,15", param2: 0.1
Item_5: TitanRods, param1: "10,15", param2: 0.1
Item_6: MagnesiumPowder, param1: "10,15", param2: 0.1
Item_7: PromethiumPellets, param1: "10,20", param2: 0.2
Item_8: PlasticMaterial, param1: "10,20", param2: 0.3
# Item_0: ErestrumIngot, param1: "1,10", param2: 0.2
# Item_0: ZascosiumIngot, param1: "1,10", param2: 0.2
}
{ +LootGroup Name: Sprouts
Count: "1,2"
Item_0: DurianRoot, param1: 1, param2: 0.2
Item_1: TomatoStage1, param1: 1, param2: 0.2
Item_2: WheatStage1, param1: 1, param2: 0.2
Item_3: CornStage1, param1: 1, param2: 0.2
Item_4: PumpkinStage1, param1: 1, param2: 0.2
Item_5: PearthingStage1, param1: 1, param2: 0.2
Item_6: InsanityPepperStage1, param1: 1, param2: 0.2
Item_7: DesertPlant20Stage1, param1: 1, param2: 0.2
}
# TEST - nested groups not yet working
# { +LootGroup Name: PistolAmmo
# Count: all
# Item_0: Pistol, param1: 1
# Item_1: 50Caliber, param1: "10,20"
# }
# { +LootGroup Name: ShotgunAmmo
# Count: all
# Item_0: Shotgun, param1: 1
# Item_1: ShotgunShells, param1: "5,10"
# }
# { +LootGroup Name: AssaultRifleAmmo
# Count: all
# Item_0: AssaultRifle, param1: 1
# Item_1: 5.8mmBullet, param1: "20,30"
# }
# { +LootGroup Name: SniperAmmo
# Count: all
# Item_0: Sniper, param1: 1
# Item_1: 12.7mmBullet, param1: "5,12"
# }
# { +LootGroup Name: WeaponsBasic
# Count: "1,2"
# Group_0: PistolAmmo, param1: 1, param2: 0.3
# Group_1: ShotgunAmmo, param1: 1, param2: 0.3
# Group_2: AssaultRifleAmmo, param1: 1, param2: 0.3
# Group_3: SniperAmmo, param1: 1, param2: 0.3
# }
## NEW LOOT GROUPS
## Common
{ +LootGroup Name: OresBasic
Count: "1,2"
Item_0: IronOre, param1: "5,12", param2: 0.5
Item_1: CopperOre, param1: "5,15", param2: 0.6
Item_2: SiliconOre, param1: "5,20", param2: 0.7
Item_3: PlasticMaterial, param1: "10,40", param2: 0.9
}
## Hishkal
{ +LootGroup Name: HishkalOresBasic
Count: "2,2"
Item_0: IronOre, param1: "2,3", param2: 0.5
Item_1: CopperOre, param1: "2,3", param2: 0.6
Item_2: SiliconOre, param1: "2,3", param2: 0.7
# Item_3: PlasticMaterial, param1: "10,40", param2: 0.9
}
{ +LootGroup Name: OresAdvanced
Count: "1,2"
Item_0: CobaltOre, param1: "5,10", param2: 0.6
Item_1: NeodymiumOre, param1: "5,10", param2: 0.6
Item_2: MagnesiumOre, param1: "5,8", param2: 0.4
Item_3: TitanOre, param1: "5,8", param2: 0.5
}
{ +LootGroup Name: OresRare
Count: "1"
Item_0: ErestrumOre, param1: "3,5", param2: 0.4
Item_1: ZascosiumOre, param1: "3,5", param2: 0.2
Item_2: SathiumOre, param1: "5,8", param2: 0.4
Item_3: PromethiumOre, param1: "5,8", param2: 0.4
}
{ +LootGroup Name: IngotsBasic
Count: "1,2"
Item_0: IronIngot, param1: "5,20", param2: 0.4
Item_1: CobaltIngot, param1: "5,20", param2: 0.4
Item_2: CopperIngot, param1: "5,15", param2: 0.3
Item_3: SiliconIngot, param1: "5,15", param2: 0.3
Item_4: NeodymiumIngot, param1: "5,15", param2: 0.3
Item_5: TitanRods, param1: "5,15", param2: 0.3
}
{ +LootGroup Name: ComponentsBasic
Count: "1,2"
Item_0: SteelPlate, param1: "5,15", param2: 0.3
Item_1: Electronics, param1: "2,5", param2: 0.2
Item_2: OpticalFiber, param1: "2,5", param2: 0.3
Item_3: EnergyMatrix, param1: "2,5", param2: 0.3
Item_4: GlassPlate, param1: "5,10", param2: 0.3
Item_5: MechanicalComponents, param1: "5,10", param2: 0.4
Item_6: Motor, param1: "2,5", param2: 0.2
Item_7: Nanotubes, param1: "5,10", param2: 0.2
Item_8: Computer, param1: "1,2", param2: 0.1
# Item_9: MetalPieces, param1: "5,10", param2: 0.3
}
{ +LootGroup Name: CraftingMaterial
Count: "1,2"
Item_0: Cement, param1: "5,10", param2: 0.3
Item_1: WoodPlanks, param1: "5,10", param2: 0.3
Item_2: PlasticMaterial, param1: "5,10", param2: 0.2
Item_3: XenoSubstrate, param1: "5,10", param2: 0.3
Item_4: WaterBottle, param1: "5,10", param2: 0.3
Item_5: RockDust, param1: "5,10", param2: 0.3
Item_6: PromethiumPellets, param1: "30,100", param2: 0.3
Item_7: HydrogenBottle, param1: "1,5", param2: 0.3
Item_8: MagnesiumPowder, param1: "5,10", param2: 0.3
}
{ +LootGroup Name: EnergyItems
Count: "1,2"
Item_0: RespiratorCharge, param1: "2,5", param2: 0.3
Item_1: OxygenBottleLarge, param1: "2,5", param2: 0.3
Item_2: WaterJug, param1: "1,3", param2: 0.3
Item_3: EnergyCell, param1: "5,10", param2: 0.3
Item_4: EnergyCellLarge, param1: "2,5", param2: 0.3
Item_5: FusionCell, param1: "1,2", param2: 0.3
}
{ +LootGroup Name: BuildingBlocks
Count: "1,2"
Item_0: HullSmallBlocks, param1: "10,15", param2: 0.1
Item_1: HullArmoredSmallBlocks, param1: "5,10", param2: 0.05
Item_2: HullLargeBlocks, param1: "10,15", param2: 0.05
Item_3: HullArmoredLargeBlocks, param1: "5,10", param2: 0.03
Item_4: HullCombatLargeBlocks, param1: "5,10", param2: 0.01
Item_5: ConcreteBlocks, param1: "10,15", param2: 0.2
Item_6: WoodBlocks, param1: "10,15", param2: 0.3
Item_7: TrussSmallBlocks, param1: "10,15", param2: 0.2
Item_8: TrussLargeBlocks, param1: "10,15", param2: 0.2
Item_9: StairsBlocks, param1: "5,10", param2: 0.3
Item_10: WindowSmallBlocks, param1: "5,10", param2: 0.3
Item_11: WindowLargeBlocks, param1: "5,10", param2: 0.1
Item_12: WindowArmoredSmallBlocks, param1: "5,10", param2: 0.3
Item_13: WindowArmoredLargeBlocks, param1: "5,10", param2: 0.1
Item_14: WalkwayLargeBlocks, param1: "5,10", param2: 0.3
Item_15: DecoBlocks, param1: "5,10", param2: 0.3
Item_16: ConsoleBlocks, param1: "5,10", param2: 0.3
Item_17: IndoorPlants, param1: "2,5", param2: 0.5
Item_18: GrowingPot, param1: "2,5", param2: 0.5
}
{ +LootGroup Name: Tools
Count: "1,2"
Item_0: Flashlight, param1: 1, param2: 0.1
# Item_3: Drill, param1: 1, param2: 0.1
Item_1: Chainsaw, param1: 1, param2: 0.1
Item_2: BioFuel, param1: "2,5", param2: 0.1
# Item_3: MultiTool, param1: 1, param2: 0.1
Item_3: MultiCharge, param1: "1,5", param2: 0.1
Item_4: DrillT2, param1: 1, param2: 0.02
Item_5: DrillCharge, param1: "1,5", param2: 0.1
Item_6: MultiToolT2, param1: 1, param2: 0.02
}
{ +LootGroup Name: WeaponsBasic
Count: "1,2"
Item_0: Pistol, param1: 1, param2: 0.3
Item_1: Shotgun, param1: 1, param2: 0.3
Item_2: AssaultRifle, param1: 1, param2: 0.3
Item_3: Sniper, param1: 1, param2: 0.3
# Item_1: 50Caliber, param1: "10,20", param2: 0.3
# Item_5: 5.8mmBullet, param1: "20,30", param2: 0.3
# Item_7: 12.7mmBullet, param1: "5,12", param2: 0.3
}
{ +LootGroup Name: WeaponsBasicKit
Count: "1"
Item_0: PistolKit, param1: 1, param2: 0.3
Item_1: ShotgunKit, param1: 1, param2: 0.2
Item_2: RifleKit, param1: 1, param2: 0.3
Item_3: SniperKit, param1: 1, param2: 0.1
}
{ +LootGroup Name: Seeds
Count: "2,3"
Item_0: DurianRoot, param1: "2,5", param2: 0.3
Item_1: CornStage1, param1: "2,5", param2: 0.3
Item_2: PumpkinStage1, param1: "2,5", param2: 0.3
Item_3: TomatoStage1, param1: "2,5", param2: 0.3
Item_4: WheatStage1, param1: "2,5", param2: 0.3
Item_5: PearthingStage1, param1: "2,5", param2: 0.3
Item_6: InsanityPepperStage1, param1: "2,5", param2: 0.3
Item_7: AlienPlantTube2Stage1, param1: "2,5", param2: 0.3
Item_8: DesertPlant20Stage1, param1: "2,5", param2: 0.3
Item_9: ElderberryStage1, param1: "2,5", param2: 0.3
Item_10: AlienPalmTreeStage1, param1: "2,5", param2: 0.3
Item_11: AlienplantWormStage1, param1: "2,5", param2: 0.3
Item_12: BulbShroomYoungStage1, param1: "2,5", param2: 0.3
# Item_13: GrowingPot, param1: "2,4", param2: 0.3
# Item_14: NutrientSolution, param1: "2,5", param2: 0.3
}
{ +LootGroup Name: MedicalItems
Count: "1"
Item_0: Medikit04, param1: "1,2", param2: 0.2
Item_1: AntibioticOintment, param1: "2,3", param2: 0.8
Item_2: AntibioticPills, param1: "1,2", param2: 0.6
Item_3: AntiToxicOintment, param1: "2,3", param2: 0.7
Item_4: AntiToxicPills, param1: "1,2", param2: 0.5
Item_5: AntiRadiationOintment, param1: "2,3", param2: 0.6
Item_6: AntiParasitePills, param1: "1,2", param2: 0.3
Item_7: StomachPills, param1: "2,3", param2: 0.8
Item_8: RadiationPills, param1: "1,2", param2: 0.4
Item_9: AntibioticInjection, param1: "1", param2: 0.4
Item_10: AntiToxicInjection, param1: "1", param2: 0.3
Item_11: AntiRadiationInjection, param1: "1", param2: 0.2
Item_12: AntiParasiteInjection, param1: "1", param2: 0.1
}
{ +LootGroup Name: Medikits
Count: "1,2"
Item_0: Medikit01, param1: "1", param2: 0.6
Item_1: Medikit02, param1: "1", param2: 0.4
Item_2: Medikit03, param1: "1", param2: 0.2
Item_3: Medikit04, param1: "3,4", param2: 0.8
}
## Common
{ +LootGroup Name: DevicesCommon
Count: "1,2"
Item_0: GeneratorBA, param1: 1, param2: 0.05
Item_1: FuelTankMSSmall, param1: 1, param2: 0.1
Item_2: WaterGenerator, param1: 1, param2: 0.3
Item_3: OxygenTankSmallMS, param1: 1, param2: 0.3
Item_4: OxygenStation, param1: 1, param2: 0.3
Item_5: OxygenGenerator, param1: 1, param2: 0.3
Item_6: FridgeMS, param1: 1, param2: 0.3
Item_7: FoodProcessorV2, param1: 1, param2: 0.08
Item_8: LightLargeBlocks, param1: 1, param2: 0.3
Item_9: ConstructorT1V2, param1: 1, param2: 0.03
Item_10: ContainerAmmoSmall, param1: 1, param2: 0.3
Item_11: DoorBlocks, param1: "2,4", param2: 0.3
Item_12: CargoContainerSmall, param1: 1, param2: 0.3
Item_13: ElevatorMS, param1: "2,4", param2: 0.3
Item_14: Core, param1: 1, param2: 0.01
Item_15: HangarDoorBlocks, param1: 1, param2: 0.1
Item_16: ShutterDoorLargeBlocks, param1: 1, param2: 0.3
Item_17: TurretBaseCannon, param1: 1, param2: 0.05
Item_18: CloneChamber, param1: 1, param2: 0.3
}
## Rare
{ +LootGroup Name: DevicesRare
Count: "1"
Item_0: GeneratorMS, param1: 1, param2: 0.05
Item_1: FuelTankMSLarge, param1: 1, param2: 0.1
Item_2: ConstructorT2, param1: 1, param2: 0.03
Item_3: ContainerAmmoLarge, param1: 1, param2: 0.3
Item_4: DoorArmoredBlocks, param1: "2,4", param2: 0.3
Item_5: FuelTankMSLarge, param1: 1, param2: 0.1
Item_6: FuelTankMSLargeT2, param1: 1, param2: 0.1
Item_7: RepairBayBA, param1: 1, param2: 0.01
Item_8: GeneratorMST2, param1: 1, param2: 0.01
Item_9: OfflineProtector, param1: 1, param2: 0.3
Item_10: ATM, param1: 1, param2: 0.3
}
## Rare Vessel
{ +LootGroup Name: DevicesVesselRare
Count: "1"
Item_0: GeneratorSV, param1: 1, param2: 0.01
Item_1: FuelTankSV, param1: 1, param2: 0.1
Item_2: RCSBlockSV, param1: 1, param2: 0.01
Item_3: RCSBlockGV, param1: 1, param2: 0.01
Item_4: OxygenTankSV, param1: 1, param2: 0.3
Item_5: FridgeSV, param1: 1, param2: 0.1
Item_6: DoorBlocksSV, param1: 1, param2: 0.3
Item_7: SpotlightSSCube, param1: 1, param2: 0.3
Item_8: ContainerAmmoSmall, param1: 1, param2: 0.3
Item_9: PassengerSeatSV, param1: 1, param2: 0.3
Item_10: PassengerSeat2SV, param1: 1, param2: 0.3
Item_11: ThrusterGVDirectional, param1: 1, param2: 0.02
Item_12: ThrusterSVDirectional, param1: 1, param2: 0.02
Item_13: ThrusterSVRoundBlocks, param1: 1, param2: 0.01
Item_14: ThrusterGVRoundBlocks, param1: 1, param2: 0.01
Item_15: DockingPad, param1: 1, param2: 0.3
Item_16: WeaponSV02, param1: 1, param2: 0.3
Item_17: TurretGVMinigun, param1: 1, param2: 0.01
Item_18: TurretGVRocket, param1: 1, param2: 0.005
Item_20: TurretGVPlasma, param1: 1, param2: 0.001
}
{ +LootGroup Name: ComponentsRare
Count: "1"
Item_0: CapacitorComponent, param1: "1,2", param2: 0.3
Item_1: CobaltAlloy, param1: "1,2", param2: 0.3
Item_2: Oscillator, param1: "1,2", param2: 0.3
Item_3: FluxCoil, param1: "1,2", param2: 0.3
Item_4: PowerCoil, param1: "1,2", param2: 0.3
}
{ +LootGroup Name: FoodItems
Count: "1"
Item_0: CannedVegetables, param1: "2,4", param2: 0.3
Item_1: CannedMeat, param1: "2,4", param2: 0.3
Item_3: EmergencyRations, param1: "1,2", param2: 0.1
}
{ +LootGroup Name: WeaponsRare
Count: "1"
Item_0: PistolT2, param1: 1, param2: 0.1
Item_1: Shotgun2, param1: 1, param2: 0.1
Item_2: PulseRifle, param1: 1, param2: 0.1
Item_3: Sniper2, param1: 1, param2: 0.1
Item_4: LaserPistol, param1: 1, param2: 0.05
Item_5: LaserRifle, param1: 1, param2: 0.03
Item_6: RocketLauncher, param1: 1, param2: 0.01
# Item_1: 50Caliber, param1: "10,20", param2: 0.1
# Item_3: ShotgunShells, param1: "5,10", param2: 0.1
# Item_6: 5.8mmBullet, param1: "20,30", param2: 0.1
# Item_3: PulseLaserChargePistol, param1: "10,20"
# Item_8: 12.7mmBullet, param1: "5,12", param2: 0.1
# Item_10: SlowRocket, param1: "5,10", param2: 0.1
}
{ +LootGroup Name: WeaponsRareKit
Count: "1"
Item_0: HeavyWeaponKit, param1: 1, param2: 0.01
Item_1: LaserKit, param1: 1, param2: 0.01
}
{ +LootGroup Name: IngotsRare
Count: "1"
Item_0: ErestrumIngot, param1: "5,10", param2: 0.2
Item_1: SathiumIngot, param1: "5,10", param2: 0.2
Item_2: ZascosiumIngot, param1: "5,10", param2: 0.2
}
{ +LootGroup Name: LootGoldIngot
Count: 1
Item_0: GoldIngot, param1: "1,2", param2: 0.2
}
{ +LootGroup Name: LootGoldCoins
Count: 1
Item_0: GoldIngot, param1: "10,15", param2: 0.2
}
{ +LootGroup Name: LootMoneyCard
Count: 1
Item_0: MoneyCard, param1: "100,500", param2: 0.2
}
## Very Rare
{ +LootGroup Name: DevicesVeryRare
Count: "1"
Item_0: GeneratorMS, param1: 1, param2: 0.05
Item_1: FuelTankMSLarge, param1: 1, param2: 0.3
Item_2: FridgeMS02, param1: 1, param2: 0.3
Item_3: OxygenTankMS, param1: 1, param2: 0.3
Item_4: ConstructorSurvival, param1: 1, param2: 0.3
Item_5: ConstructorT2, param1: 1, param2: 0.03
Item_6: GravityGeneratorMS, param1: 1, param2: 0.3
Item_7: ContainerAmmoLarge, param1: 1, param2: 0.3
Item_8: MedicinelabMS, param1: 1, param2: 0.3
Item_9: GeneratorMST2, param1: 1, param2: 0.01
Item_10: TurretBaseRocket, param1: 1, param2: 0.03
Item_11: TurretBaseMinigun, param1: 1, param2: 0.02
Item_12: TurretBasePlasma, param1: 1, param2: 0.01
Item_13: TurretBaseFlak, param1: 1, param2: 0.02
Item_14: TurretBaseArtillery, param1: 1, param2: 0.01
Item_15: TurretBasePulseLaser, param1: 1, param2: 0.01
}
{ +LootGroup Name: DevicesVesselVeryRare
Count: "1"
Item_0: HoverEngineSmall, param1: 1, param2: 0.3
Item_1: SawAttachment, param1: 1, param2: 0.3
Item_2: TurretGVPlasma, param1: 1, param2: 0.02
Item_3: TurretGVArtillery, param1: 1, param2: 0.03
Item_4: ThrusterSVRoundBlocks, param1: 1, param2: 0.1
Item_5: LandinggearBlocksSV, param1: 1, param2: 0.3
Item_6: LandinggearBlocksHeavySV, param1: 1, param2: 0.3
Item_7: HoverEngineLarge, param1: 1, param2: 0.01
Item_8: WeaponSV01, param1: 1, param2: 0.008
Item_9: WeaponSV03, param1: 1, param2: 0.008
Item_10: WeaponSV04, param1: 1, param2: 0.008
Item_11: WeaponSV05, param1: 1, param2: 0.008
}
{ +LootGroup Name: DevicesCVVeryRare
Count: "1"
Item_0: ThrusterMSRoundBlocks, param1: 1, param2: 0.03
Item_1: RCSBlockMS, param1: 1, param2: 0.01
Item_2: RepairBayCV, param1: 1, param2: 0.03
Item_3: ThrusterMSRound2x2Blocks, param1: 1, param2: 0.02
Item_4: ThrusterMSRound3x3Blocks, param1: 1, param2: 0.01
Item_5: LandinggearBlocksCV, param1: 1, param2: 0.3
Item_6: WeaponMS01, param1: 1, param2: 0.03
Item_7: WeaponMS02, param1: 1, param2: 0.03
Item_8: TurretMSProjectileBlocks, param1: 1, param2: 0.008
Item_9: TurretMSLaserBlocks, param1: 1, param2: 0.005
Item_10: TurretMSRocketBlocks, param1: 1, param2: 0.005
Item_11: TurretMSArtilleryBlocks, param1: 1, param2: 0.001
Item_12: TurretMSToolBlocks, param1: 1, param2: 0.3
Item_13: RCSBlockMS_T2, param1: 1, param2: 0.001
}
{ +LootGroup Name: WeaponsVeryRare
Count: "1"
Item_0: Minigun, param1: 1
Item_1: LaserPistolT2, param1: 1
Item_2: LaserRifle, param1: 1
Item_3: ScifiCannon, param1: 1
Item_4: RocketLauncherT2, param1: 1
# Item_1: 8.3mmBullet, param1: "50,150"
# Item_3: PulseLaserChargePistol, param1: "10,20"
# Item_5: PulseLaserChargeRifle, param1: "20,30"
# Item_9: SlowRocket, param1: "5,10"
}
{ +LootGroup Name: WeaponsEpic
Count: 1
Item_0: AssaultRifleEpic, param1: 1, param2: 1
Item_1: Sniper2Epic, param1: 1, param2: 0.6
Item_2: MinigunEpic, param1: 1, param2: 0.5
Item_3: Shotgun2Epic, param1: 1, param2: 0.7
Item_4: LaserRifleEpic, param1: 1, param2: 0.7
Item_5: PistolEpic, param1: 1, param2: 1
Item_6: ScifiCannonEpic, param1: 1, param2: 0.2
Item_7: RocketLauncherEpic, param1: 1, param2: 0.3
Item_8: PulseRifleEpic, param1: 1, param2: 1
Item_9: DrillEpic, param1: 1, param2: 0.1
Item_10: ArmorHeavyEpic, param1: 1, param2: 0.1
}
{ +LootGroup Name: AutoMinerCore
Count: 1
Item_0: AutoMinerCore, param1: "1,3"
}
{ +LootGroup Name: TroopersFood
Count: 1
Item_0: CannedVegetables, param1: "1,2", param2: 0.4
Item_1: CannedMeat, param1: "1", param2: 0.4
Item_2: PowerBar, param1: "2,4", param2: 0.7
Item_3: WaterBottle, param1: "1,2", param2: 0.9
Item_4: EnergyDrink, param1: "1,2", param2: 0.8
Item_5: EmergencyRations, param1: "1", param2: 0.1
}
{ +LootGroup Name: CiviliansFood
Count: 1
Item_0: WaterBottle, param1: "1,2", param2: 0.5
Item_1: FruitJuice, param1: "1,2", param2: 0.5
Item_2: BerryJuice, param1: "1,2", param2: 0.5
Item_3: VegetableJuice, param1: "1,2", param2: 0.5
Item_4: EnergyDrink, param1: "1,2", param2: 0.5
Item_5: HotBeverage, param1: "1,2", param2: 0.5
Item_6: Milk, param1: "1,2", param2: 0.5
Item_7: Beer, param1: "1", param2: 0.5
Item_8: AkuaWine, param1: "1", param2: 0.5
Item_9: Sandwich, param1: "1", param2: 0.5
Item_10: FriedVegetables, param1: "1", param2: 0.5
Item_11: VeggieBurger, param1: "1", param2: 0.5
Item_12: MeatBurger, param1: "1", param2: 0.5
Item_13: Fruit, param1: "2,4", param2: 0.5
Item_14: Berries, param1: "2,4", param2: 0.5
Item_15: Waffles, param1: "1", param2: 0.5
Item_16: FruitPie, param1: "1", param2: 0.5
Item_17: Cereals, param1: "1", param2: 0.5
Item_18: Cheese, param1: "1", param2: 0.5
Item_19: Bread, param1: "1", param2: 0.5
Item_20: AnniversaryCake, param1: "1", param2: 0.1
}
{ +LootGroup Name: Biological01
Count: "2,3"
Item_0: Spice, param1: "1,3", param2: 0.7
Item_1: AlienThorn, param1: "1,3", param2: 0.7
Item_2: Varonroot, param1: "1,3", param2: 0.7
Item_3: HerbalLeaves, param1: "1,3", param2: 0.7
Item_4: PlantProtein, param1: "1,3", param2: 0.7
Item_5: PhoenixFernFonds, param1: "1,3", param2: 0.7
Item_6: MushroomBrown, param1: "1,3", param2: 0.7
Item_7: MushroomSpiky, param1: "1,3", param2: 0.7
Item_8: NaturalSweetener, param1: "1,3", param2: 0.7
}
{ +LootGroup Name: SuitBooster
Count: "1"
Item_0: ArmorBoost, param1: 1, param2: 0.1
Item_1: JetpackBoost, param1: 1, param2: 0.1
Item_2: MultiBoost, param1: 1, param2: 0.1
Item_3: OxygenBoost, param1: 1, param2: 0.1
Item_4: InsulationBoost, param1: 1, param2: 0.1
Item_5: MobilityBoost, param1: 1, param2: 0.1
Item_6: RadiationBoost, param1: 1, param2: 0.1
Item_7: EVABoost, param1: 1, param2: 0.1
}
{ +LootGroup Name: SmallOptronics
Count: "1"
Item_0: SmallOptronicBridge, param1: 1, param2: 0.5
Item_1: SmallOptronicMatrix, param1: 1, param2: 0.1
}
{ +LootGroup Name: LargeOptronics
Count: "1"
Item_0: LargeOptronicBridge, param1: 1, param2: 0.5
Item_1: LargeOptronicMatrix, param1: 1, param2: 0.1
}
{ +LootGroup Name: FireSticks
Count: "1"
Item_0: FireStickRed, param1: 1, param2: 0.1
Item_1: FireStickBlue, param1: 1, param2: 0.5
Item_2: FireStickGreen, param1: 1, param2: 0.9
}
{ +LootGroup Name: KeyCards
Count: "1"
Item_0: KeyCardCommon, param1: 1, param2: 0.9
Item_1: KeyCardScience, param1: 1, param2: 0.7
Item_2: KeyCardExploration, param1: 1, param2: 0.5
Item_3: KeyCardSecurity, param1: 1, param2: 0.3
Item_4: KeyCardMilitary, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsCommon
Count: "1"
Item_0: ReportMaintenanceData, param1: 1, param2: 0.9
Item_1: ReportWorkShiftData, param1: 1, param2: 0.7
Item_2: ReportTransportationData, param1: 1, param2: 0.5
Item_3: ReportCommunicationData, param1: 1, param2: 0.3
Item_4: ReportEconomicData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsScience
Count: "1"
Item_0: ReportScienceRawData, param1: 1, param2: 0.9
Item_1: ReportBiologicalData, param1: 1, param2: 0.7
Item_2: ReportMaterialData, param1: 1, param2: 0.5
Item_3: ReportArcheologicalData, param1: 1, param2: 0.3
Item_4: ReportAnomalousData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsExploration
Count: "1"
Item_0: ReportExplorationRawData, param1: 1, param2: 0.9
Item_1: ReportGeologicalData, param1: 1, param2: 0.7
Item_2: ReportAtmosphericData, param1: 1, param2: 0.5
Item_3: ReportAstronomicalData, param1: 1, param2: 0.3
Item_4: ReportPlanetaryResourceData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsSecurity
Count: "1"
Item_0: ReportSpaceWeather, param1: 1, param2: 0.9
Item_1: ReportNavigationData, param1: 1, param2: 0.7
Item_2: ReportSpaceAnomality, param1: 1, param2: 0.5
Item_3: ReportWarpSignatures, param1: 1, param2: 0.3
Item_4: ReportWeaponSignatures, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsMilitary
Count: "1"
Item_0: ReportMilitaryRawData, param1: 1, param2: 0.9
Item_1: ReportMilitaryScouting, param1: 1, param2: 0.7
Item_2: ReportMilitarySurveillance, param1: 1, param2: 0.5
Item_3: ReportMilitaryTroopMovement, param1: 1, param2: 0.3
Item_4: ReportMilitaryClassified, param1: 1, param2: 0.01
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

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