Веб-панель управления сервером Project Zomboid

Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout:
отсюда живая консоль без RCON, стриминг логов через SSE и корректная
остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов.
Сам менеджер работает под systemd.

Что входит:
- управление жизненным циклом сервера, автозапуск и автоподъём после падения;
- метрики процесса из /proc и состояние хоста, список игроков онлайн;
- редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное
  форматирование файлов;
- моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam;
- бэкапы мира по расписанию и вручную, с ротацией и восстановлением;
- установка и обновление сервера через SteamCMD;
- вход по логину и паролю, первичная настройка по одноразовому коду.

Веб-интерфейс без сборщика, вшит в бинарник через embed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:41:11 +03:00
co-authored by Claude Opus 5
commit d0ff64c272
38 changed files with 6054 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
//go:build linux
package sysinfo
import (
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
var pageSize = int64(os.Getpagesize())
// clockTicks — USER_HZ. На всех практически встречающихся Linux-сборках 100.
const clockTicks = 100.0
// Sample обходит /proc и суммирует статистику всех процессов, входящих в
// группу pgid: сервер PZ — это скрипт-обёртка плюс порождённая им JVM.
func (s *Sampler) Sample(pgid int) ProcStats {
var (
stats ProcStats
cpuSecs float64
)
if pgid <= 0 {
s.Reset()
return stats
}
entries, err := os.ReadDir("/proc")
if err != nil {
return stats
}
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
utime, stime, threads, group, ok := readProcStat(pid)
if !ok || group != pgid {
continue
}
cpuSecs += (utime + stime) / clockTicks
stats.Threads += threads
stats.RSSBytes += readRSS(pid)
}
now := uptimeSeconds()
s.mu.Lock()
if s.lastWall > 0 && now > s.lastWall {
stats.CPUPercent = (cpuSecs - s.lastCPU) / (now - s.lastWall) * 100
if stats.CPUPercent < 0 {
stats.CPUPercent = 0
}
}
s.lastCPU, s.lastWall = cpuSecs, now
s.mu.Unlock()
return stats
}
// readProcStat разбирает /proc/<pid>/stat. Имя процесса заключено в скобки и
// может содержать пробелы, поэтому режем строку после последней ')'.
func readProcStat(pid int) (utime, stime float64, threads, pgrp int, ok bool) {
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
if err != nil {
return 0, 0, 0, 0, false
}
line := string(raw)
idx := strings.LastIndex(line, ")")
if idx < 0 || idx+2 >= len(line) {
return 0, 0, 0, 0, false
}
// Поля после comm: state(3) ppid(4) pgrp(5) ... utime(14) stime(15) ... num_threads(20).
fields := strings.Fields(line[idx+2:])
if len(fields) < 18 {
return 0, 0, 0, 0, false
}
pgrp, _ = strconv.Atoi(fields[2])
utime, _ = strconv.ParseFloat(fields[11], 64)
stime, _ = strconv.ParseFloat(fields[12], 64)
threads, _ = strconv.Atoi(fields[17])
return utime, stime, threads, pgrp, true
}
// readRSS берёт резидентные страницы из /proc/<pid>/statm (второе поле).
func readRSS(pid int) int64 {
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm"))
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) < 2 {
return 0
}
pages, _ := strconv.ParseInt(fields[1], 10, 64)
return pages * pageSize
}
func uptimeSeconds() float64 {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0
}
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
// Host собирает состояние машины; diskPath задаёт раздел для расчёта места.
func Host(diskPath string) HostStats {
h := HostStats{CPUCount: cpuCount()}
if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(raw), "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
kb, _ := strconv.ParseInt(strings.Fields(strings.TrimSpace(value))[0], 10, 64)
switch key {
case "MemTotal":
h.MemTotalBytes = kb * 1024
case "MemAvailable":
h.MemAvailableBytes = kb * 1024
}
}
}
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
if fields := strings.Fields(string(raw)); len(fields) > 0 {
h.Load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
if diskPath != "" {
var st unix.Statfs_t
if err := unix.Statfs(diskPath, &st); err == nil {
h.DiskTotalBytes = int64(st.Blocks) * int64(st.Bsize)
h.DiskFreeBytes = int64(st.Bavail) * int64(st.Bsize)
}
}
return h
}
func cpuCount() int {
raw, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return 0
}
return strings.Count(string(raw), "processor\t:")
}