panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -0,0 +1,527 @@
|
||||
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) {
|
||||
// Service callers (on-host loopback, or a valid admin-token header)
|
||||
// are admitted without a session. But downstream handlers like
|
||||
// createToken / handleMe read the caller's identity from context and
|
||||
// 401 if it's absent — so we MUST stash an identity, or those admit
|
||||
// paths break (this was the fresh-install auto-pair failure). Prefer
|
||||
// a real session user if a cookie happens to be present; otherwise
|
||||
// synthesize a service-admin identity.
|
||||
admit := func() {
|
||||
if u, err := h.auth.userFromRequest(r); err == nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), sessionUserKey{}, u))
|
||||
} else {
|
||||
r = r.WithContext(context.WithValue(r.Context(), sessionUserKey{}, serviceAdminUser))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
if isLoopback(r.RemoteAddr) {
|
||||
admit()
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get(adminTokenHeader); got != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
|
||||
admit()
|
||||
return
|
||||
}
|
||||
sessionGate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serviceAdminUser is the synthetic identity given to service callers
|
||||
// (loopback / admin-token) that don't carry a session cookie. ID 0 never
|
||||
// collides with a real DB user (serial starts at 1); handlers that only
|
||||
// check Role == "admin" (createToken, user creation) accept it.
|
||||
var serviceAdminUser = &db.UserRow{ID: 0, Email: "service@panel.local", Role: "admin"}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
@@ -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, ®); 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 = ®.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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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
|
||||
steamRealmWarned sync.Once
|
||||
loginLimiter *loginLimiter // brute-force guard for handleLogin
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
ip := normalizeIP(clientIP(r))
|
||||
if ok, retry := a.loginLimiter.Allowed(ip, req.Email); !ok {
|
||||
secs := int(retry.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited",
|
||||
"too many failed login attempts; try again later")
|
||||
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")
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "db", err.Error())
|
||||
return
|
||||
}
|
||||
if u.Disabled {
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
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 {
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
|
||||
return
|
||||
}
|
||||
// Successful auth — clear any accumulated failure counters.
|
||||
a.loginLimiter.Reset(ip, req.Email)
|
||||
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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 57 KiB |