diff --git a/server/proto/vm.go b/server/proto/vm.go index 9581114..9660772 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -84,6 +84,10 @@ type GetSSHStateRsp struct { Enabled bool `json:"enabled"` } +type GetSwapStateRsp struct { + Enabled bool `json:"enabled"` +} + type GetMdnsStateRsp struct { Enabled bool `json:"enabled"` } diff --git a/server/router/vm.go b/server/router/vm.go index fe34b0b..2a2954b 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -40,6 +40,10 @@ func vmRouter(r *gin.Engine) { api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH + api.GET("/vm/swap", service.GetSwapState) // get Swap state + api.POST("/vm/swap/enable", service.EnableSwap) // enable Swap + api.POST("/vm/swap/disable", service.DisableSwap) // disable Swap + api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS diff --git a/server/service/vm/swap.go b/server/service/vm/swap.go new file mode 100644 index 0000000..4b6fcd5 --- /dev/null +++ b/server/service/vm/swap.go @@ -0,0 +1,136 @@ +package vm + +import ( + "NanoKVM-Server/proto" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +const ( + SwapFile = "/swapfile" + SwapSize = "128M" + InittabPath = "/etc/inittab" + TempInittab = "/etc/inittab.tmp" +) + +func (s *Service) GetSwapState(c *gin.Context) { + var rsp proto.Response + + enabled := isSwapEnabled() + rsp.OkRspWithData(c, &proto.GetSwapStateRsp{ + Enabled: enabled, + }) +} + +func (s *Service) EnableSwap(c *gin.Context) { + + var rsp proto.Response + + if err := exec.Command("fallocate", "-l", SwapSize, SwapFile).Run(); err != nil { + log.Errorf("create swap partition failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := exec.Command("chmod", "600", SwapFile).Run(); err != nil { + log.Errorf("chmod failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := exec.Command("mkswap", SwapFile).Run(); err != nil { + log.Errorf("formatting failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := exec.Command("swapon", SwapFile).Run(); err != nil { + log.Errorf("enable swap partition failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + fstabEntry := fmt.Sprintf("\nsi11::sysinit:/sbin/swapon %s", SwapFile) + f, err := os.OpenFile(InittabPath, os.O_APPEND|os.O_WRONLY, 0644) + + if err != nil { + log.Errorf("read fstab failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + defer f.Close() + + _, err = f.WriteString(fstabEntry) + if err != nil { + log.Errorf("write fstab failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + rsp.OkRsp(c) + log.Debugf("Swap enabled") +} + +func (s *Service) DisableSwap(c *gin.Context) { + var rsp proto.Response + + input, err := os.ReadFile(InittabPath) + if err != nil { + log.Errorf("read fstab failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + lines := strings.Split(string(input), "\n") + output := make([]string, 0) + for _, line := range lines { + if !strings.Contains(line, SwapFile) { + output = append(output, line) + } + } + + content := strings.Join(output, "\n") + content = strings.TrimSuffix(content, "\n") + if err := os.WriteFile(TempInittab, []byte(content), 0644); err != nil { + log.Errorf("write temp fstab failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := os.Rename(TempInittab, InittabPath); err != nil { + log.Errorf("replace fstab failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := exec.Command("swapoff", "-a").Run(); err != nil { + log.Errorf("close swap partition failed: %s", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + if err := os.Remove(SwapFile); err != nil && !os.IsNotExist(err) { + log.Warnf("delete swap file failed: %s (ignored)", err) + } + + rsp.OkRsp(c) + log.Debugf("Swap disabled") +} + +func isSwapEnabled() bool { + _, err := os.Stat(SwapFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false + } + } + + return true +} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index f3957ac..7a0cf43 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -77,6 +77,21 @@ export function disableSSH() { return http.post('/api/vm/ssh/disable'); } +// get Swap state +export function getSwapState() { + return http.get('/api/vm/swap'); +} + +// enable Swap +export function enableSwap() { + return http.post('/api/vm/swap/enable'); +} + +// disable Swap +export function disableSwap() { + return http.post('/api/vm/swap/disable'); +} + // get mDNS state export function getMdnsState() { return http.get('/api/vm/mdns'); diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 0e6af05..f86d0c5 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -220,6 +220,10 @@ const en = { description: 'Enable SSH remote access', tip: 'Set a strong password before enabling (Account - Change Password)' }, + swap: { + description: 'Enable the swap partition', + tip: 'Enable the default swap partition(128M)' + }, mdns: { description: 'Enable mDNS discovery service', tip: "Turning it off if it's not needed" diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 29e095a..a930870 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -204,6 +204,10 @@ const zh = { description: '启用 SSH 远程访问', tip: '启用前请务必设置强密码(帐号 - 修改密码)' }, + swap: { + description: '启用交换分区', + tip: '启用默认的交换分区(128M)' + }, mdns: { description: '启用 mDNS 发现服务', tip: '如果您未使用此功能,建议将其关闭' diff --git a/web/src/pages/desktop/menu/settings/device/index.tsx b/web/src/pages/desktop/menu/settings/device/index.tsx index 4045682..a510bd5 100644 --- a/web/src/pages/desktop/menu/settings/device/index.tsx +++ b/web/src/pages/desktop/menu/settings/device/index.tsx @@ -13,6 +13,7 @@ import { Reboot } from './reboot.tsx'; import { Ssh } from './ssh.tsx'; import { VirtualDevices } from './virtual-devices.tsx'; import { Wifi } from './wifi.tsx'; +import { Swap } from './swap.tsx'; export const Device = () => { const { t } = useTranslation(); @@ -37,6 +38,7 @@ export const Device = () => { + {hidMode === 'normal' ? : } diff --git a/web/src/pages/desktop/menu/settings/device/swap.tsx b/web/src/pages/desktop/menu/settings/device/swap.tsx new file mode 100644 index 0000000..c383630 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/device/swap.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { Switch, Tooltip } from 'antd'; +import { CircleAlertIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/vm.ts'; + +export const Swap = () => { + const { t } = useTranslation(); + + const [isEnabled, setIsEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + + api + .getSwapState() + .then((rsp) => { + if (rsp.data?.enabled) { + setIsEnabled(true); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + async function update() { + if (isLoading) return; + setIsLoading(true); + + const rsp = isEnabled ? await api.disableSwap() : await api.enableSwap(); + setIsLoading(false); + + if (rsp.code !== 0) { + console.log(rsp.msg); + return; + } + + setIsEnabled(!isEnabled); + } + + return ( +
+
+
+ Swap + + + + +
+ {t('settings.device.swap.description')} +
+ + +
+ ); +};