2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 14:57:16 +08:00
Files
panel/internal/service/toolbox_disk.go
Copilot 54b3b60efd feat: 添加磁盘管理工具到工具箱 (#1195)
* Initial plan

* 实现磁盘管理工具的后端和前端基础功能

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* 完成磁盘管理工具功能实现并验证构建成功

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* 添加输入验证防止命令注入攻击

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* 移除命令注入验证并修复评审意见

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: merge main

* feat: merge main

* feat: 分区优化

* feat: fstab管理

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-01-09 03:22:35 +08:00

568 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/shell"
)
type ToolboxDiskService struct {
t *gotext.Locale
}
func NewToolboxDiskService(t *gotext.Locale) *ToolboxDiskService {
return &ToolboxDiskService{
t: t,
}
}
// List 获取磁盘列表
func (s *ToolboxDiskService) List(w http.ResponseWriter, r *http.Request) {
// 获取磁盘基本信息
lsblkOutput, err := shell.Execf("lsblk -J -b -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,UUID,LABEL,MODEL")
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get disk list: %v", err))
return
}
// 解析 lsblk JSON
var lsblkData struct {
BlockDevices []any `json:"blockdevices"`
}
if err = json.Unmarshal([]byte(lsblkOutput), &lsblkData); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to parse disk list: %v", err))
return
}
// 获取磁盘使用情况
dfOutput, _ := shell.Execf("df -B1 --output=source,size,used,avail,pcent,target 2>/dev/null | tail -n +2")
// 解析 df 输出为 map
dfMap := make(map[string]map[string]string)
lines := strings.Split(strings.TrimSpace(dfOutput), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) >= 6 {
mountpoint := fields[5]
dfMap[mountpoint] = map[string]string{
"size": fields[1],
"used": fields[2],
"avail": fields[3],
"percent": strings.TrimSuffix(fields[4], "%"),
}
}
}
Success(w, chix.M{
"disks": lsblkData.BlockDevices,
"df": dfMap,
})
}
// GetPartitions 获取分区列表
func (s *ToolboxDiskService) GetPartitions(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskDevice](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
output, err := shell.Execf("lsblk -J -b -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,UUID,LABEL '/dev/%s'", req.Device)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get partitions: %v", err))
return
}
Success(w, output)
}
// Mount 挂载分区
func (s *ToolboxDiskService) Mount(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskMount](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("test -d '%s' || mkdir -p '%s'", req.Path, req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to create mount point: %v", err))
return
}
if _, err = shell.Execf("mount '/dev/%s' '%s'", req.Device, req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to mount partition: %v", err))
return
}
// 如果需要写入 fstab
if req.WriteFstab {
// 获取分区的 UUID
uuid, err := shell.Execf("blkid -s UUID -o value '/dev/%s'", req.Device)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get partition UUID: %v", err))
return
}
uuid = strings.TrimSpace(uuid)
if uuid == "" {
Error(w, http.StatusInternalServerError, s.t.Get("partition has no UUID"))
return
}
// 获取文件系统类型
fsType, err := shell.Execf("blkid -s TYPE -o value '/dev/%s'", req.Device)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get filesystem type: %v", err))
return
}
fsType = strings.TrimSpace(fsType)
if fsType == "" {
fsType = "auto"
}
// 挂载选项
mountOption := req.MountOption
if mountOption == "" {
mountOption = "defaults"
}
// 检查 fstab 中是否已存在该挂载点
existCheck, _ := shell.Execf("grep -E '^[^#].*\\s+%s\\s+' /etc/fstab", req.Path)
if strings.TrimSpace(existCheck) != "" {
Error(w, http.StatusBadRequest, s.t.Get("mount point %s already exists in fstab", req.Path))
return
}
// 写入 fstab
fstabEntry := fmt.Sprintf("UUID=%s %s %s %s 0 2", uuid, req.Path, fsType, mountOption)
if _, err = shell.Execf("echo '%s' >> /etc/fstab", fstabEntry); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to write fstab: %v", err))
return
}
}
Success(w, nil)
}
// Umount 卸载分区
func (s *ToolboxDiskService) Umount(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskUmount](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("umount '%s'", req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to umount partition: %v", err))
return
}
Success(w, nil)
}
// Format 格式化分区
func (s *ToolboxDiskService) Format(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskFormat](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
var formatCmd string
switch req.FsType {
case "ext4":
formatCmd = fmt.Sprintf("mkfs.ext4 -F '/dev/%s'", req.Device)
case "ext3":
formatCmd = fmt.Sprintf("mkfs.ext3 -F '/dev/%s'", req.Device)
case "xfs":
formatCmd = fmt.Sprintf("mkfs.xfs -f '/dev/%s'", req.Device)
case "btrfs":
formatCmd = fmt.Sprintf("mkfs.btrfs -f '/dev/%s'", req.Device)
default:
Error(w, http.StatusUnprocessableEntity, s.t.Get("unsupported filesystem type: %s", req.FsType))
return
}
if _, err = shell.Execf(formatCmd); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to format partition: %v", err))
return
}
Success(w, nil)
}
// Init 初始化磁盘(删除所有分区,创建单个分区并格式化)
func (s *ToolboxDiskService) Init(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskInit](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
device := "/dev/" + req.Device
// 检查设备是否存在
if _, err = shell.Execf("test -b '%s'", device); err != nil {
Error(w, http.StatusBadRequest, s.t.Get("device not found: %s", device))
return
}
// 检查是否为系统盘(检查是否有分区挂载在 /
mountInfo, _ := shell.Execf("lsblk -no MOUNTPOINT '%s' 2>/dev/null", device)
if strings.Contains(mountInfo, "/\n") || strings.TrimSpace(mountInfo) == "/" {
Error(w, http.StatusBadRequest, s.t.Get("cannot initialize system disk"))
return
}
// 卸载该磁盘的所有分区
_, _ = shell.Execf("umount '%s'* 2>/dev/null || true", device)
// 先清除分区表
if _, err = shell.Execf("wipefs -a '%s'", device); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to wipe disk: %v", err))
return
}
// sfdisk 创建 GPT 分区表和单个分区
if _, err = shell.Execf("echo 'type=linux' | sfdisk '%s'", device); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to create partition: %v", err))
return
}
// 等待内核更新分区表
_, _ = shell.Execf("partprobe '%s' 2>/dev/null || true", device)
_, _ = shell.Execf("sleep 1")
// 确定新分区的设备名device + "1",如 sdb1 或 nvme0n1p1
var partDevice string
if strings.Contains(req.Device, "nvme") || strings.Contains(req.Device, "loop") {
partDevice = device + "p1"
} else {
partDevice = device + "1"
}
// 格式化新分区
var formatCmd string
switch req.FsType {
case "ext4":
formatCmd = fmt.Sprintf("mkfs.ext4 -F '%s'", partDevice)
case "ext3":
formatCmd = fmt.Sprintf("mkfs.ext3 -F '%s'", partDevice)
case "xfs":
formatCmd = fmt.Sprintf("mkfs.xfs -f '%s'", partDevice)
case "btrfs":
formatCmd = fmt.Sprintf("mkfs.btrfs -f '%s'", partDevice)
default:
Error(w, http.StatusUnprocessableEntity, s.t.Get("unsupported filesystem type: %s", req.FsType))
return
}
if _, err = shell.Execf(formatCmd); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to format partition: %v", err))
return
}
Success(w, nil)
}
// GetLVMInfo 获取LVM信息
func (s *ToolboxDiskService) GetLVMInfo(w http.ResponseWriter, r *http.Request) {
// 获取物理卷信息
pvOutput, _ := shell.Execf("pvdisplay -C --noheadings --separator '|' -o pv_name,vg_name,pv_size,pv_free 2>/dev/null || echo ''")
// 获取卷组信息
vgOutput, _ := shell.Execf("vgdisplay -C --noheadings --separator '|' -o vg_name,pv_count,lv_count,vg_size,vg_free 2>/dev/null || echo ''")
// 获取逻辑卷信息
lvOutput, _ := shell.Execf("lvdisplay -C --noheadings --separator '|' -o lv_name,vg_name,lv_size,lv_path 2>/dev/null || echo ''")
pvs := s.parseLVMOutput(pvOutput)
vgs := s.parseLVMOutput(vgOutput)
lvs := s.parseLVMOutput(lvOutput)
Success(w, chix.M{
"pvs": pvs,
"vgs": vgs,
"lvs": lvs,
})
}
// CreatePV 创建物理卷
func (s *ToolboxDiskService) CreatePV(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskDevice](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("pvcreate '/dev/%s'", req.Device); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to create physical volume: %v", err))
return
}
Success(w, nil)
}
// CreateVG 创建卷组
func (s *ToolboxDiskService) CreateVG(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskVG](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 构建设备列表,每个设备单独引用
var deviceArgs []string
for _, dev := range req.Devices {
deviceArgs = append(deviceArgs, fmt.Sprintf("'%s'", dev))
}
if _, err = shell.Execf("vgcreate '%s' %s", req.Name, strings.Join(deviceArgs, " ")); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to create volume group: %v", err))
return
}
Success(w, nil)
}
// CreateLV 创建逻辑卷
func (s *ToolboxDiskService) CreateLV(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskLV](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 验证逻辑卷大小(必须为正数)
if req.Size <= 0 {
Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid logical volume size"))
return
}
// 创建逻辑卷
if _, err = shell.Execf("lvcreate -L '%dG' -n '%s' '%s'", req.Size, req.Name, req.VGName); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to create logical volume: %v", err))
return
}
Success(w, nil)
}
// RemovePV 删除物理卷
func (s *ToolboxDiskService) RemovePV(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskDevice](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("pvremove '%s'", req.Device); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to remove physical volume: %v", err))
return
}
Success(w, nil)
}
// RemoveVG 删除卷组
func (s *ToolboxDiskService) RemoveVG(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskVGName](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("vgremove -f '%s'", req.Name); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to remove volume group: %v", err))
return
}
Success(w, nil)
}
// RemoveLV 删除逻辑卷
func (s *ToolboxDiskService) RemoveLV(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskLVPath](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if _, err = shell.Execf("lvremove -f '%s'", req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to remove logical volume: %v", err))
return
}
Success(w, nil)
}
// ExtendLV 扩容逻辑卷
func (s *ToolboxDiskService) ExtendLV(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskExtendLV](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 验证扩容大小为正整数
if req.Size <= 0 {
Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid size"))
return
}
// 扩容逻辑卷
if _, err = shell.Execf("lvextend -L +%dG '%s'", req.Size, req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to extend logical volume: %v", err))
return
}
// 扩展文件系统
if req.Resize {
// 检测文件系统类型并扩展
fsType, _ := shell.Execf("blkid -o value -s TYPE '%s'", req.Path)
fsType = strings.TrimSpace(fsType)
switch fsType {
case "ext4", "ext3":
if _, err = shell.Execf("resize2fs '%s'", req.Path); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err))
return
}
case "xfs":
// XFS需要挂载后才能扩展
mountPoint, _ := shell.Execf("findmnt -n -o TARGET '%s'", req.Path)
mountPoint = strings.TrimSpace(mountPoint)
if mountPoint != "" {
if _, err = shell.Execf("xfs_growfs '%s'", mountPoint); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err))
return
}
} else {
// XFS未挂载时返回错误信息
Error(w, http.StatusInternalServerError, s.t.Get("xfs filesystem is not mounted, logical volume has been extended but filesystem was not resized"))
return
}
case "btrfs":
// btrfs需要挂载后才能扩展
mountPoint, _ := shell.Execf("findmnt -n -o TARGET '%s'", req.Path)
mountPoint = strings.TrimSpace(mountPoint)
if mountPoint != "" {
// 扩展到当前可用的最大空间
if _, err = shell.Execf("btrfs filesystem resize max '%s'", mountPoint); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err))
return
}
} else {
// btrfs未挂载时返回错误信息
Error(w, http.StatusInternalServerError, s.t.Get("btrfs filesystem is not mounted, logical volume has been extended but filesystem was not resized"))
return
}
}
}
Success(w, nil)
}
// parseLVMOutput 解析LVM命令输出
// 将LVM命令的表格输出解析为map数组每行数据的字段以field_0, field_1...命名
var spaceRegex = regexp.MustCompile(`\s+`)
func (s *ToolboxDiskService) parseLVMOutput(output string) []map[string]string {
lines := strings.Split(strings.TrimSpace(output), "\n")
var result []map[string]string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = spaceRegex.ReplaceAllString(line, " ")
fields := strings.Split(line, "|")
item := make(map[string]string)
for i, field := range fields {
item[fmt.Sprintf("field_%d", i)] = strings.TrimSpace(field)
}
result = append(result, item)
}
return result
}
// GetFstab 获取 fstab 列表
func (s *ToolboxDiskService) GetFstab(w http.ResponseWriter, r *http.Request) {
content, err := shell.Execf("cat /etc/fstab")
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to read fstab: %v", err))
return
}
var entries []request.ToolboxDiskFstabEntry
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// 跳过空行和注释
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 4 {
entry := request.ToolboxDiskFstabEntry{
Device: fields[0],
MountPoint: fields[1],
FsType: fields[2],
Options: fields[3],
}
if len(fields) >= 5 {
entry.Dump = fields[4]
}
if len(fields) >= 6 {
entry.Pass = fields[5]
}
entries = append(entries, entry)
}
}
Success(w, entries)
}
// DeleteFstab 删除 fstab 条目
func (s *ToolboxDiskService) DeleteFstab(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ToolboxDiskFstabDelete](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 不允许删除根目录挂载
if req.MountPoint == "/" {
Error(w, http.StatusBadRequest, s.t.Get("cannot delete root mount point"))
return
}
if _, err = shell.Execf(`sed -i 's@^[^#].*\s%s\s.*$@@g' /etc/fstab`, req.MountPoint); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to delete fstab entry: %v", err))
return
}
if _, err = shell.Execf("mount -a"); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to remount filesystems: %v", err))
return
}
Success(w, nil)
}