Files
panel/controller/cmd/controller/dayzxml.go
T
dbledeez a00bd620a1 Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:17:39 -07:00

275 lines
9.3 KiB
Go

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
}