diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..71b363d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Pocketpair official image. `./palworld-manager.sh update` follows the tag
+# published in Pocketpair's official Compose file.
+PALWORLD_IMAGE_REPOSITORY=ghcr.io/pocketpairjp/palserver
+PALWORLD_IMAGE=ghcr.io/pocketpairjp/palserver:v1.0.0.100427
+
+# Public game port. Only UDP 8211 needs to be opened on the firewall/router.
+PALWORLD_PORT=8211
+PALWORLD_BIND_IP=0.0.0.0
+
+# The REST API is deliberately bound to loopback by compose.yaml.
+REST_API_PORT=8212
+
+# External backup retention policy.
+BACKUP_RETENTION_DAYS=14
+BACKUP_MAX_COUNT=168
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c5199ed
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+* text=auto
+.env.example text eol=lf
+.gitattributes text eol=lf
+.gitignore text eol=lf
+*.sh text eol=lf
+*.yaml text eol=lf
+*.yml text eol=lf
+*.md text eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4d45c71
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Install ShellCheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+
+ - name: Check shell syntax
+ run: bash -n palworld-manager.sh && sh -n helper.sh
+
+ - name: Run ShellCheck
+ run: shellcheck palworld-manager.sh helper.sh
+
+ - name: Validate Compose model
+ run: cp .env.example .env && docker compose config --quiet
+
+ - name: Verify official image and REST API binding
+ run: |
+ grep -q 'ghcr.io/pocketpairjp/palserver:' compose.yaml
+ grep -q '127.0.0.1:' compose.yaml
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d28c5d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.env
+.paldsms-admin-password
+Saved/
+Saved.pre-restore-*/
+Saved.failed-restore-*/
+backups/
+.restore.*/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..dabe782
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,19 @@
+# Changelog
+
+## 2.0.0 - 2026-07-14
+
+- Rebuilt the project around Pocketpair's official Palworld server image.
+- Updated the default image to `v1.0.0.100427`.
+- Added official-image tag discovery and rollback-safe updates.
+- Replaced bundled `mcrcon` usage with the official loopback-only REST API.
+- Added verified external backups, retention controls, and restore rollback.
+- Added game-native rolling backups through `bIsUseBackupSaveData=True`.
+- Added systemd backup and update timers.
+- Added Debian/Ubuntu Docker Engine and Compose v2 installation.
+- Added environment diagnostics, redesigned documentation, a migration guide, security policy,
+ contribution guide, and CI checks.
+- Archived v1.1 ZIP distributions under `legacy/`.
+
+## 1.1 - 2024-02-07
+
+- Historical ZIP-based Chinese, English, and Japanese distributions.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8b61c94
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,25 @@
+# Contributing
+
+Contributions that improve compatibility, backup safety, documentation, or Linux distribution
+support are welcome.
+
+## Before opening a pull request
+
+1. Base changes on the current default branch and keep each pull request focused.
+2. Preserve Bash 4+ compatibility and LF line endings.
+3. Do not add bundled credentials, save data, backups, or unreviewed executables.
+4. Keep the REST API loopback-only by default.
+5. Update the README and changelog when user-visible behavior changes.
+
+Run the same checks used by CI:
+
+```bash
+bash -n palworld-manager.sh
+sh -n helper.sh
+shellcheck palworld-manager.sh helper.sh
+cp .env.example .env
+docker compose config --quiet
+```
+
+For backup, restore, or update changes, describe the failure and rollback cases you tested in the
+pull-request body.
diff --git a/README.md b/README.md
index 022f6b7..afe78b0 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,281 @@
-# Palworld Docker Server Management System Script
-# 幻兽帕鲁Docker服务器管理系统脚本
-# Palworld Dockerサーバー管理システムスクリプト
-This script was created by AlanBacker and currently only works on Debian distributions. 这个脚本由AlanBacker创建,目前仅适用于Debian发行版本。このスクリプトはAlanBackerによって作成され、現在はDebianディストリビューションにのみ対応しています。
+# PalDSMS — 幻兽帕鲁 Docker 服务器管理系统
-# PalWorld Docker Server Management System
+[](https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script/releases/latest)
+[](https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script/releases)
+[](https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script/actions/workflows/ci.yml)
+[](LICENSE)
+[](https://docs.palworldgame.com/)
+[](#服务器要求)
-This Docker server management script for PalWorld is created by AlanBacker and is specifically designed for Debian server platforms.
+面向 Linux 专用服务器的《幻兽帕鲁》部署、备份、恢复与自动更新工具。
-## Features
+PalDSMS v2 已按 Pocketpair **服务器 1.0.0** 文档重写,默认使用其官方镜像
+`ghcr.io/pocketpairjp/palserver:v1.0.0.100427`。管理操作使用官方 REST API,
+不再依赖旧版 ZIP 中随附的第三方镜像和 `mcrcon` 二进制文件。
-This script offers a variety of functions including:
+**[下载最新稳定版](https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script/releases/latest)** ·
+**[查看官方服务器文档](https://docs.palworldgame.com/)** ·
+**[从 v1.1 迁移](docs/MIGRATION-v1.md)**
-- Docker installation
-- Deployment of a new PalWorld server
-- Deployment of an auto-maintenance system that includes automatic backups and scheduled restarts
-- Creation of manual backups
-- Data recovery from backup files
-- Real-time logs viewing
-- Game rules file editing
-- Management of the game Docker image (start, stop, restart)
-- Broadcasting server messages via RCON
+> [!IMPORTANT]
+> v1.1 使用第三方镜像,不能直接覆盖升级到 v2。请先完成存档备份,再按照
+> [迁移指南](docs/MIGRATION-v1.md)切换。仓库中的 v1.1 ZIP 只用于历史留档。
-## Precautions
+## 适配状态
-- Ensure you have adequate knowledge of Docker and Linux before using this script.
-- Always backup your data before performing any operations.
-- Make sure Docker and all dependencies are properly installed and configured on your Debian server.
-- The Docker image used by this script is sourced from: [kagurazakanyaa/palworld](https://hub.docker.com/r/kagurazakanyaa/palworld).
-- Set up the `AdminPassword` and `RCONenabled` parameters for the game before deploying the auto-maintenance system.
+| 项目 | 当前状态 |
+|---|---|
+| Pocketpair 服务器文档 | 1.0.0 |
+| 默认官方镜像 | `ghcr.io/pocketpairjp/palserver:v1.0.0.100427` |
+| 游戏端口 | `8211/udp`,可配置 |
+| 管理接口 | 官方 REST API,仅映射到 `127.0.0.1` |
+| 目标系统 | x86_64 Debian / Ubuntu,Docker Compose v2+ |
+| 存档目录 | `./Saved`,直接可见、可迁移 |
+| 自动维护 | systemd timer:每小时备份、每日检查官方更新 |
-## Contributor
+## v2 的主要改进
-- AlanBacker
+- 使用 Pocketpair 官方镜像与官方 `Saved` 目录布局。
+- `update` 自动读取 Pocketpair 官方 Compose 文件中的当前镜像标签。
+- 更新前强制备份;新镜像启动失败时自动回滚镜像标签。
+- REST API 只映射到 `127.0.0.1`,不会把管理接口暴露到公网。
+- 外部 `tar.gz` 备份经过完整性检查,并按时间和数量双重清理。
+- 恢复前自动再备份;恢复失败时自动换回原存档。
+- 同时启用游戏内建滚动备份 `bIsUseBackupSaveData`。
+- 使用 systemd timer 代替重复写入 crontab:每小时备份、每日检查更新。
+- 支持 Debian、Ubuntu 的 Docker Engine / Compose v2 安装。
+- 不再每 4 小时强制重启;仅在管理员操作或实际更新时重启。
+- 配置、存档、备份均为普通目录,避免硬编码 Docker 内部卷路径。
+## 服务器要求
-## License
+Pocketpair 当前给出的基础要求:
-This script is released under the MIT License. By using this script, you agree to its terms.
+- x86_64 Linux(Debian / Ubuntu 等)
+- 4 核以上 CPU
+- 至少 16 GiB 内存,人数较多时推荐 32 GiB 以上
+- SSD,并预留足够的存档和备份空间
+- 对外开放 `8211/udp`(修改端口时同步调整防火墙和路由器)
-## Disclaimer
+先运行环境检查可以发现常见问题:
-The author of this script is not responsible for any direct or indirect damage caused by the use of this script. Users assume all risks.
+```bash
+./palworld-manager.sh doctor
+```
----
+## 下载
-**Note: This is the English version of the README.**
+推荐从 [GitHub Releases](https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script/releases/latest)
+下载 `PalDSMS-v2.0.0.zip`,并使用同一 Release 中的 `SHA256SUMS.txt` 校验文件。
+Linux 校验示例:
-# Palworld Docker 服务器管理系统
+```bash
+sha256sum -c SHA256SUMS.txt
+```
-这是一个由 AlanBacker 创建的 PalWorld 服务器管理脚本,专门用于 Debian 服务器平台。
+也可以直接克隆仓库,适合希望持续使用 `git pull` 更新管理器的用户。
-## 功能
+## 快速开始
-该脚本提供以下功能:
+```bash
+git clone https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script.git
+cd PalWorld-Docker-Server-Management-System-Script
+chmod +x palworld-manager.sh helper.sh
+cp .env.example .env
-- 安装 Docker
-- 部署新的 PalWorld 服务器
-- 部署自动维护系统,包括自动备份和定时重启
-- 创建手动备份
-- 从备份文件中恢复数据
-- 查看实时日志
-- 修改游戏规则文件
-- 管理(启动、停止、重启)游戏 Docker 镜像
-- 通过 RCON 广播服务器消息
+# 尚未安装 Docker 时执行;已有 Compose v2 可跳过
+sudo ./palworld-manager.sh install-docker
-## 注意事项
+# 部署官方服务器
+sudo ./palworld-manager.sh deploy
-- 使用本脚本前请确保你有足够的 Docker 和 Linux 知识。
-- 如果你在中国大陆,并且没有良好的网络环境(如无法高速访问外网),请修改脚本中第55行为`docker pull docker.mirrors.sjtug.sjtu.edu.cn/kagurazakanyaa/palworld` 注意隔行!
-
- 如图:
-- 以及第97行为`if docker pull docker.mirrors.sjtug.sjtu.edu.cn/kagurazakanyaa/palworld | grep -q 'Downloaded newer image'; then`
-
- 如图:
+# 设置管理员密码,同时启用本机 REST API 和游戏内建备份
+sudo ./palworld-manager.sh set-password
+```
-- 在执行任何操作前,务必备份你的数据。
-- 确保 Docker 和所有依赖项在 Debian 服务器上已正确安装和配置。
-- 本脚本使用的 Docker 镜像来自:[kagurazakanyaa/palworld](https://hub.docker.com/r/kagurazakanyaa/palworld)。
-- 在部署自动维护系统前,请先设置游戏的 `AdminPassword` 和 `RCONenabled` 参数。
+直接运行脚本会打开中文菜单:
-## 贡献者
+```bash
+sudo ./palworld-manager.sh
+```
-- AlanBacker
+客户端默认通过 `服务器公网 IP:8211` 连接。
-## 欢迎关注
+首次启动可能需要数分钟下载和初始化服务器文件。不要因为容器刚进入 `running`
+状态就立即强制终止;可使用 `sudo ./palworld-manager.sh logs` 查看进度。
-- Bilibili: [AlanBacker的Bilibili空间](https://space.bilibili.com/702922307)
+## 常用命令
-## 使用许可
+| 命令 | 作用 |
+|---|---|
+| `doctor` | 检查架构、内存、磁盘、Docker 和关键配置 |
+| `deploy` | 拉取官方镜像并部署服务器 |
+| `set-password` | 设置管理员密码,启用本地 REST API 与内建备份 |
+| `config` | 编辑 `PalWorldSettings.ini`,保存后重启 |
+| `start` / `stop` / `restart` | 管理服务器生命周期 |
+| `status` / `logs` | 查看游戏版本、容器状态或实时日志 |
+| `backup` | 请求游戏落盘后创建外部备份 |
+| `restore <文件>` | 安全恢复备份,失败时自动回滚 |
+| `check-update` | 对比当前与 Pocketpair 官方镜像标签 |
+| `update` | 备份、拉取、更新,并在失败时回滚 |
+| `install-maintenance` | 安装自动备份和更新 systemd timers |
+| `remove-maintenance` | 卸载自动维护,不删除存档和备份 |
-本脚本遵循 MIT 许可。使用本脚本即表明您同意其条款。
+完整帮助:
-## 免责声明
+```bash
+./palworld-manager.sh help
+```
-本脚本作者不对因使用本脚本而造成的任何直接或间接损失负责。用户应自行承担风险。
+## 配置
----
+服务器运行参数在 `.env` 中修改:
-**注意:这是中文版本的 README。**
+```dotenv
+PALWORLD_IMAGE_REPOSITORY=ghcr.io/pocketpairjp/palserver
+PALWORLD_IMAGE=ghcr.io/pocketpairjp/palserver:v1.0.0.100427
+PALWORLD_PORT=8211
+PALWORLD_BIND_IP=0.0.0.0
+REST_API_PORT=8212
+BACKUP_RETENTION_DAYS=14
+BACKUP_MAX_COUNT=168
+```
-# Palworld Dockerサーバー管理システム
+网络环境需要可信镜像代理时,只修改 `PALWORLD_IMAGE_REPOSITORY`。`update` 会继续从
+Pocketpair 官方仓库读取版本标签,再从指定仓库拉取同名标签;不要使用来源不明的镜像。
-このPalWorldサーバー管理スクリプトはAlanBackerによって作成され、Debianサーバープラットフォーム専用に設計されています。
+游戏规则使用以下命令编辑:
-## 機能
+```bash
+sudo ./palworld-manager.sh config
+```
-このスクリプトは以下の機能を提供します:
+对应文件为:
-- Dockerのインストール
-- 新しいPalWorldサーバーのデプロイ
-- 自動バックアップとスケジュールされたリスタートを含む自動メンテナンスシステムのデプロイ
-- マニュアルバックアップの作成
-- バックアップファイルからのデータ復旧
-- リアルタイムログの表示
-- ゲームルールファイルの編集
-- ゲームDockerイメージの管理(開始、停止、リスタート)
-- RCONを通じたサーバーメッセージのブロードキャスト
+```text
+Saved/Config/LinuxServer/PalWorldSettings.ini
+```
-## 注意事項
+脚本从镜像内的 `DefaultPalWorldSettings.ini` 创建初始配置。不要直接修改镜像内的
+默认文件,因为游戏不会读取那里的改动。
-- このスクリプトを使用する前に、DockerとLinuxに関する十分な知識を持っていることを確認してください。
-- いかなる操作を行う前に、必ずデータをバックアップしてください。
-- Debianサーバー上でDockerおよびすべての依存関係が正しくインストールされ、設定されていることを確認してください。
-- このスクリプトで使用されるDockerイメージはこちらから取得しています:[kagurazakanyaa/palworld](https://hub.docker.com/r/kagurazakanyaa/palworld)。
-- 自動メンテナンスシステムをデプロイする前に、ゲームの`AdminPassword`と`RCONenabled`パラメータを設定してください。
+服务器 1.0.0 文档指出,不设置旧的多线程参数有时性能更好,因此 v2 默认只传入
+`-port`,不再强制使用 `-useperfthreads -NoAsyncLoadingThread -UseMultithreadForDS`。
-## 貢献者
+## 自动维护
-- AlanBacker
+```bash
+sudo ./palworld-manager.sh install-maintenance
+systemctl list-timers 'paldsms-*'
+```
-## 連絡先
+默认计划:
-- Bilibili: [AlanBackerのBilibiliスペース](https://space.bilibili.com/702922307)
+- 每小时创建一次外部备份,最多保存 14 天 / 168 份。
+- 每天 04:15 后随机延迟最多 30 分钟,检查 Pocketpair 官方镜像标签。
+- 只有发现新镜像时才备份、通知玩家并重建容器。
-## ライセンス
+查看执行记录:
-このスクリプトはMITライセンスの下でリリースされています。このスクリプトを使用することで、その条件に同意したことになります。
+```bash
+journalctl -u paldsms-backup.service
+journalctl -u paldsms-update.service
+```
-## 免責事項
+## 升级现有 v2 安装
-このスクリプトの作者は、このスクリプトの使用によって生じる直接的または間接的な損害について一切責任を負いません。ユーザーはすべてのリスクを負担します。
+先更新管理器,再让脚本执行存档备份和官方镜像升级:
----
+```bash
+git pull --ff-only
+sudo ./palworld-manager.sh check-update
+sudo ./palworld-manager.sh update
+```
-**注意:これはREADMEの日本語版です。**
+`update` 会保留服务器原来的运行状态:正在运行的服务器会在备份后更新;原本停止的
+服务器只更新镜像标签,不会被意外启动。新镜像未能就绪时会自动恢复旧标签。
+
+## 安全说明
+
+- **只开放游戏 UDP 端口。** 不要把 `8212/tcp` 暴露到公网。
+- Compose 将 REST API 强制绑定到宿主机 `127.0.0.1`。
+- 管理员密码保存在 `.paldsms-admin-password`,权限为 `0600`,且已加入
+ `.gitignore`。不要把它提交或复制到公开位置。
+- 任何升级、改配置或恢复操作前,仍建议额外保存一份异地备份。
+- `Saved/` 与 `backups/` 不应放在低性能网络盘上运行。
+
+## 从 v1.1 迁移
+
+不要直接在旧容器上覆盖部署。先备份,再把旧 Docker 卷中的存档复制到 v2 的
+`Saved/`。完整步骤见 [v1.1 迁移指南](docs/MIGRATION-v1.md)。
+
+## 常见问题
+
+
+容器在运行,但客户端无法连接
+
+确认云安全组、主机防火墙和路由器端口转发开放的是 **UDP** 端口。默认只需
+`8211/udp`,不是 TCP。修改 `.env` 中的 `PALWORLD_PORT` 后需同步修改外部规则。
+
+
+
+
+status、backup 或广播提示 REST API 失败
+
+执行 `sudo ./palworld-manager.sh set-password`,确认
+`Saved/Config/LinuxServer/PalWorldSettings.ini` 中启用了 `RESTAPIEnabled=True`。
+不要为了排障把 `8212/tcp` 映射到公网。
+
+
+
+
+服务器频繁因内存不足退出
+
+运行 `./palworld-manager.sh doctor`。Pocketpair 要求至少 16 GiB 内存,大型服务器
+建议 32 GiB 以上;同时检查宿主机是否还有其他高内存服务。
+
+
+
+
+无法访问 GHCR 或自动更新失败
+
+优先修复宿主机到 GitHub/GHCR 的网络连接。确需可信代理仓库时,只修改 `.env` 中的
+`PALWORLD_IMAGE_REPOSITORY`;脚本仍会从 Pocketpair 官方 Compose 文件读取版本标签。
+
+
+
+## 项目结构
+
+```text
+.
+├── compose.yaml # 官方镜像部署定义
+├── helper.sh # 与 Pocketpair 官方入口保持一致
+├── palworld-manager.sh # 管理器源码
+├── .env.example # 可复制的运行参数
+├── CHANGELOG.md # 版本变更记录
+├── SECURITY.md # 安全策略与报告方式
+├── CONTRIBUTING.md # 贡献与验证要求
+├── Saved/ # 运行后生成,不纳入 Git
+├── backups/ # 外部备份,不纳入 Git
+└── legacy/ # v1.1 历史 ZIP
+```
+
+## Compatibility / English summary
+
+PalDSMS v2 targets the current Pocketpair dedicated-server 1.0.0 line and uses
+Pocketpair's official GHCR image. It supports deployment, local-only REST API
+management, validated backups, rollback-safe restores, official-tag updates,
+and systemd automation on x86_64 Debian/Ubuntu hosts. Run
+`./palworld-manager.sh help` for the command list.
+
+## 参考资料
+
+- [Pocketpair Palworld Server Guide](https://docs.palworldgame.com/)
+- [Pocketpair official dedicated-server Docker image](https://github.com/pocketpairjp/palworld-dedicated-server-docker)
+- [Configuration parameters](https://docs.palworldgame.com/settings-and-operation/configuration/)
+- [Server startup arguments](https://docs.palworldgame.com/settings-and-operation/arguments/)
+
+## 许可
+
+[MIT License](LICENSE) © AlanBacker
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..92238f3
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,26 @@
+# Security Policy
+
+## Supported versions
+
+| Version | Security updates |
+|---|---|
+| 2.x | Supported |
+| 1.x | Unsupported; migrate to v2 |
+
+## Reporting a vulnerability
+
+Please use GitHub's private security-advisory workflow for vulnerabilities that could expose
+administrator credentials, overwrite save data, escape the intended Docker boundary, or make the
+local management API reachable from the Internet. Do not publish working exploits or real secrets
+in a public issue.
+
+For ordinary bugs that do not carry a security impact, open a normal GitHub issue with the PalDSMS
+version, Linux distribution, Docker Compose version, relevant redacted logs, and reproduction steps.
+
+## Deployment security baseline
+
+- Publish only the configured Palworld UDP game port. Keep the REST API bound to `127.0.0.1`.
+- Keep `.paldsms-admin-password`, `Saved/`, and `backups/` out of Git and restrict their permissions.
+- Pull images from Pocketpair's official GHCR repository or a mirror you independently trust.
+- Keep an off-host backup before game-server updates or save migrations.
+- Review local changes before running the manager as root.
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..2a2aa60
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,23 @@
+name: palworld
+
+services:
+ palworld-server:
+ image: ${PALWORLD_IMAGE:-ghcr.io/pocketpairjp/palserver:v1.0.0.100427}
+ container_name: palworld-server
+ restart: unless-stopped
+ init: true
+ stop_grace_period: 2m
+ entrypoint: /pal/helper.sh
+ command:
+ - -port=${PALWORLD_PORT:-8211}
+ ports:
+ - "${PALWORLD_BIND_IP:-0.0.0.0}:${PALWORLD_PORT:-8211}:${PALWORLD_PORT:-8211}/udp"
+ # Never expose the management API directly to the Internet.
+ - "127.0.0.1:${REST_API_PORT:-8212}:${REST_API_PORT:-8212}/tcp"
+ volumes:
+ - ./helper.sh:/pal/helper.sh:ro
+ - ./Saved:/pal/Package/Pal/Saved
+ ulimits:
+ nofile:
+ soft: 100000
+ hard: 100000
diff --git a/docs/MIGRATION-v1.md b/docs/MIGRATION-v1.md
new file mode 100644
index 0000000..97b05b1
--- /dev/null
+++ b/docs/MIGRATION-v1.md
@@ -0,0 +1,91 @@
+# 从 PalDSMS v1.1 迁移到 v2
+
+v1.1 使用第三方 `kagurazakanyaa/palworld` 镜像和名为 `palworld_saved` 的 Docker
+卷。v2 使用 Pocketpair 官方镜像,并把同一层级的存档保存在仓库的 `Saved/` 目录。
+
+迁移不会改变存档格式,但必须停止旧服务器后再复制数据,避免得到不一致的存档。
+
+## 1. 在旧版本创建最后备份
+
+进入旧脚本目录,先使用 v1.1 的手动备份功能。确认备份文件可读取后再继续。
+
+还可以额外保存整个 Docker 卷:
+
+```bash
+sudo docker stop palworld-server
+sudo tar -C /var/lib/docker/volumes/palworld_saved/_data \
+ -czf "$HOME/palworld-v1-volume-$(date +%Y%m%d-%H%M%S).tar.gz" .
+```
+
+如果 Docker 的数据根目录不是 `/var/lib/docker`,请先执行:
+
+```bash
+sudo docker info --format '{{.DockerRootDir}}'
+```
+
+## 2. 保留旧容器名称
+
+v2 也使用 `palworld-server` 作为容器名。不要立即删除旧容器,先重命名以便回退:
+
+```bash
+sudo docker rename palworld-server palworld-server-v1
+```
+
+## 3. 准备 v2
+
+```bash
+git clone https://github.com/AlanBacker/PalWorld-Docker-Server-Management-System-Script.git
+cd PalWorld-Docker-Server-Management-System-Script
+chmod +x palworld-manager.sh helper.sh
+cp .env.example .env
+mkdir -p Saved
+```
+
+## 4. 复制旧卷
+
+使用 Docker 自己解析卷位置,避免依赖 `/var/lib/docker`:
+
+```bash
+sudo docker run --rm \
+ -v palworld_saved:/source:ro \
+ -v "$PWD/Saved:/target" \
+ --entrypoint /bin/sh \
+ ghcr.io/pocketpairjp/palserver:v1.0.0.100427 \
+ -c 'cp -a /source/. /target/'
+```
+
+复制完成后确认以下内容存在:
+
+```text
+Saved/Config/LinuxServer/PalWorldSettings.ini
+Saved/SaveGames/0//Level.sav
+```
+
+## 5. 启动并检查
+
+```bash
+sudo ./palworld-manager.sh start
+sudo ./palworld-manager.sh set-password
+sudo ./palworld-manager.sh status
+sudo ./palworld-manager.sh logs
+```
+
+确认客户端可以连接、角色和世界正确后,立即创建一份 v2 备份:
+
+```bash
+sudo ./palworld-manager.sh backup
+```
+
+## 6. 确认稳定后清理旧资源
+
+以下命令会删除旧容器,只有在 v2 已验证并且异地备份存在时才执行:
+
+```bash
+sudo docker rm palworld-server-v1
+```
+
+旧的 `palworld_saved` 卷建议再保留一段时间。需要删除时必须明确指定:
+
+```bash
+sudo docker volume rm palworld_saved
+```
diff --git a/helper.sh b/helper.sh
new file mode 100755
index 0000000..91c641a
--- /dev/null
+++ b/helper.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Kept intentionally aligned with Pocketpair's official helper script.
+set -eu
+sudo chown -R user:usergroup /pal/Package/Pal/Saved
+exec /bin/sh /pal/Package/PalServer.sh "$@"
diff --git a/PalDSMS v1.1 - Chinese verion.zip b/legacy/PalDSMS v1.1 - Chinese verion.zip
similarity index 100%
rename from PalDSMS v1.1 - Chinese verion.zip
rename to legacy/PalDSMS v1.1 - Chinese verion.zip
diff --git a/PalDSMS v1.1 - English version.zip b/legacy/PalDSMS v1.1 - English version.zip
similarity index 100%
rename from PalDSMS v1.1 - English version.zip
rename to legacy/PalDSMS v1.1 - English version.zip
diff --git a/PalDSMS v1.1 - Japanese version.zip b/legacy/PalDSMS v1.1 - Japanese version.zip
similarity index 100%
rename from PalDSMS v1.1 - Japanese version.zip
rename to legacy/PalDSMS v1.1 - Japanese version.zip
diff --git a/palworld-manager.sh b/palworld-manager.sh
new file mode 100755
index 0000000..d76bd98
--- /dev/null
+++ b/palworld-manager.sh
@@ -0,0 +1,870 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+umask 077
+
+readonly PALDSMS_VERSION="2.0.0"
+readonly OFFICIAL_COMPOSE_URL="${PALDSMS_OFFICIAL_COMPOSE_URL:-https://raw.githubusercontent.com/pocketpairjp/palworld-dedicated-server-docker/main/compose/compose.yaml}"
+readonly DEFAULT_IMAGE="ghcr.io/pocketpairjp/palserver:v1.0.0.100427"
+
+SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || printf '%s' "${BASH_SOURCE[0]}")"
+SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
+ROOT_DIR="${PALDSMS_HOME:-$SCRIPT_DIR}"
+COMPOSE_FILE="$ROOT_DIR/compose.yaml"
+ENV_EXAMPLE="$ROOT_DIR/.env.example"
+ENV_FILE="$ROOT_DIR/.env"
+SAVE_DIR="$ROOT_DIR/Saved"
+SETTINGS_FILE="$SAVE_DIR/Config/LinuxServer/PalWorldSettings.ini"
+BACKUP_DIR="$ROOT_DIR/backups"
+PASSWORD_FILE="$ROOT_DIR/.paldsms-admin-password"
+
+if [[ -t 1 ]]; then
+ readonly C_RESET=$'\033[0m'
+ readonly C_RED=$'\033[31m'
+ readonly C_GREEN=$'\033[32m'
+ readonly C_YELLOW=$'\033[33m'
+ readonly C_BLUE=$'\033[34m'
+else
+ readonly C_RESET="" C_RED="" C_GREEN="" C_YELLOW="" C_BLUE=""
+fi
+
+info() { printf '%s[信息]%s %s\n' "$C_BLUE" "$C_RESET" "$*"; }
+success() { printf '%s[完成]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
+warn() { printf '%s[警告]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
+die() { printf '%s[错误]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || die "缺少命令:$1"
+}
+
+require_root() {
+ [[ ${EUID:-$(id -u)} -eq 0 ]] || die "此操作需要 root 权限,请使用 sudo。"
+}
+
+ensure_project_files() {
+ [[ -f "$COMPOSE_FILE" ]] || die "找不到 $COMPOSE_FILE"
+ [[ -f "$ENV_EXAMPLE" ]] || die "找不到 $ENV_EXAMPLE"
+ [[ -f "$ROOT_DIR/helper.sh" ]] || die "找不到 $ROOT_DIR/helper.sh"
+}
+
+ensure_env() {
+ ensure_project_files
+ if [[ ! -f "$ENV_FILE" ]]; then
+ cp -- "$ENV_EXAMPLE" "$ENV_FILE"
+ chmod 600 "$ENV_FILE"
+ info "已从 .env.example 创建 .env。"
+ fi
+}
+
+env_get() {
+ local key=$1
+ local default_value=${2:-}
+ local value=""
+
+ if [[ -f "$ENV_FILE" ]]; then
+ value="$(sed -n -E "s/^${key}=(.*)$/\\1/p" "$ENV_FILE" | tail -n 1)"
+ value="${value%$'\r'}"
+ if [[ "$value" == \"*\" && "$value" == *\" ]]; then
+ value="${value:1:${#value}-2}"
+ fi
+ fi
+ printf '%s' "${value:-$default_value}"
+}
+
+env_set() {
+ local key=$1
+ local value=$2
+ local tmp
+ tmp="$(mktemp "$ROOT_DIR/.env.XXXXXX")"
+
+ if grep -q -E "^${key}=" "$ENV_FILE"; then
+ sed -E "s|^${key}=.*$|${key}=${value}|" "$ENV_FILE" >"$tmp"
+ else
+ cp -- "$ENV_FILE" "$tmp"
+ printf '\n%s=%s\n' "$key" "$value" >>"$tmp"
+ fi
+ chmod 600 "$tmp"
+ mv -f -- "$tmp" "$ENV_FILE"
+}
+
+compose() {
+ ensure_env
+ docker compose \
+ --project-directory "$ROOT_DIR" \
+ --env-file "$ENV_FILE" \
+ -f "$COMPOSE_FILE" "$@"
+}
+
+require_runtime() {
+ require_command docker
+ docker compose version >/dev/null 2>&1 || die "需要 Docker Compose v2(docker compose)。"
+ docker info >/dev/null 2>&1 || die "Docker 服务不可用,或当前用户无权访问 Docker。"
+ require_command curl
+ require_command tar
+}
+
+container_running() {
+ local container_id
+ container_id="$(compose ps -q palworld-server 2>/dev/null || true)"
+ [[ -n "$container_id" ]] || return 1
+ [[ "$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || true)" == "true" ]]
+}
+
+wait_until_running() {
+ local timeout=${1:-120}
+ local elapsed=0
+ while (( elapsed < timeout )); do
+ if container_running; then
+ return 0
+ fi
+ sleep 2
+ ((elapsed += 2))
+ done
+ return 1
+}
+
+wait_until_api() {
+ local timeout=${1:-180}
+ local elapsed=0
+ while (( elapsed < timeout )); do
+ if api_request GET info >/dev/null 2>&1; then
+ return 0
+ fi
+ sleep 2
+ ((elapsed += 2))
+ done
+ return 1
+}
+
+wait_until_ready() {
+ local timeout=${1:-180}
+ wait_until_running "$timeout" || return 1
+ if get_admin_password >/dev/null 2>&1; then
+ wait_until_api "$timeout"
+ fi
+}
+
+get_admin_password() {
+ if [[ -n "${PALWORLD_ADMIN_PASSWORD:-}" ]]; then
+ printf '%s' "$PALWORLD_ADMIN_PASSWORD"
+ return 0
+ fi
+ if [[ -s "$PASSWORD_FILE" ]]; then
+ IFS= read -r REPLY <"$PASSWORD_FILE" || true
+ [[ -n "$REPLY" ]] || return 1
+ printf '%s' "$REPLY"
+ return 0
+ fi
+ return 1
+}
+
+api_request() {
+ local method=$1
+ local endpoint=$2
+ local payload=${3:-}
+ local password port url
+
+ password="$(get_admin_password)" || return 2
+ port="$(env_get REST_API_PORT 8212)"
+ [[ "$port" =~ ^[0-9]{1,5}$ ]] || return 2
+ url="http://127.0.0.1:${port}/v1/api/${endpoint}"
+
+ if [[ -n "$payload" ]]; then
+ curl --silent --show-error --fail-with-body \
+ --connect-timeout 3 --max-time 15 \
+ --user "admin:${password}" \
+ -H 'Content-Type: application/json' \
+ -X "$method" --data-raw "$payload" "$url"
+ else
+ curl --silent --show-error --fail-with-body \
+ --connect-timeout 3 --max-time 15 \
+ --user "admin:${password}" \
+ -H 'Content-Length: 0' \
+ -X "$method" "$url"
+ fi
+}
+
+json_escape() {
+ local value=$1
+ value=${value//\\/\\\\}
+ value=${value//\"/\\\"}
+ value=${value//$'\n'/\\n}
+ value=${value//$'\r'/\\r}
+ value=${value//$'\t'/\\t}
+ printf '%s' "$value"
+}
+
+api_save() {
+ api_request POST save >/dev/null
+}
+
+api_announce() {
+ local escaped
+ escaped="$(json_escape "$1")"
+ api_request POST announce "{\"message\":\"${escaped}\"}" >/dev/null
+}
+
+ensure_default_config() {
+ local tmp
+ [[ -s "$SETTINGS_FILE" ]] && return 0
+ container_running || die "服务器未运行,无法读取官方默认配置。"
+
+ mkdir -p -- "$(dirname -- "$SETTINGS_FILE")"
+ tmp="$(mktemp "$ROOT_DIR/.PalWorldSettings.XXXXXX")"
+ if ! compose exec -T palworld-server \
+ sh -c 'cat /pal/Package/DefaultPalWorldSettings.ini' >"$tmp"; then
+ rm -f -- "$tmp"
+ die "无法从官方镜像读取 DefaultPalWorldSettings.ini。"
+ fi
+ [[ -s "$tmp" ]] || { rm -f -- "$tmp"; die "官方默认配置为空。"; }
+ install -m 600 "$tmp" "$SETTINGS_FILE"
+ rm -f -- "$tmp"
+ success "已生成 $SETTINGS_FILE"
+}
+
+set_admin_password() {
+ local password confirm port escaped timestamp
+ require_runtime
+ ensure_default_config
+
+ if [[ -n "${PALWORLD_ADMIN_PASSWORD:-}" ]]; then
+ password="$PALWORLD_ADMIN_PASSWORD"
+ elif [[ -t 0 ]]; then
+ read -r -s -p "请输入新的管理员密码(不会显示): " password
+ printf '\n'
+ read -r -s -p "请再次输入: " confirm
+ printf '\n'
+ [[ "$password" == "$confirm" ]] || die "两次输入的密码不一致。"
+ else
+ die "非交互模式请通过 PALWORLD_ADMIN_PASSWORD 环境变量提供密码。"
+ fi
+
+ [[ -n "$password" ]] || die "管理员密码不能为空。"
+ [[ "$password" != *\"* && "$password" != *$'\n'* && "$password" != *$'\r'* ]] \
+ || die "密码不能包含双引号或换行符。"
+ (( ${#password} >= 12 )) || warn "建议使用至少 12 位的随机管理员密码。"
+
+ port="$(env_get REST_API_PORT 8212)"
+ [[ "$port" =~ ^[0-9]{1,5}$ ]] || die "REST_API_PORT 无效:$port"
+ (( port >= 1 && port <= 65535 )) || die "REST_API_PORT 超出范围:$port"
+
+ timestamp="$(date +%Y%m%d-%H%M%S-%N)"
+ cp -a -- "$SETTINGS_FILE" "${SETTINGS_FILE}.before-paldsms-${timestamp}"
+
+ escaped=${password//\\/\\\\}
+ escaped=${escaped//&/\\&}
+ escaped=${escaped//|/\\|}
+
+ grep -q 'AdminPassword=' "$SETTINGS_FILE" || die "官方默认配置中没有 AdminPassword。"
+ grep -q 'RESTAPIEnabled=' "$SETTINGS_FILE" || die "官方默认配置中没有 RESTAPIEnabled。"
+ grep -q 'RESTAPIPort=' "$SETTINGS_FILE" || die "官方默认配置中没有 RESTAPIPort。"
+ grep -q 'RCONEnabled=' "$SETTINGS_FILE" || die "官方默认配置中没有 RCONEnabled。"
+ grep -q 'bIsUseBackupSaveData=' "$SETTINGS_FILE" \
+ || die "官方默认配置中没有 bIsUseBackupSaveData。"
+
+ sed -i -E \
+ -e "s|AdminPassword=\"[^\"]*\"|AdminPassword=\"${escaped}\"|" \
+ -e 's#RESTAPIEnabled=(True|False)#RESTAPIEnabled=True#' \
+ -e "s#RESTAPIPort=[0-9]+#RESTAPIPort=${port}#" \
+ -e 's#RCONEnabled=(True|False)#RCONEnabled=False#' \
+ -e 's#bIsUseBackupSaveData=(True|False)#bIsUseBackupSaveData=True#' \
+ "$SETTINGS_FILE"
+
+ grep -Fq "AdminPassword=\"${password}\"" "$SETTINGS_FILE" \
+ || die "管理员密码写入校验失败,原配置备份仍然保留。"
+ grep -q 'RESTAPIEnabled=True' "$SETTINGS_FILE" \
+ || die "REST API 配置写入校验失败。"
+ grep -q "RESTAPIPort=${port}" "$SETTINGS_FILE" \
+ || die "REST API 端口写入校验失败。"
+ grep -q 'RCONEnabled=False' "$SETTINGS_FILE" \
+ || die "RCON 禁用配置写入校验失败。"
+ grep -q 'bIsUseBackupSaveData=True' "$SETTINGS_FILE" \
+ || die "游戏内建备份配置写入校验失败。"
+
+ printf '%s' "$password" >"$PASSWORD_FILE"
+ chmod 600 "$PASSWORD_FILE" "$SETTINGS_FILE"
+
+ info "REST API 已启用,并仅通过 Docker 映射到 127.0.0.1:${port}。"
+ restart_server
+}
+
+deploy_server() {
+ require_runtime
+ ensure_env
+ mkdir -p -- "$SAVE_DIR" "$BACKUP_DIR"
+ chmod 700 "$SAVE_DIR" "$BACKUP_DIR"
+
+ info "拉取 Pocketpair 官方服务器镜像……"
+ compose pull palworld-server
+ compose up -d
+ wait_until_running 120 || die "容器未能在 120 秒内进入运行状态,请执行 logs 查看原因。"
+ ensure_default_config
+ success "服务器已部署。游戏端口:UDP $(env_get PALWORLD_PORT 8211)"
+ info "首次部署请执行:./palworld-manager.sh set-password"
+}
+
+start_server() {
+ require_runtime
+ mkdir -p -- "$SAVE_DIR" "$BACKUP_DIR"
+ compose up -d
+ wait_until_ready 180 || die "服务器未能在 180 秒内就绪,请查看日志。"
+ success "服务器已启动。"
+}
+
+stop_server() {
+ require_runtime
+ if container_running; then
+ if api_save; then
+ info "已请求服务器落盘存档。"
+ sleep 3
+ else
+ warn "REST API 保存请求失败,将依赖容器的正常停止流程。"
+ fi
+ fi
+ compose stop -t 120
+ success "服务器已停止。"
+}
+
+restart_server() {
+ require_runtime
+ if container_running; then
+ if api_save; then
+ api_announce "Server restart in 10 seconds" || true
+ sleep 10
+ else
+ warn "REST API 不可用,将直接执行 Docker 的正常重启。"
+ fi
+ fi
+ compose restart -t 120 palworld-server
+ wait_until_ready 180 || die "服务器重启后未能在 180 秒内就绪,请查看日志。"
+ success "服务器已重启。"
+}
+
+show_status() {
+ require_runtime
+ compose ps
+ if container_running; then
+ printf '\n'
+ if ! api_request GET info; then
+ warn "容器正在运行,但 REST API 状态读取失败。请检查管理员密码和配置。"
+ fi
+ printf '\n'
+ fi
+}
+
+show_logs() {
+ require_runtime
+ compose logs --tail=200 -f palworld-server
+}
+
+edit_config() {
+ local editor timestamp
+ require_runtime
+ ensure_default_config
+ editor="${EDITOR:-nano}"
+ command -v "$editor" >/dev/null 2>&1 || die "找不到编辑器 $editor,请设置 EDITOR 环境变量。"
+ timestamp="$(date +%Y%m%d-%H%M%S)"
+ cp -a -- "$SETTINGS_FILE" "${SETTINGS_FILE}.before-edit-${timestamp}"
+ "$editor" "$SETTINGS_FILE"
+ info "配置已保存,正在重启服务器使其生效。"
+ restart_server
+}
+
+prune_backups() {
+ local days max_count index
+ local -a backups=()
+ days="$(env_get BACKUP_RETENTION_DAYS 14)"
+ max_count="$(env_get BACKUP_MAX_COUNT 168)"
+ [[ "$days" =~ ^[0-9]+$ ]] || die "BACKUP_RETENTION_DAYS 必须是非负整数。"
+ [[ "$max_count" =~ ^[1-9][0-9]*$ ]] || die "BACKUP_MAX_COUNT 必须是正整数。"
+
+ find "$BACKUP_DIR" -maxdepth 1 -type f -name 'palworld-*.tar.gz' \
+ -mtime "+${days}" -delete
+ mapfile -t backups < <(
+ find "$BACKUP_DIR" -maxdepth 1 -type f -name 'palworld-*.tar.gz' \
+ -printf '%T@ %p\n' | sort -nr | cut -d' ' -f2-
+ )
+ if (( ${#backups[@]} > max_count )); then
+ for ((index=max_count; index<${#backups[@]}; index++)); do
+ rm -f -- "${backups[$index]}"
+ done
+ fi
+}
+
+create_backup() {
+ local timestamp final_file temp_file
+ require_runtime
+ mkdir -p -- "$SAVE_DIR" "$BACKUP_DIR"
+ chmod 700 "$BACKUP_DIR"
+
+ if container_running; then
+ if api_save; then
+ info "已请求服务器落盘存档。"
+ sleep 5
+ else
+ warn "REST API 保存请求失败;仍会创建文件级备份,但一致性保障较弱。"
+ fi
+ fi
+
+ timestamp="$(date +%Y%m%d-%H%M%S-%N)"
+ final_file="$BACKUP_DIR/palworld-${timestamp}.tar.gz"
+ temp_file="$BACKUP_DIR/.palworld-${timestamp}.tmp"
+ if ! tar -C "$ROOT_DIR" -czf "$temp_file" Saved; then
+ rm -f -- "$temp_file"
+ die "创建备份包失败。"
+ fi
+ if ! tar -tzf "$temp_file" >/dev/null; then
+ rm -f -- "$temp_file"
+ die "备份完整性校验失败。"
+ fi
+ mv -- "$temp_file" "$final_file"
+ chmod 600 "$final_file"
+ prune_backups
+ success "备份已创建:$final_file"
+}
+
+validate_backup_archive() {
+ local archive=$1 entry
+ tar -tzf "$archive" >/dev/null 2>&1 || die "备份包损坏或不是 tar.gz 文件。"
+ while IFS= read -r entry; do
+ [[ "$entry" == "Saved" || "$entry" == "Saved/" || "$entry" == Saved/* ]] \
+ || die "备份包包含 Saved 目录以外的路径:$entry"
+ [[ "/$entry/" != *"/../"* ]] || die "备份包包含不安全的上级目录路径。"
+ [[ "$entry" != /* ]] || die "备份包包含绝对路径。"
+ done < <(tar -tzf "$archive")
+ if tar -tvzf "$archive" | grep -E '^[lh]' >/dev/null; then
+ die "为安全起见,拒绝恢复包含符号链接或硬链接的备份包。"
+ fi
+}
+
+restore_backup() {
+ local archive=${1:-} stage old failed timestamp had_original=false
+ [[ -n "$archive" ]] || die "用法:$0 restore <备份文件.tar.gz>"
+ archive="$(readlink -f "$archive" 2>/dev/null || true)"
+ [[ -f "$archive" ]] || die "找不到备份文件。"
+ [[ "$archive" != "$SAVE_DIR"/* ]] || die "备份文件不能放在待替换的 Saved 目录内。"
+ require_runtime
+ validate_backup_archive "$archive"
+
+ if [[ -d "$SAVE_DIR" ]]; then
+ had_original=true
+ create_backup
+ fi
+ compose stop -t 120
+
+ timestamp="$(date +%Y%m%d-%H%M%S)"
+ stage="$(mktemp -d "$ROOT_DIR/.restore.XXXXXX")"
+ old="$ROOT_DIR/Saved.pre-restore-${timestamp}"
+ failed="$ROOT_DIR/Saved.failed-restore-${timestamp}"
+
+ if ! tar -xzf "$archive" -C "$stage" || [[ ! -d "$stage/Saved" ]]; then
+ rm -rf -- "$stage"
+ compose up -d || true
+ die "备份解压失败,原存档未改动。"
+ fi
+
+ [[ ! -e "$old" ]] || die "回滚目录已存在:$old"
+ if [[ "$had_original" == true ]]; then
+ mv -- "$SAVE_DIR" "$old"
+ fi
+ mv -- "$stage/Saved" "$SAVE_DIR"
+ rmdir "$stage" 2>/dev/null || true
+
+ if compose up -d && wait_until_running 120; then
+ if [[ "$had_original" == true ]]; then
+ success "存档恢复完成。恢复前数据保留在:$old"
+ else
+ success "存档恢复完成。"
+ fi
+ return 0
+ fi
+
+ warn "恢复后的服务器未能启动,正在自动回滚。"
+ compose stop -t 30 >/dev/null 2>&1 || true
+ mv -- "$SAVE_DIR" "$failed"
+ if [[ "$had_original" == true ]]; then
+ mv -- "$old" "$SAVE_DIR"
+ else
+ mkdir -p -- "$SAVE_DIR"
+ fi
+ compose up -d || true
+ die "恢复失败,已回滚原存档;失败数据保留在:$failed"
+}
+
+fetch_official_image() {
+ local image
+ image="$(curl --silent --show-error --fail --location --max-time 20 \
+ "$OFFICIAL_COMPOSE_URL" \
+ | awk '/^[[:space:]]*image:[[:space:]]*ghcr\.io\/pocketpairjp\/palserver:/ {print $2; exit}')"
+ [[ "$image" =~ ^ghcr\.io/pocketpairjp/palserver:v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] \
+ || die "无法从 Pocketpair 官方 Compose 文件解析有效镜像标签。"
+ printf '%s' "$image"
+}
+
+resolve_target_image() {
+ local official_image=$1 repository tag
+ repository="$(env_get PALWORLD_IMAGE_REPOSITORY ghcr.io/pocketpairjp/palserver)"
+ tag="${official_image##*:}"
+ [[ "$repository" =~ ^[A-Za-z0-9._:/-]+$ && "$repository" != *: ]] \
+ || die "PALWORLD_IMAGE_REPOSITORY 格式无效:$repository"
+ printf '%s:%s' "$repository" "$tag"
+}
+
+check_update() {
+ local current official latest
+ require_command curl
+ ensure_env
+ current="$(env_get PALWORLD_IMAGE "$DEFAULT_IMAGE")"
+ official="$(fetch_official_image)"
+ latest="$(resolve_target_image "$official")"
+ printf '当前镜像:%s\n官方标签:%s\n更新目标:%s\n' "$current" "${official##*:}" "$latest"
+ if [[ "$current" == "$latest" ]]; then
+ success "当前已是官方最新镜像标签。"
+ return 0
+ fi
+ info "发现可用更新。"
+ return 10
+}
+
+update_server() {
+ local current official latest was_running=false
+ require_runtime
+ ensure_env
+ current="$(env_get PALWORLD_IMAGE "$DEFAULT_IMAGE")"
+ official="$(fetch_official_image)"
+ latest="$(resolve_target_image "$official")"
+
+ if [[ "$current" == "$latest" ]]; then
+ success "当前已是官方最新镜像标签:$current"
+ return 0
+ fi
+
+ info "发现官方更新:$current -> $latest"
+ if container_running; then
+ was_running=true
+ fi
+ create_backup
+ if [[ "$was_running" == true ]] && api_announce "Server update in 30 seconds"; then
+ sleep 30
+ fi
+
+ info "拉取新镜像……"
+ docker pull "$latest"
+ env_set PALWORLD_IMAGE "$latest"
+
+ if ! compose config --quiet; then
+ env_set PALWORLD_IMAGE "$current"
+ die "新镜像标签写入后 Compose 校验失败,已回滚 .env。"
+ fi
+
+ if [[ "$was_running" == false ]]; then
+ success "镜像已更新到 $latest;服务器保持停止状态。"
+ return 0
+ fi
+
+ if compose up -d --force-recreate && wait_until_ready 240; then
+ success "服务器已更新到:$latest"
+ return 0
+ fi
+
+ warn "新版本启动失败,正在回滚到:$current"
+ env_set PALWORLD_IMAGE "$current"
+ compose up -d --force-recreate || true
+ wait_until_ready 240 || true
+ die "更新失败,已尝试回滚。请查看日志。"
+}
+
+install_maintenance() {
+ local script_quoted
+ require_root
+ require_runtime
+ require_command systemctl
+ [[ -s "$PASSWORD_FILE" ]] || warn "尚未保存管理员密码;定时备份无法先调用 REST API 落盘。"
+
+ script_quoted="$(printf '%q' "$SCRIPT_PATH")"
+ printf '#!/usr/bin/env bash\nexec /bin/bash %s "$@"\n' "$script_quoted" \
+ >/usr/local/sbin/paldsms
+ chmod 755 /usr/local/sbin/paldsms
+
+ cat >/etc/systemd/system/paldsms-backup.service <<'EOF'
+[Unit]
+Description=PalDSMS safe Palworld backup
+After=docker.service network-online.target
+Requires=docker.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/paldsms backup
+TimeoutStartSec=15min
+Nice=10
+IOSchedulingClass=best-effort
+IOSchedulingPriority=7
+EOF
+
+ cat >/etc/systemd/system/paldsms-backup.timer <<'EOF'
+[Unit]
+Description=Run PalDSMS backup hourly
+
+[Timer]
+OnCalendar=hourly
+Persistent=true
+RandomizedDelaySec=5m
+
+[Install]
+WantedBy=timers.target
+EOF
+
+ cat >/etc/systemd/system/paldsms-update.service <<'EOF'
+[Unit]
+Description=PalDSMS official Palworld image update
+After=docker.service network-online.target
+Wants=network-online.target
+Requires=docker.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/paldsms update
+TimeoutStartSec=30min
+EOF
+
+ cat >/etc/systemd/system/paldsms-update.timer <<'EOF'
+[Unit]
+Description=Check Pocketpair's official Palworld image daily
+
+[Timer]
+OnCalendar=*-*-* 04:15:00
+Persistent=true
+RandomizedDelaySec=30m
+
+[Install]
+WantedBy=timers.target
+EOF
+
+ systemctl daemon-reload
+ systemctl enable --now paldsms-backup.timer paldsms-update.timer
+ success "自动维护已启用:每小时备份,每日检查官方镜像更新。"
+ systemctl list-timers 'paldsms-*' --no-pager
+}
+
+uninstall_maintenance() {
+ require_root
+ require_command systemctl
+ systemctl disable --now paldsms-backup.timer paldsms-update.timer 2>/dev/null || true
+ rm -f \
+ /etc/systemd/system/paldsms-backup.service \
+ /etc/systemd/system/paldsms-backup.timer \
+ /etc/systemd/system/paldsms-update.service \
+ /etc/systemd/system/paldsms-update.timer \
+ /usr/local/sbin/paldsms
+ systemctl daemon-reload
+ success "自动维护已卸载;游戏存档和备份未删除。"
+}
+
+install_docker() {
+ local distro codename arch
+ require_root
+ [[ -r /etc/os-release ]] || die "无法识别 Linux 发行版。"
+ # shellcheck disable=SC1091
+ source /etc/os-release
+ distro="${ID:-}"
+ codename="${VERSION_CODENAME:-}"
+ case "$distro" in
+ debian|ubuntu) ;;
+ *) die "自动安装目前支持 Debian 和 Ubuntu;当前系统:${distro:-unknown}" ;;
+ esac
+ [[ -n "$codename" ]] || die "无法识别发行版代号 VERSION_CODENAME。"
+ arch="$(dpkg --print-architecture)"
+
+ apt-get update
+ apt-get install -y ca-certificates curl
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL "https://download.docker.com/linux/${distro}/gpg" \
+ -o /etc/apt/keyrings/docker.asc
+ chmod a+r /etc/apt/keyrings/docker.asc
+ printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' \
+ "$distro" "$codename" "$arch" >/etc/apt/sources.list.d/docker.sources
+ apt-get update
+ apt-get install -y docker-ce docker-ce-cli containerd.io \
+ docker-buildx-plugin docker-compose-plugin curl tar nano
+ systemctl enable --now docker
+ success "Docker Engine 与 Compose v2 已安装。"
+}
+
+doctor() {
+ local failures=0 arch memory_kib memory_gib available_kib available_gib
+ printf 'PalDSMS v%s 环境检查\n\n' "$PALDSMS_VERSION"
+
+ arch="$(uname -m)"
+ if [[ "$arch" == "x86_64" || "$arch" == "amd64" ]]; then
+ success "CPU 架构:$arch"
+ else
+ warn "CPU 架构为 $arch;Pocketpair Linux 专用服务器主要面向 x86_64。"
+ ((failures += 1))
+ fi
+
+ memory_kib="$(awk '/MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || printf '0')"
+ memory_gib=$((memory_kib / 1024 / 1024))
+ if (( memory_gib >= 16 )); then
+ success "内存:约 ${memory_gib} GiB"
+ else
+ warn "内存仅约 ${memory_gib} GiB;官方要求 16 GiB,推荐 32 GiB 以上。"
+ ((failures += 1))
+ fi
+
+ available_kib="$(df -Pk "$ROOT_DIR" | awk 'NR==2 {print $4}')"
+ available_gib=$((available_kib / 1024 / 1024))
+ if (( available_gib >= 20 )); then
+ success "可用磁盘:约 ${available_gib} GiB"
+ else
+ warn "可用磁盘仅约 ${available_gib} GiB;建议使用 SSD 并预留至少 20 GiB。"
+ ((failures += 1))
+ fi
+
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ success "Docker Compose v2 可用。"
+ else
+ warn "Docker Compose v2 不可用。"
+ ((failures += 1))
+ fi
+
+ if [[ -s "$SETTINGS_FILE" ]]; then
+ if grep -q 'RESTAPIEnabled=True' "$SETTINGS_FILE"; then
+ success "REST API 已在游戏配置中启用。"
+ else
+ warn "REST API 尚未启用,请运行 set-password。"
+ fi
+ if grep -q 'bIsUseBackupSaveData=True' "$SETTINGS_FILE"; then
+ success "游戏内建滚动备份已启用。"
+ else
+ warn "游戏内建滚动备份尚未启用。"
+ fi
+ else
+ warn "尚未生成服务器配置。"
+ fi
+
+ printf '\n'
+ if (( failures == 0 )); then
+ success "关键环境检查通过。"
+ else
+ warn "发现 ${failures} 项需要处理的问题。"
+ return 1
+ fi
+}
+
+show_help() {
+ cat <
+
+ menu 打开交互式中文菜单(无参数时默认)
+ doctor 检查 CPU、内存、磁盘和 Docker 环境
+ install-docker 在 Debian/Ubuntu 安装 Docker Engine
+ deploy 拉取官方镜像并首次部署
+ set-password 设置管理员密码,启用本地 REST API 与内建备份
+ config 编辑 PalWorldSettings.ini 并重启
+ start|stop|restart 管理服务器
+ status|logs 查看状态或跟踪日志
+ backup 创建经过校验的外部备份
+ restore <文件> 恢复备份;失败时自动回滚
+ check-update 对比 Pocketpair 官方镜像标签
+ update 备份后更新;启动失败时回滚镜像标签
+ install-maintenance 安装 systemd 自动备份/更新定时器
+ remove-maintenance 移除自动维护(不删除存档)
+ version 显示版本
+EOF
+}
+
+pause_menu() {
+ [[ -t 0 ]] || return 0
+ read -r -p "按回车键返回菜单……" _
+}
+
+menu() {
+ local choice backup_file
+ while true; do
+ clear 2>/dev/null || true
+ cat </dev/null || true
+ read -r -p "请输入要恢复的备份文件完整路径: " backup_file
+ restore_backup "$backup_file"
+ ;;
+ 11) check_update || [[ $? -eq 10 ]] ;;
+ 12) update_server ;;
+ 13) install_maintenance ;;
+ 14) uninstall_maintenance ;;
+ 15) show_logs ;;
+ 0) exit 0 ;;
+ *) warn "无效选项。" ;;
+ esac
+ pause_menu
+ done
+}
+
+main() {
+ local command=${1:-menu}
+ case "$command" in
+ menu) menu ;;
+ doctor) doctor ;;
+ install-docker) install_docker ;;
+ deploy) deploy_server ;;
+ set-password) set_admin_password ;;
+ config) edit_config ;;
+ start) start_server ;;
+ stop) stop_server ;;
+ restart) restart_server ;;
+ status) show_status ;;
+ logs) show_logs ;;
+ backup) create_backup ;;
+ restore) shift; restore_backup "${1:-}" ;;
+ check-update) check_update ;;
+ update) update_server ;;
+ install-maintenance) install_maintenance ;;
+ remove-maintenance) uninstall_maintenance ;;
+ help|-h|--help) show_help ;;
+ version|-v|--version) printf 'PalDSMS %s\n' "$PALDSMS_VERSION" ;;
+ *) show_help; die "未知命令:$command" ;;
+ esac
+}
+
+main "$@"