update version to 2.1.6

feat: support downloading image from online URL
feat: add keyboard shortcut Ctrl+Alt+Del
fix: fix the CSRF issue
perf: add an option to configure custom ICE servers
perf: removed unnecessary modifications to DNS configuration
perf: add an SSH enable/disable toggle in the web UI
perf: add a Tailscale enable/disable toggle in the web UI
perf: download Tailscale installation package from the official source
perf: automatic enable/disable GOMEMLIMIT on tailscale start/stop
perf: add JWT configuration
perf: implement secure password storage using bcrypt hashing
perf: implement integrity checks for online updates
refactor: refactor HDMI module and remove the dependency libmaixcam_lib.so
refactor: web terminal use pty instead of SSH
refactor: move Tailscale APIs from the network module to the extensions module
This commit is contained in:
wj-xiao
2025-02-14 14:18:13 +08:00
parent 9caf34e641
commit 6eb4a4ea62
67 changed files with 1533 additions and 1290 deletions

View File

@@ -44,9 +44,13 @@ logger:
# Note: Only disable authentication in development environment
authentication: enable
# JWT secret key configuration
# If left empty, a random key will be generated on each server start
secretKey: ""
jwt:
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
secretKey: ""
# JWT token expiration time in seconds. Default: 2678400 (31 days)
refreshTokenDuration: 2678400
# Invalidate all JWT tokens when the user logs out. Default: true
revokeTokensOnLogout: true
# Address for custom STUN server
stun: stun.l.google.com:19302
@@ -80,6 +84,15 @@ Note: Use Linux operating system (x86-64). This build process is not compatible
3. Run `patchelf --add-rpath \$ORIGIN/dl_lib NanoKVM-Server` to modify the RPATH of the executable file.
4. Deploy the Application
1. Before deploying, update the application to the latest version in your browser. Follow the instructions [here](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/system/updating.html).
1. File uploads requires SSH. Please enable it in the Web Settings: `Settings > SSH`;
2. Replace the original file in the NanoKVM `/kvmapp/server/` directory with the newly compiled `NanoKVM-Server`.
3. Restart the service on NanoKVM by executing `/etc/init.d/S95nanokvm restart`.
3. Restart the service on NanoKVM by executing `/etc/init.d/S95nanokvm restart`.
## Manually Update
> File uploads requires SSH. Please enable it in the Web Settings: `Settings > SSH`;
1. Download the latest application from [GitHub](https://github.com/sipeed/NanoKVM/releases);
2. Unzip the downloaded file and rename the unzipped folder to `kvmapp`;
3. Back up the existing `/kvmapp` directory on your NanoKVM, then replace it with the new `kvmapp` folder;
4. Run `/etc/init.d/S95nanokvm restart` on your NanoKVM to restart the service.

View File

@@ -42,9 +42,13 @@ logger:
# 注意:生产环境中请勿使用 disable。
authentication: enable
# JWT 密钥
# 如果不设置,则每次服务启动时使用随机生成的密钥
secretKey: ""
jwt:
# jwt 密钥。设置为空则使用随机生成的64位密钥
secretKey: ""
# jwt token 过期时间单位默认为267840031天
refreshTokenDuration: 2678400
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
revokeTokensOnLogout: true
stun: stun.l.google.com:19302
@@ -75,6 +79,15 @@ turn:
3. 执行 `patchelf --add-rpath \$ORIGIN/dl_lib NanoKVM-Server` 修改可执行文件的 RPATH 属性。
4. 部署
1. 部署前,请先在浏览器中将应用[更新](https://wiki.sipeed.com/hardware/zh/kvm/NanoKVM/system/updating.html)到最新版本;
2. 使用编译生成的 `NanoKVM-Server` 文件,替换 NanoKVM 中 `/kvmapp/server/` 目录下的原始文件;
1. 上传文件需要启用 SSH 功能。请在 Web `设置 - SSH` 中检查 SSH 是否已经启用;
2. 使用编译生成的 `NanoKVM-Server` 文件,替换 NanoKVM 中 `/kvmapp/server/` 目录下的原始文件;
3. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
## 手动更新
> 请确保已经在 Web 界面的 `设置 - SSH` 中启用了 SSH 功能,以便上传文件。
1. 从 [GitHub](https://github.com/sipeed/NanoKVM/releases) 下载最新的应用安装包;
2. 解压缩下载的安装包,并将解压后的文件夹重命名为 `kvmapp`
3. 备份 NanoKVM 系统中的 `/kvmapp` 目录,然后用解压后的 `kvmapp` 文件夹替换现有目录。
4. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。

View File

@@ -139,7 +139,7 @@ func (k *KvmVision) ReadH264PPS() ([]byte, int) {
return data, result
}
func (k *KvmVision) EnableHdmi(enable bool) int {
func (k *KvmVision) SetHDMI(enable bool) int {
hdmiEnable := C.uint8_t(0)
if enable {
hdmiEnable = C.uint8_t(1)

View File

@@ -2,28 +2,24 @@ package config
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log"
"os"
"sync"
"time"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
var (
config Config
once sync.Once
instance Config
once sync.Once
)
func GetInstance() *Config {
once.Do(initialize)
return &config
return &instance
}
func initialize() {
@@ -43,20 +39,16 @@ func initialize() {
log.Fatalf("Failed to validate configuration!")
}
if err := viper.Unmarshal(&config); err != nil {
if err := viper.Unmarshal(&instance); err != nil {
log.Fatalf("Failed to parse configuration: %s", err)
}
if config.Authentication == "disable" {
checkDefaultValue()
if instance.Authentication == "disable" {
log.Println("NOTICE: Authentication is disabled! Please ensure your service is secure!")
}
if config.SecretKey == "" {
config.SecretKey = generateRandomString()
}
config.Hardware = getHardware()
log.Println("config loaded successfully")
}
@@ -128,16 +120,3 @@ func validate() error {
return readByDefault()
}
// Generate random string for secret key.
func generateRandomString() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
currentTime := time.Now().UnixNano()
timeString := fmt.Sprintf("%d", currentTime)
return fmt.Sprintf("%064s", timeString)
}
return base64.URLEncoding.EncodeToString(b)
}

View File

@@ -14,6 +14,11 @@ var defaultConfig = &Config{
Level: "info",
File: "stdout",
},
JWT: JWT{
SecretKey: "",
RefreshTokenDuration: 2678400,
RevokeTokensOnLogout: true,
},
Stun: "stun.l.google.com:19302",
Turn: Turn{
TurnAddr: "turn.cloudflare.com:3478",
@@ -22,3 +27,24 @@ var defaultConfig = &Config{
},
Authentication: "enable",
}
func checkDefaultValue() {
if instance.JWT.SecretKey == "" {
instance.JWT.SecretKey = generateRandomSecretKey()
instance.JWT.RevokeTokensOnLogout = true
}
if instance.JWT.RefreshTokenDuration == 0 {
instance.JWT.RefreshTokenDuration = 2678400
}
if instance.Stun == "" {
instance.Stun = "stun.l.google.com:19302"
}
if instance.Authentication == "" {
instance.Authentication = "enable"
}
instance.Hardware = getHardware()
}

28
server/config/jwt.go Normal file
View File

@@ -0,0 +1,28 @@
package config
import (
"crypto/rand"
"encoding/base64"
"fmt"
"time"
)
// RegenerateSecretKey regenerate secret key when logout
func RegenerateSecretKey() {
if instance.JWT.RevokeTokensOnLogout {
instance.JWT.SecretKey = generateRandomSecretKey()
}
}
// Generate random string for secret key.
func generateRandomSecretKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
currentTime := time.Now().UnixNano()
timeString := fmt.Sprintf("%d", currentTime)
return fmt.Sprintf("%064s", timeString)
}
return base64.URLEncoding.EncodeToString(b)
}

View File

@@ -6,7 +6,7 @@ type Config struct {
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
SecretKey string `yaml:"secretKey"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
@@ -28,6 +28,12 @@ type Cert struct {
Key string `yaml:"key"`
}
type JWT struct {
SecretKey string `yaml:"secretKey"`
RefreshTokenDuration uint64 `yaml:"refreshTokenDuration"`
RevokeTokensOnLogout bool `yaml:"revokeTokensOnLogout"`
}
type Turn struct {
TurnAddr string `yaml:"turnAddr"`
TurnUser string `yaml:"turnUser"`

Binary file not shown.

BIN
server/dl_lib/libkvm_mmf.so Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -3,6 +3,7 @@ module NanoKVM-Server
go 1.22.1
require (
github.com/creack/pty v1.1.24
github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0

View File

@@ -6,6 +6,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -156,28 +158,17 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=

View File

@@ -37,6 +37,7 @@ void kvmv_init(uint8_t _debug_info_en);
* @param _pp_kvm_data @output: Encode data
* @param _p_kvmv_data_size @output: Encode data size
* @return
-4: Modifying image resolution, please wait
-3: img buffer full
-2: VENC Error
-1: No images were acquired

View File

@@ -8,9 +8,11 @@ import (
"NanoKVM-Server/router"
"NanoKVM-Server/utils"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
cors "github.com/rs/cors/wrapper/gin"
@@ -19,33 +21,51 @@ import (
func main() {
initialize()
defer dispose()
signalHandler()
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(cors.AllowAll())
router.Init(r)
run(r)
run()
}
func initialize() {
logger.Init()
// init screen parameters
_ = common.GetScreen()
_ = common.GetKvmVision()
// init HDMI
vision := common.GetKvmVision()
vision.SetHDMI(false)
time.Sleep(10 * time.Millisecond)
vision.SetHDMI(true)
utils.InitGoMemLimit()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
sig := <-sigChan
log.Printf("\nReceived signal: %v\n", sig)
dispose()
os.Exit(0)
}()
}
func run(r *gin.Engine) {
func run() {
conf := config.GetInstance()
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
if conf.Authentication == "disable" {
r.Use(cors.AllowAll())
}
router.Init(r)
httpAddr := fmt.Sprintf(":%d", conf.Port.Http)
httpsAddr := fmt.Sprintf(":%d", conf.Port.Https)
fmt.Printf("proto: %s, port: %d %d\n", conf.Proto, conf.Port.Http, conf.Port.Https)
log.Printf("proto: %s, port: %d %d\n", conf.Proto, conf.Port.Http, conf.Port.Https)
if conf.Proto == "https" {
r.Use(middleware.Tls())
@@ -69,16 +89,3 @@ func run(r *gin.Engine) {
func dispose() {
common.GetKvmVision().Close()
}
func signalHandler() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
sig := <-sigChan
fmt.Printf("\nReceived signal: %v\n", sig)
dispose()
os.Exit(0)
}()
}

View File

@@ -16,10 +16,6 @@ type Token struct {
jwt.RegisteredClaims
}
const (
ExpireDuration = 31 * 24 * time.Hour
)
func CheckToken() gin.HandlerFunc {
return func(c *gin.Context) {
conf := config.GetInstance()
@@ -46,24 +42,25 @@ func CheckToken() gin.HandlerFunc {
func GenerateJWT(username string) (string, error) {
conf := config.GetInstance()
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
claims := Token{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExpireDuration)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(conf.SecretKey))
return t.SignedString([]byte(conf.JWT.SecretKey))
}
func ParseJWT(jwtToken string) (*Token, error) {
conf := config.GetInstance()
t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) {
secretKey := conf.SecretKey
return []byte(secretKey), nil
return []byte(conf.JWT.SecretKey), nil
})
if err != nil {
log.Debugf("parse jwt error: %s", err)

View File

@@ -1,7 +1,7 @@
package proto
type ImageEnabledRsp struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
}
type StatusImageRsp struct {

View File

@@ -79,3 +79,7 @@ type GetOLEDRsp struct {
Exist bool `json:"exist"`
Sleep int `json:"sleep"`
}
type GetSSHStateRsp struct {
Enabled bool `json:"enabled"`
}

View File

@@ -17,4 +17,5 @@ func authRouter(r *gin.Engine) {
api.GET("/auth/password", service.IsPasswordUpdated) // is password updated
api.GET("/auth/account", service.GetAccount) // get account
api.POST("/auth/password", service.ChangePassword) // change password
api.POST("/auth/logout", service.Logout) // logout
}

View File

@@ -0,0 +1,25 @@
package router
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/extensions/tailscale"
"github.com/gin-gonic/gin"
)
func extensionsRouter(r *gin.Engine) {
api := r.Group("/api/extensions").Use(middleware.CheckToken())
ts := tailscale.NewService()
api.POST("/tailscale/install", ts.Install) // install tailscale
api.POST("/tailscale/uninstall", ts.Uninstall) // uninstall tailscale
api.GET("/tailscale/status", ts.GetStatus) // get tailscale status
api.POST("/tailscale/up", ts.Up) // run tailscale up
api.POST("/tailscale/down", ts.Down) // run tailscale down
api.POST("/tailscale/login", ts.Login) // tailscale login
api.POST("/tailscale/logout", ts.Logout) // tailscale logout
api.POST("/tailscale/start", ts.Start) // tailscale start
api.POST("/tailscale/stop", ts.Stop) // tailscale stop
api.POST("/tailscale/restart", ts.Restart) // tailscale restart
}

View File

@@ -18,15 +18,5 @@ func networkRouter(r *gin.Engine) {
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/tailscale/install", service.TsInstall) // install tailscale
api.POST("/network/tailscale/uninstall", service.TsUninstall) // uninstall tailscale
api.GET("/network/tailscale/status", service.GetTsStatus) // get tailscale status
api.POST("/network/tailscale/up", service.TsUp) // run tailscale up
api.POST("/network/tailscale/down", service.TsDown) // run tailscale down
api.POST("/network/tailscale/login", service.TsLogin) // tailscale login
api.POST("/network/tailscale/logout", service.TsLogout) // tailscale logout
api.POST("/network/tailscale/stop", service.TsStop) // tailscale stop
api.POST("/network/tailscale/restart", service.TsRestart) // tailscale restart
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
}

View File

@@ -38,4 +38,5 @@ func server(r *gin.Engine) {
hidRouter(r)
wsRouter(r)
downloadRouter(r)
extensionsRouter(r)
}

View File

@@ -35,4 +35,8 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi (pcie only)
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
}

View File

@@ -1,211 +0,0 @@
package application
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
)
const (
maxTries = 3
)
func (s *Service) GetVersion(c *gin.Context) {
var rsp proto.Response
log.Debugf("get version api triggered")
// current version
currentVersion := "1.0.0"
content, err := os.ReadFile(versionFile)
if err == nil {
currentVersion = strings.ReplaceAll(string(content), "\n", "")
}
log.Debugf("current version: %s", currentVersion)
// latest version
url := fmt.Sprintf("%s?now=%d", versionURL, time.Now().Unix())
resp, err := http.Get(url)
if err != nil {
log.Debugf("get latest version failed: %v", err)
rsp.ErrRsp(c, -2, "Unable to access sipeed.com. Please check your network.")
return
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("read body failed: %v", err)
rsp.ErrRsp(c, -4, "read body failed")
return
}
if resp.StatusCode != http.StatusOK {
log.Errorf("server responded with status code: %d", resp.StatusCode)
rsp.ErrRsp(c, -3, fmt.Sprintf("get version failed, server returns status code: %d, body: %s", resp.StatusCode, string(body)))
return
}
latestVersion := strings.ReplaceAll(string(body), "\n", "")
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latestVersion,
})
}
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
log.Debugf("update application api triggered")
if err := updateApp(); err != nil {
log.Debugf("update failed: %s", err)
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("update application success")
// Sleep for a second before restarting the device
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func updateApp() error {
log.Debugf("update application")
_ = os.RemoveAll(temporary)
_ = os.MkdirAll(temporary, 0o755)
defer func() {
_ = os.RemoveAll(temporary)
}()
if err := downloadLib(); err != nil {
log.Errorf("download lib failed: %s", err)
return err
}
if err := downloadApp(); err != nil {
log.Errorf("download app failed: %s", err)
return err
}
err := utils.MoveFile(temporary+"/"+libName, temporary+"/latest/kvm_system/dl_lib/"+libName) // move lib
if err != nil {
log.Errorf("rename lib failed: %s", err)
return err
}
// backup old version
err = os.RemoveAll(backup)
if err != nil {
log.Errorf("remove backup failed: %s", err)
return err
}
err = utils.MoveFilesRecursively(workspace, backup)
if err != nil {
log.Errorf("backuping old libraries failed: %s", err)
return err
}
// update
err = utils.MoveFilesRecursively(temporary+"/latest", workspace)
if err != nil {
log.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
err = utils.ChmodRecursively(workspace, 0o755)
if err != nil {
log.Errorf("chmod failed: %s", err)
return err
}
return nil
}
func downloadApp() error {
var err error
log.Debugf("downloading latest application...")
url := fmt.Sprintf("%s?now=%d", applicationURL, time.Now().Unix())
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
}
var req *http.Request
req, err = http.NewRequest("GET", url, nil)
if err != nil {
log.Errorf("new request err: %s", err)
continue
}
zipFile := temporary + "/latest.zip"
log.Debugf("update will be saved to: %s", zipFile)
err = utils.Download(req, zipFile)
if err != nil {
log.Errorf("downloading latest application failed, try again...")
continue
}
err = utils.Unzip(zipFile, temporary)
if err != nil {
log.Errorf("unzip app failed: %s", err)
continue
}
return nil
}
return err
}
func downloadLib() error {
log.Debugf("downloading libs...")
content, err := os.ReadFile("/device_key")
if err != nil {
log.Errorf("error reading device key: %s", err)
return err
}
deviceKey := strings.ReplaceAll(string(content), "\n", "")
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
}
var req *http.Request
url := fmt.Sprintf("%s?uid=%s", libURL, deviceKey)
req, err = http.NewRequest("GET", url, nil)
if err != nil {
log.Errorf("error creating new request: %s", err)
continue
}
req.Header.Set("token", "MaixVision2024")
target := fmt.Sprintf("%s/%s", temporary, libName)
err = utils.Download(req, target)
if err != nil {
log.Errorf("downloading lib failed: %s", err)
continue
}
return nil
}
return err
}

View File

@@ -1,15 +1,11 @@
package application
const (
versionURL = "https://cdn.sipeed.com/nanokvm/latest"
applicationURL = "https://cdn.sipeed.com/nanokvm/latest.zip"
libURL = "https://maixvision.sipeed.com/api/v1/nanokvm/encryption"
BaseURL = "https://cdn.sipeed.com/nanokvm"
temporary = "/root/.kvmcache"
workspace = "/kvmapp"
backup = "/root/old"
libName = "libmaixcam_lib.so"
versionFile = "/kvmapp/version"
AppDir = "/kvmapp"
BackupDir = "/root/old"
CacheDir = "/root/.kvmcache"
)
type Service struct{}

View File

@@ -0,0 +1,222 @@
package application
import (
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
)
const (
maxTries = 3
)
type Latest struct {
Version string `json:"version"`
Name string `json:"name"`
Sha512 string `json:"sha512"`
Size uint `json:"size"`
}
func (s *Service) GetVersion(c *gin.Context) {
var rsp proto.Response
// current version
currentVersion := "1.0.0"
versionFile := fmt.Sprintf("%s/version", AppDir)
if version, err := os.ReadFile(versionFile); err == nil {
currentVersion = strings.ReplaceAll(string(version), "\n", "")
}
log.Debugf("current version: %s", currentVersion)
// latest version
latest, err := getLatest()
if err != nil {
rsp.ErrRsp(c, -1, "get latest version failed")
return
}
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latest.Version,
})
}
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("update application success")
// Sleep for a second before restarting the device
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func update() error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
// get latest information
latest, err := getLatest()
if err != nil {
return err
}
// download
url := fmt.Sprintf("%s/%s", BaseURL, latest.Name)
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
if err := download(url, target); err != nil {
log.Errorf("download app failed: %s", err)
return err
}
// check sha512
if err := checksum(target, latest.Sha512); err != nil {
log.Errorf("check sha512 failed: %s", err)
return err
}
// decompress
dir, err := utils.UnTarGz(target, CacheDir)
log.Debugf("untar: %s", dir)
if err != nil {
log.Errorf("decompress app failed: %s", err)
return err
}
// backup old version
if err := os.RemoveAll(BackupDir); err != nil {
log.Errorf("remove backup failed: %s", err)
return err
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
log.Errorf("backup app failed: %s", err)
return err
}
// update
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
log.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
log.Errorf("chmod failed: %s", err)
return err
}
return nil
}
func getLatest() (*Latest, error) {
url := fmt.Sprintf("%s/latest.json?now=%d", BaseURL, time.Now().Unix())
resp, err := http.Get(url)
if err != nil {
log.Debugf("failed to request version: %v", err)
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("failed to read response: %v", err)
return nil, err
}
if resp.StatusCode != http.StatusOK {
log.Errorf("server responded with status code: %d", resp.StatusCode)
return nil, err
}
var latest Latest
if err := json.Unmarshal(body, &latest); err != nil {
log.Errorf("failed to unmarshal response: %s", err)
return nil, err
}
log.Debugf("get application latest version: %s", latest.Version)
return &latest, nil
}
func download(url string, target string) (err error) {
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
}
var req *http.Request
req, err = http.NewRequest("GET", url, nil)
if err != nil {
log.Errorf("new request err: %s", err)
continue
}
log.Debugf("update will be saved to: %s", target)
err = utils.Download(req, target)
if err != nil {
log.Errorf("downloading latest application failed, try again...")
continue
}
return nil
}
return err
}
func checksum(filePath string, expectedHash string) error {
file, err := os.Open(filePath)
if err != nil {
log.Errorf("failed to open file %s: %v", filePath, err)
return err
}
defer func() {
_ = file.Close()
}()
hasher := sha512.New()
_, err = io.Copy(hasher, file)
if err != nil {
log.Errorf("failed to copy file contents to hasher: %v", err)
return err
}
hash := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
if hash != expectedHash {
log.Errorf("invalid sha512 %s", hash)
return fmt.Errorf("invalid sha512 %s", hash)
}
return nil
}

View File

@@ -1,12 +1,14 @@
package utils
package auth
import (
"NanoKVM-Server/utils"
"encoding/json"
"errors"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
const AccountFile = "/etc/kvm/pwd"
@@ -16,27 +18,11 @@ type Account struct {
Password string `json:"password"`
}
func IsAccountExist() bool {
if _, err := os.Stat(AccountFile); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false
}
return false
}
return true
}
func GetAccount() (*Account, error) {
// use default account
if _, err := os.Stat(AccountFile); err != nil {
if errors.Is(err, os.ErrNotExist) {
return &Account{
Username: "admin",
Password: "admin",
}, nil
return getDefaultAccount(), nil
}
return nil, err
}
@@ -46,19 +32,11 @@ func GetAccount() (*Account, error) {
}
var account Account
err = json.Unmarshal(content, &account)
if err != nil {
if err = json.Unmarshal(content, &account); err != nil {
log.Errorf("unmarshal account failed: %s", err)
return nil, err
}
password, err := DecodeDecrypt(account.Password)
if err != nil {
return nil, err
}
account.Password = password
return &account, nil
}
@@ -87,6 +65,35 @@ func SetAccount(username string, password string) error {
return nil
}
func CompareAccount(username string, password string) bool {
account, err := GetAccount()
if err != nil {
return false
}
if username != account.Username {
return false
}
decryptedPassword, err := utils.DecodeDecrypt(password)
if err != nil || decryptedPassword == "" {
return false
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(decryptedPassword))
if err != nil {
// Compatible with old versions
accountDecryptedPassword, _ := utils.DecodeDecrypt(account.Password)
if accountDecryptedPassword == decryptedPassword {
return true
}
return false
}
return true
}
func DelAccount() error {
if err := os.Remove(AccountFile); err != nil {
log.Errorf("failed to delete password: %s", err)
@@ -95,3 +102,12 @@ func DelAccount() error {
return nil
}
func getDefaultAccount() *Account {
password, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
return &Account{
Username: "admin",
Password: string(password),
}
}

View File

@@ -4,9 +4,6 @@ import (
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"fmt"
"os"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -16,11 +13,6 @@ func (s *Service) Login(c *gin.Context) {
var req proto.LoginReq
var rsp proto.Response
if !isLibExist() {
rsp.ErrRsp(c, -6, "Lib not exist! Please connect to internet and update.")
return
}
// authentication disabled
conf := config.GetInstance()
if conf.Authentication == "disable" {
@@ -35,26 +27,14 @@ func (s *Service) Login(c *gin.Context) {
return
}
passwordDecrypt, err := utils.DecodeDecrypt(req.Password)
if err != nil {
rsp.ErrRsp(c, -2, "decrypt password failed")
return
}
account, err := utils.GetAccount()
if err != nil {
rsp.ErrRsp(c, -3, "get account failed")
return
}
if req.Username != account.Username || passwordDecrypt != account.Password {
rsp.ErrRsp(c, -4, "invalid username or password")
if ok := CompareAccount(req.Username, req.Password); !ok {
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
rsp.ErrRsp(c, -5, "generate token failed")
rsp.ErrRsp(c, -3, "generate token failed")
return
}
@@ -65,10 +45,21 @@ func (s *Service) Login(c *gin.Context) {
log.Debugf("login success, username: %s", req.Username)
}
func (s *Service) Logout(c *gin.Context) {
conf := config.GetInstance()
if conf.JWT.RevokeTokensOnLogout {
config.RegenerateSecretKey()
}
var rsp proto.Response
rsp.OkRsp(c)
}
func (s *Service) GetAccount(c *gin.Context) {
var rsp proto.Response
account, err := utils.GetAccount()
account, err := GetAccount()
if err != nil {
rsp.ErrRsp(c, -1, "get account failed")
return
@@ -79,9 +70,3 @@ func (s *Service) GetAccount(c *gin.Context) {
})
log.Debugf("get account successful")
}
func isLibExist() bool {
libPath := fmt.Sprintf("/kvmapp/kvm_system/dl_lib/libmaixcam_lib.so")
_, err := os.Stat(libPath)
return err == nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
func (s *Service) ChangePassword(c *gin.Context) {
@@ -21,23 +22,28 @@ func (s *Service) ChangePassword(c *gin.Context) {
return
}
err := utils.SetAccount(req.Username, req.Password)
if err != nil {
rsp.ErrRsp(c, -2, "failed to save password")
password, err := utils.DecodeDecrypt(req.Password)
if err != nil || password == "" {
rsp.ErrRsp(c, -2, "invalid password")
return
}
account, err := utils.GetAccount()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
rsp.ErrRsp(c, -3, "failed to get password")
rsp.ErrRsp(c, -3, "failed to hash password")
return
}
if err = SetAccount(req.Username, string(hashedPassword)); err != nil {
rsp.ErrRsp(c, -4, "failed to save password")
return
}
// change root password
err = changeRootPassword(account.Password)
err = changeRootPassword(password)
if err != nil {
_ = utils.DelAccount()
rsp.ErrRsp(c, -4, "failed to change password")
_ = DelAccount()
rsp.ErrRsp(c, -5, "failed to change password")
return
}
@@ -48,24 +54,24 @@ func (s *Service) ChangePassword(c *gin.Context) {
func (s *Service) IsPasswordUpdated(c *gin.Context) {
var rsp proto.Response
isUpdated := false
if utils.IsAccountExist() {
account, err := utils.GetAccount()
if err != nil {
rsp.ErrRsp(c, -1, "failed to get password")
return
}
if account != nil && account.Password != "admin" {
isUpdated = true
}
if _, err := os.Stat(AccountFile); err != nil {
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
IsUpdated: false,
})
return
}
account, err := GetAccount()
if err != nil || account == nil {
rsp.ErrRsp(c, -1, "failed to get password")
return
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin"))
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
IsUpdated: isUpdated,
IsUpdated: err == nil,
})
log.Debugf("is password updated: %t", isUpdated)
}
func changeRootPassword(password string) error {

View File

@@ -69,11 +69,13 @@ func (c *Cli) Stop() error {
return err
}
_ = os.Remove(ConfigPath)
return os.Remove(ScriptPath)
}
func (c *Cli) Up() error {
command := "tailscale up"
command := "tailscale up --accept-dns=false"
return exec.Command("sh", "-c", command).Run()
}
@@ -111,7 +113,7 @@ func (c *Cli) Status() (*TsStatus, error) {
}
func (c *Cli) Login() (string, error) {
command := "tailscale login --timeout=10m"
command := "tailscale login --accept-dns=false --timeout=10m"
cmd := exec.Command("sh", "-c", command)
stderr, err := cmd.StderrPipe()

View File

@@ -0,0 +1,118 @@
package tailscale
import (
"NanoKVM-Server/utils"
"fmt"
"io"
"net/http"
"os"
log "github.com/sirupsen/logrus"
)
const (
OriginalURL = "https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
Workspace = "/root/.tailscale"
)
func isInstalled() bool {
_, err1 := os.Stat(TailscalePath)
_, err2 := os.Stat(TailscaledPath)
return err1 == nil && err2 == nil
}
func install() error {
_ = os.MkdirAll(Workspace, 0o755)
defer func() {
_ = os.RemoveAll(Workspace)
}()
tarFile := fmt.Sprintf("%s/tailscale_riscv64.tgz", Workspace)
// download
if err := download(tarFile); err != nil {
log.Errorf("failed to download tailscale: %s", err)
return err
}
// decompress
dir, err := utils.UnTarGz(tarFile, Workspace)
if err != nil {
log.Errorf("failed to decompress tailscale: %s", err)
return err
}
// move
tailscalePath := fmt.Sprintf("%s/tailscale", dir)
err = utils.MoveFile(tailscalePath, TailscalePath)
if err != nil {
log.Errorf("failed to move tailscale: %s", err)
return err
}
tailscaledPath := fmt.Sprintf("%s/tailscaled", dir)
err = utils.MoveFile(tailscaledPath, TailscaledPath)
if err != nil {
log.Errorf("failed to move tailscaled: %s", err)
return err
}
log.Debugf("install tailscale successfully")
return nil
}
func download(target string) error {
url, err := getDownloadURL()
if err != nil {
log.Errorf("failed to get Tailscale download url: %s", err)
return err
}
resp, err := http.Get(url)
if err != nil {
log.Errorf("failed to download Tailscale: %s", err)
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
out, err := os.Create(target)
if err != nil {
log.Errorf("failed to create file: %s", err)
return err
}
defer func() {
_ = out.Close()
}()
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Errorf("failed to copy response body to file: %s", err)
return err
}
log.Debugf("download Tailscale successfully")
return nil
}
func getDownloadURL() (string, error) {
resp, err := (&http.Client{}).Get(OriginalURL)
if err != nil {
return "", err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp.Request.URL.String(), nil
}

View File

@@ -0,0 +1,235 @@
package tailscale
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"net"
"os"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Service struct{}
const (
TailscalePath = "/usr/bin/tailscale"
TailscaledPath = "/usr/sbin/tailscaled"
ConfigPath = "etc/sysctl.d/99-tailscale.conf"
)
var StateMap = map[string]proto.TailscaleState{
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
}
func NewService() *Service {
return &Service{}
}
func (s *Service) Install(c *gin.Context) {
var rsp proto.Response
if !isInstalled() {
if err := install(); err != nil {
rsp.ErrRsp(c, -1, "install failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("install tailscale successfully")
}
func (s *Service) Uninstall(c *gin.Context) {
var rsp proto.Response
_ = NewCli().Stop()
_ = utils.DelGoMemLimit()
_ = os.Remove(TailscalePath)
_ = os.Remove(TailscaledPath)
_ = os.Remove(ConfigPath)
rsp.OkRsp(c)
log.Debugf("uninstall tailscale successfully")
}
func (s *Service) Start(c *gin.Context) {
var rsp proto.Response
err := NewCli().Start()
if err != nil {
rsp.ErrRsp(c, -1, "start failed")
log.Errorf("failed to run tailscale start: %s", err)
return
}
if !utils.IsGoMemLimitExist() {
_ = utils.SetGoMemLimit(50)
}
rsp.OkRsp(c)
log.Debugf("tailscale start successfully")
}
func (s *Service) Restart(c *gin.Context) {
var rsp proto.Response
err := NewCli().Restart()
if err != nil {
rsp.ErrRsp(c, -1, "restart failed")
log.Errorf("failed to run tailscale restart: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("tailscale restart successfully")
}
func (s *Service) Stop(c *gin.Context) {
var rsp proto.Response
err := NewCli().Stop()
if err != nil {
rsp.ErrRsp(c, -1, "stop failed")
log.Errorf("failed to run tailscale stop: %s", err)
return
}
_ = utils.DelGoMemLimit()
rsp.OkRsp(c)
log.Debugf("tailscale stop successfully")
}
func (s *Service) Up(c *gin.Context) {
var rsp proto.Response
err := NewCli().Up()
if err != nil {
rsp.ErrRsp(c, -1, "tailscale up failed")
log.Errorf("failed to run tailscale up: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("run tailscale up successfully")
}
func (s *Service) Down(c *gin.Context) {
var rsp proto.Response
err := NewCli().Down()
if err != nil {
rsp.ErrRsp(c, -1, "tailscale down failed")
log.Errorf("failed to run tailscale down: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("run tailscale down successfully")
}
func (s *Service) Login(c *gin.Context) {
var rsp proto.Response
// check tailscale status
cli := NewCli()
status, err := cli.Status()
if err != nil {
_ = cli.Start()
status, err = cli.Status()
}
if err != nil {
log.Errorf("failed to get tailscale status: %s", err)
rsp.ErrRsp(c, -1, "unknown status")
return
}
if status.BackendState == "Running" {
rsp.OkRspWithData(c, &proto.LoginTailscaleRsp{})
return
}
// get login url
url, err := cli.Login()
if err != nil {
log.Errorf("failed to run tailscale login: %s", err)
rsp.ErrRsp(c, -2, "login failed")
return
}
if !utils.IsGoMemLimitExist() {
_ = utils.SetGoMemLimit(50)
}
rsp.OkRspWithData(c, &proto.LoginTailscaleRsp{
Url: url,
})
log.Debugf("tailscale login url: %s", url)
}
func (s *Service) Logout(c *gin.Context) {
var rsp proto.Response
err := NewCli().Logout()
if err != nil {
rsp.ErrRsp(c, -1, "logout failed")
log.Errorf("failed to run tailscale logout: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("tailscale logout successfully")
}
func (s *Service) GetStatus(c *gin.Context) {
var rsp proto.Response
if !isInstalled() {
rsp.OkRspWithData(c, &proto.GetTailscaleStatusRsp{
State: proto.TailscaleNotInstall,
})
return
}
status, err := NewCli().Status()
if err != nil {
log.Debugf("failed to get tailscale status: %s", err)
rsp.OkRspWithData(c, &proto.GetTailscaleStatusRsp{
State: proto.TailscaleNotRunning,
})
return
}
state, ok := StateMap[status.BackendState]
if !ok {
log.Errorf("unknown tailscale state: %s", status.BackendState)
rsp.ErrRsp(c, -1, "unknown state")
return
}
ipv4 := ""
for _, tailscaleIp := range status.Self.TailscaleIPs {
ip := net.ParseIP(tailscaleIp)
if ip != nil && ip.To4() != nil {
ipv4 = ip.String()
}
}
data := proto.GetTailscaleStatusRsp{
State: state,
IP: ipv4,
Name: status.Self.HostName,
Account: status.CurrentTailnet.Name,
}
rsp.OkRspWithData(c, &data)
log.Debugf("get tailscale status successfully")
}

View File

@@ -1,49 +1,7 @@
package network
import (
"NanoKVM-Server/service/network/tailscale"
"github.com/gin-gonic/gin"
)
type Service struct{}
func NewService() *Service {
return &Service{}
}
func (s *Service) TsInstall(c *gin.Context) {
tailscale.Install(c)
}
func (s *Service) TsUninstall(c *gin.Context) {
tailscale.Uninstall(c)
}
func (s *Service) GetTsStatus(c *gin.Context) {
tailscale.GetStatus(c)
}
func (s *Service) TsUp(c *gin.Context) {
tailscale.Up(c)
}
func (s *Service) TsDown(c *gin.Context) {
tailscale.Down(c)
}
func (s *Service) TsLogin(c *gin.Context) {
tailscale.Login(c)
}
func (s *Service) TsLogout(c *gin.Context) {
tailscale.Logout(c)
}
func (s *Service) TsStop(c *gin.Context) {
tailscale.Stop(c)
}
func (s *Service) TsRestart(c *gin.Context) {
tailscale.Restart(c)
}

View File

@@ -1,87 +0,0 @@
package tailscale
import (
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func Login(c *gin.Context) {
var rsp proto.Response
// check tailscale status
cli := NewCli()
status, err := cli.Status()
if err != nil {
_ = cli.Start()
status, err = cli.Status()
}
if err != nil {
log.Errorf("failed to get tailscale status: %s", err)
rsp.ErrRsp(c, -1, "unknown status")
return
}
if status.BackendState == "Running" {
rsp.OkRspWithData(c, &proto.LoginTailscaleRsp{})
return
}
// get login url
url, err := cli.Login()
if err != nil {
log.Errorf("failed to run tailscale login: %s", err)
rsp.ErrRsp(c, -2, "login failed")
return
}
rsp.OkRspWithData(c, &proto.LoginTailscaleRsp{
Url: url,
})
log.Debugf("tailscale login url: %s", url)
}
func Logout(c *gin.Context) {
var rsp proto.Response
err := NewCli().Logout()
if err != nil {
rsp.ErrRsp(c, -1, "logout failed")
log.Errorf("failed to run tailscale logout: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("tailscale logout successfully")
}
func Restart(c *gin.Context) {
var rsp proto.Response
err := NewCli().Restart()
if err != nil {
rsp.ErrRsp(c, -1, "restart failed")
log.Errorf("failed to run tailscale restart: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("tailscale restart successfully")
}
func Stop(c *gin.Context) {
var rsp proto.Response
err := NewCli().Stop()
if err != nil {
rsp.ErrRsp(c, -1, "stop failed")
log.Errorf("failed to run tailscale stop: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("tailscale stop successfully")
}

View File

@@ -1,97 +0,0 @@
package tailscale
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
DownloadUrl = "https://cdn.sipeed.com/nanokvm/resources/tailscale_riscv64.zip"
Workspace = "/root/.tailscale"
TailscalePath = "/usr/bin/tailscale"
TailscaledPath = "/usr/sbin/tailscaled"
)
func Install(c *gin.Context) {
var rsp proto.Response
if IsInstalled() {
rsp.OkRsp(c)
return
}
_ = os.MkdirAll(Workspace, 0o755)
defer func() {
_ = os.RemoveAll(Workspace)
}()
// download
req, err := http.NewRequest("GET", DownloadUrl, nil)
if err != nil {
rsp.ErrRsp(c, -1, "request failed")
log.Errorf("failed to create request: %s", err)
return
}
zipPath := fmt.Sprintf("%s/tailscale_riscv64.zip", Workspace)
err = utils.Download(req, zipPath)
if err != nil {
rsp.ErrRsp(c, -2, "download failed")
log.Errorf("failed to download tailscale: %s", err)
return
}
// install
err = utils.Unzip(zipPath, Workspace)
if err != nil {
rsp.ErrRsp(c, -3, "unzip failed")
log.Errorf("failed to unzip tailscale: %s", err)
return
}
tailscalePath := fmt.Sprintf("%s/tailscale_riscv64/tailscale", Workspace)
err = utils.MoveFile(tailscalePath, TailscalePath)
if err != nil {
rsp.ErrRsp(c, -4, "install failed")
log.Errorf("failed to move tailscale: %s", err)
return
}
tailscaledPath := fmt.Sprintf("%s/tailscale_riscv64/tailscaled", Workspace)
err = utils.MoveFile(tailscaledPath, TailscaledPath)
if err != nil {
rsp.ErrRsp(c, -5, "install failed")
log.Errorf("failed to move tailscaled: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("install tailscale successfully")
}
func Uninstall(c *gin.Context) {
var rsp proto.Response
_ = NewCli().Stop()
_ = utils.DelGoMemLimit()
_ = os.Remove(TailscalePath)
_ = os.Remove(TailscaledPath)
rsp.OkRsp(c)
log.Debugf("uninstall tailscale successfully")
}
func IsInstalled() bool {
_, err1 := os.Stat(TailscalePath)
_, err2 := os.Stat(TailscaledPath)
return err1 == nil && err2 == nil
}

View File

@@ -1,90 +0,0 @@
package tailscale
import (
"NanoKVM-Server/proto"
"net"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var StateMap = map[string]proto.TailscaleState{
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
}
func GetStatus(c *gin.Context) {
var rsp proto.Response
if !IsInstalled() {
rsp.OkRspWithData(c, &proto.GetTailscaleStatusRsp{
State: proto.TailscaleNotInstall,
})
return
}
status, err := NewCli().Status()
if err != nil {
log.Debugf("failed to get tailscale status: %s", err)
rsp.OkRspWithData(c, &proto.GetTailscaleStatusRsp{
State: proto.TailscaleNotRunning,
})
return
}
state, ok := StateMap[status.BackendState]
if !ok {
log.Errorf("unknown tailscale state: %s", status.BackendState)
rsp.ErrRsp(c, -1, "unknown state")
return
}
ipv4 := ""
for _, tailscaleIp := range status.Self.TailscaleIPs {
ip := net.ParseIP(tailscaleIp)
if ip != nil && ip.To4() != nil {
ipv4 = ip.String()
}
}
data := proto.GetTailscaleStatusRsp{
State: state,
IP: ipv4,
Name: status.Self.HostName,
Account: status.CurrentTailnet.Name,
}
rsp.OkRspWithData(c, &data)
log.Debugf("get tailscale status successfully")
}
func Up(c *gin.Context) {
var rsp proto.Response
err := NewCli().Up()
if err != nil {
rsp.ErrRsp(c, -1, "tailscale up failed")
log.Errorf("failed to run tailscale up: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("run tailscale up successfully")
}
func Down(c *gin.Context) {
var rsp proto.Response
err := NewCli().Down()
if err != nil {
rsp.ErrRsp(c, -1, "tailscale down failed")
log.Errorf("failed to run tailscale down: %s", err)
return
}
rsp.OkRsp(c)
log.Debugf("run tailscale down successfully")
}

View File

@@ -14,9 +14,9 @@ func (s *Service) ResetHdmi(c *gin.Context) {
vision := common.GetKvmVision()
vision.EnableHdmi(false)
vision.SetHDMI(false)
time.Sleep(1 * time.Second)
vision.EnableHdmi(true)
vision.SetHDMI(true)
rsp.OkRsp(c)
log.Debug("reset hdmi")

67
server/service/vm/ssh.go Normal file
View File

@@ -0,0 +1,67 @@
package vm
import (
"NanoKVM-Server/proto"
"errors"
"fmt"
"os"
"os/exec"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
SSHScript = "/etc/init.d/S50sshd"
SSHStopFlag = "/etc/kvm/ssh_stop"
)
func (s *Service) GetSSHState(c *gin.Context) {
var rsp proto.Response
enabled := isSSHEnabled()
rsp.OkRspWithData(c, &proto.GetSSHStateRsp{
Enabled: enabled,
})
}
func (s *Service) EnableSSH(c *gin.Context) {
var rsp proto.Response
command := fmt.Sprintf("%s permanent_on", SSHScript)
err := exec.Command("sh", "-c", command).Run()
if err != nil {
log.Errorf("failed to run SSH script: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
rsp.OkRsp(c)
log.Debugf("SSH enabled")
}
func (s *Service) DisableSSH(c *gin.Context) {
var rsp proto.Response
command := fmt.Sprintf("%s permanent_off", SSHScript)
err := exec.Command("sh", "-c", command).Run()
if err != nil {
log.Errorf("failed to run SSH script: %s", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
rsp.OkRsp(c)
log.Debugf("SSH disabled")
}
func isSSHEnabled() bool {
_, err := os.Stat(SSHStopFlag)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return true
}
}
return false
}

View File

@@ -2,17 +2,15 @@ package vm
import (
"encoding/json"
"io"
"net/http"
"os"
"os/exec"
"time"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"NanoKVM-Server/utils"
)
const (
@@ -20,244 +18,93 @@ const (
maxMessageSize = 1024
)
type WindowSize struct {
Height int `json:"height"`
Width int `json:"width"`
}
type SshClient struct {
conn *websocket.Conn
addr string
user string
password string
client *ssh.Client
session *ssh.Session
sessionIn io.WriteCloser
sessionOut io.Reader
closeSig chan struct{}
type WinSize struct {
Rows uint16 `json:"rows"`
Cols uint16 `json:"cols"`
}
var upgrader = websocket.Upgrader{
ReadBufferSize: maxMessageSize,
WriteBufferSize: maxMessageSize,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
var terminalModes = ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
func (s *SshClient) getWindowSize() (size *WindowSize, err error) {
_ = s.conn.SetReadDeadline(time.Now().Add(messageWait))
msgType, msg, err := s.conn.ReadMessage()
func (s *Service) Terminal(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("ws read message failed: %s", err)
log.Errorf("failed to init websocket: %s", err)
return
}
if msgType != websocket.BinaryMessage {
log.Errorf("ws message type is not binary")
return
}
size = new(WindowSize)
if err = json.Unmarshal(msg, size); err != nil {
log.Errorf("unmarshal ws message failed: %s", err)
return
}
return
}
func (s *SshClient) wsWrite() error {
defer func() {
s.closeSig <- struct{}{}
_ = ws.Close()
}()
cmd := exec.Command("/bin/sh")
ptmx, err := pty.Start(cmd)
if err != nil {
log.Errorf("failed to start pty: %s", err)
return
}
defer func() {
_ = ptmx.Close()
_ = cmd.Process.Kill()
}()
go wsWrite(ws, ptmx)
wsRead(ws, ptmx)
}
// pty to ws
func wsWrite(ws *websocket.Conn, ptmx *os.File) {
data := make([]byte, maxMessageSize)
for {
time.Sleep(10 * time.Millisecond)
n, err := s.sessionOut.Read(data)
n, err := ptmx.Read(data)
if err != nil {
return err
return
}
if n > 0 {
_ = s.conn.SetWriteDeadline(time.Now().Add(messageWait))
_ = ws.SetWriteDeadline(time.Now().Add(messageWait))
err = s.conn.WriteMessage(websocket.BinaryMessage, data[:n])
err = ws.WriteMessage(websocket.BinaryMessage, data[:n])
if err != nil {
log.Errorf("write ws message failed: %s", err)
return err
return
}
}
}
}
func (s *SshClient) wsRead() error {
defer func() {
s.closeSig <- struct{}{}
}()
// ws to pty
func wsRead(ws *websocket.Conn, ptmx *os.File) {
var zeroTime time.Time
_ = s.conn.SetReadDeadline(zeroTime)
_ = ws.SetReadDeadline(zeroTime)
for {
msgType, connReader, err := s.conn.NextReader()
msgType, p, err := ws.ReadMessage()
if err != nil {
return err
return
}
if msgType != websocket.BinaryMessage {
_, err = io.Copy(s.sessionIn, connReader)
if err != nil {
log.Errorf("copy ws message failed: %s", err)
return err
// resize message
if msgType == websocket.BinaryMessage {
var winSize WinSize
if err := json.Unmarshal(p, &winSize); err == nil {
_ = pty.Setsize(ptmx, &pty.Winsize{
Rows: winSize.Rows,
Cols: winSize.Cols,
})
}
continue
}
data := make([]byte, maxMessageSize)
n, err := connReader.Read(data)
_, err = ptmx.Write(p)
if err != nil {
log.Errorf("read ws message failed: %s", err)
return err
}
var size WindowSize
err = json.Unmarshal(data[:n], &size)
if err != nil {
log.Errorf("unmarshal ws message failed: %s", err)
return err
}
err = s.session.WindowChange(size.Height, size.Width)
if err != nil {
log.Errorf("ws window change failed: %s", err)
return err
log.Errorf("failed to write to pty: %s", err)
return
}
}
}
func (s *SshClient) bridgeWSAndSSH() {
defer func() {
_ = s.conn.Close()
if r := recover(); r != nil {
log.Debugf("terminal recover: %s", r)
}
}()
size, err := s.getWindowSize()
if err != nil {
return
}
auth := ssh.Password(s.password)
config := &ssh.ClientConfig{
User: s.user,
Auth: []ssh.AuthMethod{auth},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
s.client, err = ssh.Dial("tcp", s.addr, config)
if err != nil {
// log.Errorf("init ssh failed: %s", err)
return
}
defer func() {
_ = s.client.Close()
}()
s.session, err = s.client.NewSession()
if err != nil {
log.Errorf("open ssh session failed: %s", err)
return
}
defer s.session.Close()
s.session.Stderr = os.Stderr
s.sessionOut, err = s.session.StdoutPipe()
if err != nil {
log.Errorf("open ssh session out failed: %s", err)
return
}
s.sessionIn, err = s.session.StdinPipe()
if err != nil {
log.Errorf("open ssh session in failed: %s", err)
return
}
defer func() {
_ = s.sessionIn.Close()
}()
if err = s.session.RequestPty("xterm", size.Height, size.Width, terminalModes); err != nil {
log.Errorf("oen session request pty failed: %s", err)
return
}
if err = s.session.Shell(); err != nil {
log.Errorf("open ssh shell failed: %s", err)
return
}
go func() {
_ = s.wsRead()
}()
go func() {
_ = s.wsWrite()
}()
<-s.closeSig
}
func getRootPassword() string {
if !utils.IsAccountExist() {
return "root"
}
account, err := utils.GetAccount()
if err != nil {
return "root"
}
if account == nil || account.Password == "" {
return "root"
}
return account.Password
}
func (s *Service) Terminal(c *gin.Context) {
user := c.Query("u")
if user == "" {
user = "root"
}
password, _ := utils.Decrypt(c.Query("t"))
if password == "" {
password = getRootPassword()
}
upgrader.CheckOrigin = func(r *http.Request) bool {
return true
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("init websocket failed: %s", err)
return
}
sshClient := &SshClient{
conn: conn,
addr: "127.0.0.1:22",
user: user,
password: password,
closeSig: make(chan struct{}, 1),
}
go sshClient.bridgeWSAndSSH()
}

View File

@@ -7,30 +7,24 @@ import (
log "github.com/sirupsen/logrus"
)
const EncryptSecretKey = "nanokvm-sipeed-2024"
// SecretKey is only used to prevent the data from being transmitted in plaintext.
const SecretKey = "nanokvm-sipeed-2024"
func Decrypt(ciphertext string) (string, error) {
defer func() {
if err := recover(); err != nil {
log.Errorf("decrypt failed: %s", err)
}
}()
if ciphertext == "" {
return "", nil
}
decrypt := aes256.Decrypt(ciphertext, EncryptSecretKey)
decrypt := aes256.Decrypt(ciphertext, SecretKey)
return decrypt, nil
}
func DecodeDecrypt(ciphertext string) (string, error) {
decode, err := url.QueryUnescape(ciphertext)
func DecodeDecrypt(data string) (string, error) {
ciphertext, err := url.QueryUnescape(data)
if err != nil {
log.Errorf("decode ciphertext failed: %s", err)
return "", err
}
return Decrypt(decode)
return Decrypt(ciphertext)
}

View File

@@ -41,8 +41,8 @@ func Download(req *http.Request, target string) error {
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/octet-stream" && contentType != "application/zip" {
log.Debugf("unexpected content-type, it should be either octet-stream or zip, but got: %s", contentType)
if contentType != "application/octet-stream" && contentType != "application/zip" && contentType != "application/gzip" {
log.Debugf("unexpected content-type, it should be either octet-stream or (g)zip, but got: %s", contentType)
return errors.New("unsupported content type")
}

82
server/utils/untar.go Normal file
View File

@@ -0,0 +1,82 @@
package utils
import (
"archive/tar"
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
)
func UnTarGz(srcFile string, destDir string) (string, error) {
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", err
}
fr, err := os.Open(srcFile)
if err != nil {
return "", err
}
defer func() {
_ = fr.Close()
}()
gr, err := gzip.NewReader(fr)
if err != nil {
return "", err
}
defer func() {
_ = gr.Close()
}()
tr := tar.NewReader(gr)
targetFile := ""
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if targetFile == "" {
parts := strings.Split(header.Name, "/")
if len(parts) > 0 {
targetFile = filepath.Join(destDir, parts[0])
}
}
filename := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(filename, os.FileMode(header.Mode)); err != nil {
return "", err
}
case tar.TypeReg:
file, err := os.OpenFile(filename, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return "", err
}
if _, err := io.Copy(file, tr); err != nil {
_ = file.Close()
return "", err
}
_ = file.Close()
case tar.TypeSymlink:
if err := os.Symlink(header.Linkname, filename); err != nil {
return "", err
}
}
}
return targetFile, nil
}

View File

@@ -22,6 +22,8 @@ src
## Local Development
> Development requires SSH. You can enable it in the Web Settings: `Settings > SSH`.
Due to CORS restrictions, authentication needs to be disabled during local development.
To develop authentication features, you need to build the project and test in NanoKVM.
@@ -55,4 +57,7 @@ pnpm build
3. Upload `web` to `/kvmapp/server/` in NanoKVM.
4. Restart the service by executing `/etc/init.d/S95nanokvm restart` in NanoKVM.
Additionally, browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.
Tips:
1. File uploads requires SSH. You can enable it in the Web Settings: `Settings > SSH`.
2. Browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.

View File

@@ -22,11 +22,13 @@ src
## 本地开发
> 开发需要启用 SSH 功能。请在 Web `设置 - SSH` 中检查 SSH 是否已经启用。
由于 CORS 的限制,在本地开发时,需要关闭鉴权功能。
如果想要开发鉴权相关的功能,需要编译后在 NanoKVM 中进行测试。
1. 通过 ssh 登录到 NanoKVM`ssh root@your-nanokvm-ip`(默认密码为 root
1. 通过 SSH 登录到 NanoKVM`ssh root@your-nanokvm-ip`(默认密码为 root
2. 修改配置文件 `/etc/kvm/server.yaml`,添加一行 `authentication: disable`。⚠️注意:该选项会禁用所有鉴权功能,生产环境请勿开启该选项!
3. 执行 `/etc/init.d/S95nanokvm restart` 重启服务。
4. 编辑 `.env.development` 文件,将 `VITE_SERVER_IP` 修改为你的 NanoKVM IP 地址。
@@ -56,4 +58,7 @@ pnpm build
4. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
注意:更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。
注意:
1. 上传文件需要启用 SSH 功能。请在 Web `设置 - SSH` 中检查 SSH 是否已经启用。
2. 更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。

View File

@@ -8,6 +8,10 @@ export function login(username: string, password: string) {
return http.post('/api/auth/login', data);
}
export function logout() {
return http.post('/api/auth/logout');
}
export function getAccount() {
return http.get('/api/auth/account');
}

View File

@@ -0,0 +1,51 @@
import { http } from '@/lib/http.ts';
// install tailscale
export function install() {
return http.post('/api/extensions/tailscale/install');
}
// uninstall tailscale
export function uninstall() {
return http.post('/api/extensions/tailscale/uninstall');
}
// get tailscale status
export function getStatus() {
return http.get('/api/extensions/tailscale/status');
}
// start tailscale
export function start() {
return http.post('/api/extensions/tailscale/start');
}
// restart tailscale
export function restart() {
return http.post('/api/extensions/tailscale/restart');
}
// stop tailscale
export function stop() {
return http.post('/api/extensions/tailscale/stop');
}
// run tailscale up
export function up() {
return http.post('/api/extensions/tailscale/up');
}
// run tailscale down
export function down() {
return http.post('/api/extensions/tailscale/down');
}
// login tailscale
export function login() {
return http.post('/api/extensions/tailscale/login');
}
// logout tailscale
export function logout() {
return http.post('/api/extensions/tailscale/logout');
}

View File

@@ -21,51 +21,6 @@ export function deleteWolMac(mac: string) {
});
}
// install tailscale
export function installTailscale() {
return http.post('/api/network/tailscale/install');
}
// uninstall tailscale
export function uninstallTailscale() {
return http.post('/api/network/tailscale/uninstall');
}
// get tailscale status
export function getTailscaleStatus() {
return http.get('/api/network/tailscale/status');
}
// run tailscale up
export function upTailscale() {
return http.post('/api/network/tailscale/up');
}
// run tailscale down
export function downTailscale() {
return http.post('/api/network/tailscale/down');
}
// login tailscale
export function loginTailscale() {
return http.post('/api/network/tailscale/login');
}
// logout tailscale
export function logoutTailscale() {
return http.post('/api/network/tailscale/logout');
}
// stop tailscale
export function stopTailscale() {
return http.post('/api/network/tailscale/stop');
}
// restart tailscale
export function restartTailscale() {
return http.post('/api/network/tailscale/restart');
}
// get wifi information
export function getWiFi() {
return http.get('/api/network/wifi');

View File

@@ -61,3 +61,18 @@ export function setOLED(sleep: number) {
export function resetHdmi() {
return http.post('/api/vm/hdmi/reset');
}
// get SSH state
export function getSSHState() {
return http.get('/api/vm/ssh');
}
// enable SSH
export function enableSSH() {
return http.post('/api/vm/ssh/enable');
}
// disable SSH
export function disableSSH() {
return http.post('/api/vm/ssh/disable');
}

View File

@@ -137,6 +137,12 @@ const en = {
input: 'Please enter the MAC',
ok: 'Ok'
},
download: {
title: 'Download Image',
input: 'Please enter a remote image URL',
ok: 'Ok',
disabled: '/data partition is RO, so we cannot download the image'
},
power: {
title: 'Power',
reset: 'Reset',
@@ -144,12 +150,6 @@ const en = {
powerShort: 'Power (short click)',
powerLong: 'Power (long click)'
},
download: {
download: 'Download Image',
input: 'Please enter a remote image URL',
ok: 'Ok',
disabled: '/data partition is RO, so we cannot download the image',
},
settings: {
title: 'Settings',
about: {
@@ -191,6 +191,10 @@ const en = {
description: 'Configure Wi-Fi',
setBtn: 'Config'
},
ssh: {
description: 'Enable SSH remote access',
tip: 'Set a strong password before enabling (Account - Change Password)'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
@@ -205,7 +209,7 @@ const en = {
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
stopDesc: 'Log out Tailscale and disable automatic startup on boot.',
loading: 'Loading...',
notInstall: 'Tailscale not found! Please install.',
install: 'Install',
@@ -229,6 +233,7 @@ const en = {
account: 'Account',
logout: 'Logout',
logout2: 'Sure to logout?',
uninstall: 'Uninstall Tailscale',
okBtn: 'Yes',
cancelBtn: 'No'
},
@@ -246,7 +251,7 @@ const en = {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
updateBtn: 'Change',
logoutBtn: 'Logout'
}
}

View File

@@ -134,6 +134,12 @@ const zh = {
input: '请输入MAC地址',
ok: '确定'
},
download: {
title: '下载镜像',
input: '请输入镜像的下载地址',
ok: '确定',
disabled: '/data 是只读分区,无法下载镜像'
},
power: {
title: '电源',
reset: '重启',
@@ -182,6 +188,10 @@ const zh = {
description: '配置 Wi-Fi 信息',
setBtn: '设置'
},
ssh: {
description: '启用 SSH 远程访问',
tip: '启用前请务必设置强密码(帐号 - 修改密码)'
},
disk: '虚拟U盘',
diskDesc: '在远程主机中挂载虚拟U盘',
network: '虚拟网卡',
@@ -219,6 +229,7 @@ const zh = {
account: '账号',
logout: '退出',
logout2: '确认退出?',
uninstall: '卸载 Tailscale',
okBtn: '确认',
cancelBtn: '取消'
},

View File

@@ -1,14 +1,9 @@
import CryptoJS from 'crypto-js';
// This key is only used to prevent the data from being transmitted in plaintext.
const SECRET_KEY = 'nanokvm-sipeed-2024';
export function encrypt(data: string) {
const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
return encodeURIComponent(dataEncrypt);
}
export function decrypt(data: string) {
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY);
const dataDecrypt = bytes.toString(CryptoJS.enc.Utf8);
return decodeURIComponent(dataDecrypt);
}

View File

@@ -36,14 +36,7 @@ export const Login = () => {
.login(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
if (rsp.code === -3) {
setMsg(t('auth.noAccount'));
} else if (rsp.code === -4) {
setMsg(t('auth.invalidUser'));
} else {
setMsg(t('auth.error'));
}
setMsg(rsp.code === -2 ? t('auth.invalidUser') : t('auth.error'));
return;
}
@@ -63,13 +56,12 @@ export const Login = () => {
<Head title={t('head.login')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={login}
>
<h2 className="text-xl font-semibold text-neutral-100 text-center">{t('auth.login')}</h2>
<h2 className="text-center text-xl font-semibold text-neutral-100">{t('auth.login')}</h2>
<Form.Item
name="username"

View File

@@ -6,7 +6,7 @@ import { useSetAtom } from 'jotai';
import { DownloadIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { downloadImage, statusImage, imageEnabled } from '@/api/download.ts';
import { downloadImage, imageEnabled, statusImage } from '@/api/download.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const DownloadImage = () => {
@@ -21,12 +21,10 @@ export const DownloadImage = () => {
const [diskEnabled, setDiskEnabled] = useState(false);
const [popoverKey, setPopoverKey] = useState(0);
const inputRef = useRef<InputRef>(null);
const intervalId = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
checkDiskEnabled();
}, []);
@@ -34,7 +32,6 @@ export const DownloadImage = () => {
function checkDiskEnabled() {
imageEnabled()
.then((res) => {
console.log(res.data.enabled);
setDiskEnabled(res.data.enabled);
})
.catch(() => {
@@ -48,9 +45,9 @@ export const DownloadImage = () => {
getDownloadStatus();
if (!intervalId.current) {
intervalId.current = setInterval(getDownloadStatus, 2500);
};
}
setIsKeyboardEnable(false);
setPopoverKey(prevKey => prevKey + 1); // Force re-render
setPopoverKey((prevKey) => prevKey + 1); // Force re-render
} else {
setInput('');
setStatus('');
@@ -75,45 +72,50 @@ export const DownloadImage = () => {
if (rsp.data.status === 'in_progress') {
// Check if rsp has a percentage value
if (rsp.data.percentage) {
setLog('Downloading ('+ rsp.data.percentage + ')' + ': ' + rsp.data.file);
setLog('Downloading (' + rsp.data.percentage + ')' + ': ' + rsp.data.file);
} else {
setLog('Downloading' + ': ' + rsp.data.file);
}
setInput(rsp.data.file);
};
}
if (rsp.data.status === 'failed') {
setLog('Failed');
clearInterval(intervalId.current);
};
}
if (rsp.data.status === 'idle') {
setLog(''); // Clear the log
clearInterval(intervalId.current);
};
};
setLog(''); // Clear the log
clearInterval(intervalId.current);
}
}
});
};
}
function download(url?: string) {
if (!url) return;
setStatus('in_progress');
setLog('Downloading: ' + url);
// start the getDownloadStatus to tick every 5 seconds
downloadImage(url).then(() => {
getDownloadStatus();
// Start the interval to check the download status
if (!intervalId.current) {
intervalId.current = setInterval(getDownloadStatus, 2500);}
}).catch(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
setStatus('failed');
setLog('Failed');
});
downloadImage(url)
.then(() => {
getDownloadStatus();
// Start the interval to check the download status
if (!intervalId.current) {
intervalId.current = setInterval(getDownloadStatus, 2500);
}
})
.catch(() => {
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
setStatus('failed');
setLog('Failed');
});
}
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('download.download')}</span>
<span className="text-base font-bold text-neutral-300">{t('download.title')}</span>
</div>
<Divider style={{ margin: '10px 0 10px 0' }} />
@@ -124,8 +126,17 @@ export const DownloadImage = () => {
<>
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} disabled={status === 'in_progress'} />
<Button type="primary" onClick={() => download(input)} disabled={status === 'in_progress'}>
<Input
ref={inputRef}
value={input}
onChange={handleChange}
disabled={status === 'in_progress'}
/>
<Button
type="primary"
onClick={() => download(input)}
disabled={status === 'in_progress'}
>
{t('download.ok')}
</Button>
</div>
@@ -152,10 +163,11 @@ export const DownloadImage = () => {
content={content}
placement="bottomLeft"
trigger="click"
arrow={false}
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<div className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700 hover:text-white">
<DownloadIcon size={18} />
</div>
</Popover>

View File

@@ -7,6 +7,7 @@ import { MenuIcon, XIcon } from 'lucide-react';
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
import { DownloadImage } from './download.tsx';
import { Fullscreen } from './fullscreen';
import { Image } from './image';
import { Keyboard } from './keyboard';
@@ -16,7 +17,6 @@ import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Terminal } from './terminal';
import { DownloadImage } from './download.tsx';
import { Wol } from './wol';
export const Menu = () => {
@@ -48,12 +48,12 @@ export const Menu = () => {
<Divider type="vertical" />
{!menuDisabledItems.includes('image') && <Image />}
{!menuDisabledItems.includes('download') && <DownloadImage />}
{!menuDisabledItems.includes('script') && <Script />}
{!menuDisabledItems.includes('terminal') && <Terminal />}
{!menuDisabledItems.includes('wol') && <Wol />}
<DownloadImage />
{['image', 'script', 'terminal', 'wol'].some(
{['image', 'script', 'terminal', 'wol', 'download'].some(
(key) => !menuDisabledItems.includes(key)
) && <Divider type="vertical" />}

View File

@@ -25,9 +25,16 @@ export const Account = () => {
navigate('/auth/password');
}
function logout() {
removeToken();
navigate('/auth/login');
async function logout() {
api.logout().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
removeToken();
navigate('/auth/login');
});
}
return (

View File

@@ -1,6 +1,13 @@
import { Switch } from 'antd';
import { useAtom } from 'jotai';
import { DiscIcon, FileJsonIcon, NetworkIcon, PowerIcon, TerminalSquareIcon } from 'lucide-react';
import {
DiscIcon,
DownloadIcon,
FileJsonIcon,
NetworkIcon,
PowerIcon,
TerminalSquareIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage.ts';
@@ -13,6 +20,7 @@ export const MenuBar = () => {
const items = [
{ key: 'image', icon: <DiscIcon size={16} /> },
{ key: 'download', icon: <DownloadIcon size={16} /> },
{ key: 'script', icon: <FileJsonIcon size={15} /> },
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
{ key: 'wol', icon: <NetworkIcon size={16} /> },

View File

@@ -2,6 +2,7 @@ import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { Oled } from './oled.tsx';
import { Ssh } from './ssh.tsx';
import { VirtualDevices } from './virtual-devices.tsx';
import { Wifi } from './wifi.tsx';
@@ -16,6 +17,7 @@ export const Device = () => {
<div className="flex flex-col space-y-6">
<Oled />
<Wifi />
<Ssh />
<VirtualDevices />
</div>
</>

View File

@@ -0,0 +1,64 @@
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 Ssh = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
api
.getSSHState()
.then((rsp) => {
if (rsp.data?.enabled) {
setIsEnabled(true);
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
async function update() {
if (isLoading) return;
setIsLoading(true);
const rsp = isEnabled ? await api.disableSSH() : await api.enableSSH();
setIsLoading(false);
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsEnabled(!isEnabled);
}
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span>SSH</span>
<div className="flex items-center space-x-1">
<span className="text-xs text-neutral-500">{t('settings.device.ssh.description')}</span>
<Tooltip
title={t('settings.device.ssh.tip')}
className="cursor-pointer text-neutral-500"
placement="bottom"
>
<CircleAlertIcon size={16} />
</Tooltip>
</div>
</div>
<Switch checked={isEnabled} loading={isLoading} onChange={update} />
</div>
);
};

View File

@@ -3,7 +3,7 @@ import { LogoutOutlined } from '@ant-design/icons';
import { Button, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
import * as api from '@/api/extensions/tailscale.ts';
import { Status } from './types.ts';
@@ -30,7 +30,7 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
setIsUpdating(true);
try {
const rsp = isRunning ? await api.downTailscale() : await api.upTailscale();
const rsp = isRunning ? await api.down() : await api.up();
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
return;
@@ -47,7 +47,7 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
setIsLogging(true);
api
.logoutTailscale()
.logout()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);

View File

@@ -1,46 +1,42 @@
import { useEffect, useState } from 'react';
import { Popconfirm } from 'antd';
import { CircleStopIcon, LoaderIcon, RotateCwIcon } from 'lucide-react';
import { useState } from 'react';
import { Popconfirm, Popover } from 'antd';
import { CircleStopIcon, EllipsisIcon, LoaderIcon, RotateCwIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
import * as api from '@/api/extensions/tailscale.ts';
import { Status } from './types.ts';
import { Memory } from './memory.tsx';
import type { State } from './types.ts';
import { Uninstall } from './uninstall.tsx';
type HeaderProps = {
status: Status | undefined;
state: State | undefined;
onSuccess: () => void;
};
export const Header = ({ status, onSuccess }: HeaderProps) => {
type Loading = '' | 'restarting' | 'stopping';
export const Header = ({ state, onSuccess }: HeaderProps) => {
const { t } = useTranslation();
const [isRunning, setIsRunning] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
useEffect(() => {
const running =
status !== undefined && ['notLogin', 'stopped', 'running'].includes(status.state);
setIsRunning(running);
}, [status]);
const [loading, setLoading] = useState<Loading>('');
function restart() {
if (isRestarting || isStopping) return;
setIsRestarting(true);
if (loading !== '') return;
setLoading('restarting');
api.restartTailscale().finally(() => {
setIsRestarting(false);
api.restart().finally(() => {
setLoading('');
onSuccess();
});
}
function stop() {
if (isRestarting || isStopping) return;
setIsStopping(true);
if (loading !== '') return;
setLoading('stopping');
api.stopTailscale().finally(() => {
setIsStopping(false);
api.stop().finally(() => {
setLoading('');
onSuccess();
});
}
@@ -48,44 +44,67 @@ export const Header = ({ status, onSuccess }: HeaderProps) => {
return (
<div className="flex items-center justify-between">
<span className="text-base font-bold">{t('settings.tailscale.title')}</span>
{isRunning && (
<div className="flex items-center space-x-3">
<Popconfirm
title={t('settings.tailscale.restart')}
onConfirm={restart}
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
placement="bottom"
disabled={isRestarting || isStopping}
>
<div className="cursor-pointer text-green-500 hover:text-green-500/80">
{isRestarting ? (
<LoaderIcon className="animate-spin" size={20} />
) : (
<RotateCwIcon size={20} />
)}
</div>
</Popconfirm>
<Popconfirm
title={t('settings.tailscale.stop')}
description={t('settings.tailscale.stopDesc')}
onConfirm={stop}
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
<div className="flex items-center space-x-2">
{state && ['notLogin', 'stopped', 'running'].includes(state) && (
<>
{/* restart button */}
<Popconfirm
title={t('settings.tailscale.restart')}
onConfirm={restart}
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
placement="bottom"
disabled={loading !== ''}
>
<div className="flex cursor-pointer rounded p-1 text-green-500 hover:bg-neutral-600 hover:text-green-500/80">
{loading === 'restarting' ? (
<LoaderIcon className="animate-spin" size={18} />
) : (
<RotateCwIcon size={18} />
)}
</div>
</Popconfirm>
{/* stop button */}
<Popconfirm
title={t('settings.tailscale.stop')}
description={t('settings.tailscale.stopDesc')}
onConfirm={stop}
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
placement="bottom"
disabled={loading !== ''}
>
<div className="flex cursor-pointer rounded p-1 text-red-500 hover:bg-neutral-600 hover:text-red-500/80">
{loading === 'stopping' ? (
<LoaderIcon className="animate-spin" size={18} />
) : (
<CircleStopIcon size={18} />
)}
</div>
</Popconfirm>
</>
)}
{/* more button */}
{state && state !== 'notInstall' && (
<Popover
content={
<div className="flex min-w-[250px] flex-col">
<Memory />
<Uninstall onSuccess={onSuccess} />
</div>
}
placement="bottom"
disabled={isRestarting || isStopping}
trigger="click"
>
<div className="cursor-pointer text-red-500 hover:text-red-500/80">
{isStopping ? (
<LoaderIcon className="animate-spin" size={20} />
) : (
<CircleStopIcon size={20} />
)}
<div className="flex cursor-pointer rounded p-1 text-white hover:bg-neutral-600">
<EllipsisIcon size={18} />
</div>
</Popconfirm>
</div>
)}
</Popover>
)}
</div>
</div>
);
};

View File

@@ -3,14 +3,13 @@ import { Divider } from 'antd';
import { LoaderCircleIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
import * as api from '@/api/extensions/tailscale.ts';
import { Device } from './device.tsx';
import { Header } from './header.tsx';
import { Install } from './install.tsx';
import { Login } from './login.tsx';
import { Memory } from './memory.tsx';
import { Status } from './types.ts';
import type { Status } from './types.ts';
type TailscaleProps = {
setIsLocked: (isLocked: boolean) => void;
@@ -32,7 +31,7 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
setIsLoading(true);
api
.getTailscaleStatus()
.getStatus()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);
@@ -48,10 +47,7 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
return (
<>
<Header status={status} onSuccess={getStatus} />
<Divider />
<Memory />
<Header state={status?.state} onSuccess={getStatus} />
<Divider />
{isLoading ? (

View File

@@ -1,9 +1,9 @@
import { useState } from 'react';
import { InboxOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { DownloadOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Button, Card, Result } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
import * as api from '@/api/extensions/tailscale.ts';
type InstallProps = {
setIsLocked: (setIsLocked: boolean) => void;
@@ -21,7 +21,7 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
setIsLocked(true);
api
.installTailscale()
.install()
.then((rsp) => {
if (rsp.code !== 0) {
setState('failed');
@@ -40,7 +40,7 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
<>
{state !== 'failed' ? (
<Result
icon={<InboxOutlined />}
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button type="primary" size="large" loading={state === 'installing'} onClick={install}>

View File

@@ -3,7 +3,7 @@ import { UserSwitchOutlined } from '@ant-design/icons';
import { Button, Card } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network.ts';
import * as api from '@/api/extensions/tailscale.ts';
type LoginProps = {
onSuccess: () => void;
@@ -21,7 +21,7 @@ export const Login = ({ onSuccess }: LoginProps) => {
setIsLoading(true);
api
.loginTailscale()
.login()
.then((rsp) => {
if (rsp.code !== 0) {
setErrMsg(rsp.msg);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Select, Tooltip } from 'antd';
import { Switch, Tooltip } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -9,14 +9,7 @@ export const Memory = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [limit, setLimit] = useState('');
const options = [
{ value: '0', label: t('settings.tailscale.memory.disable') },
{ value: '40', label: '40 MB' },
{ value: '50', label: '50 MB' },
{ value: '60', label: '60 MB' }
];
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
api.getMemoryLimit().then((rsp) => {
@@ -25,27 +18,26 @@ export const Memory = () => {
return;
}
const value = rsp.data.enabled ? rsp.data.limit.toString() : '0';
setLimit(value);
setIsEnabled(!!rsp.data.enabled);
});
}, []);
function update(value: string) {
function update() {
if (isLoading) return;
setIsLoading(true);
const enabled = value !== '0';
const limitNum = parseInt(value);
const enabled = !isEnabled;
const limit = isEnabled ? 0 : 50;
api
.setMemoryLimit(enabled, limitNum)
.setMemoryLimit(enabled, limit)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setLimit(value);
setIsEnabled(enabled);
})
.finally(() => {
setIsLoading(false);
@@ -54,22 +46,20 @@ export const Memory = () => {
return (
<>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '350px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<div className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70">
<div className="flex items-center space-x-1">
<span>{t('settings.tailscale.memory.title')}</span>
<Tooltip
title={t('settings.tailscale.memory.tip')}
className="cursor-pointer text-neutral-500"
placement="top"
overlayStyle={{ maxWidth: '350px' }}
>
<CircleHelpIcon size={15} />
</Tooltip>
</div>
<Select style={{ width: 100 }} value={limit} options={options} onChange={update} />
<Switch value={isEnabled} size="small" onClick={update} />
</div>
</>
);

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Button } from 'antd';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
type UninstallProps = {
onSuccess: () => void;
};
export const Uninstall = ({ onSuccess }: UninstallProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [isConfirmation, setIsConfirmation] = useState(false);
function showConfirmation() {
if (!isConfirmation) {
setIsConfirmation(true);
}
}
function uninstall() {
if (isLoading) return;
setIsLoading(true);
api.uninstall().finally(() => {
setIsLoading(false);
onSuccess();
});
}
return (
<div
className="flex h-[40px] cursor-pointer items-center justify-between space-x-6 rounded px-3 text-neutral-300 hover:bg-neutral-700/70"
onClick={showConfirmation}
>
<span className={clsx(isConfirmation && 'text-red-500')}>
{t('settings.tailscale.uninstall')}
</span>
{isConfirmation && (
<div className="flex items-center space-x-2">
<Button type="primary" size="small" disabled={isLoading} danger onClick={uninstall}>
{t('settings.tailscale.okBtn')}
</Button>
<Button type="primary" size="small" onClick={() => setIsConfirmation(false)}>
{t('settings.tailscale.cancelBtn')}
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,20 +1,82 @@
import { useState } from 'react';
import { useEffect } from 'react';
import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal as XtermTerminal } from '@xterm/xterm';
import { useTranslation } from 'react-i18next';
import { Head } from '@/components/head.tsx';
import '@xterm/xterm/css/xterm.css';
import { Login } from './login.tsx';
import { Xterm } from './xterm.tsx';
import { getBaseUrl } from '@/lib/service.ts';
import { Head } from '@/components/head.tsx';
export const Terminal = () => {
const { t } = useTranslation();
const [token, setToken] = useState('u=root');
useEffect(() => {
const terminalEle = document.getElementById('terminal');
if (!terminalEle) return;
const terminal = new XtermTerminal({
cursorBlink: true
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalEle);
fitAddon.fit();
const url = `${getBaseUrl('ws')}/api/vm/terminal`;
const ws = new WebSocket(url);
ws.onopen = () => {
const attachAddon = new AttachAddon(ws);
terminal.loadAddon(attachAddon);
sendSize();
setTimeout(runPicocom, 300);
};
const sendSize = () => {
const windowSize = { rows: terminal.rows, cols: terminal.cols };
const blob = new Blob([JSON.stringify(windowSize)], { type: 'application/json' });
ws.send(blob);
};
const runPicocom = () => {
const urls = window.location.href.split('?');
if (urls.length < 2) return;
const searchParams = new URLSearchParams(urls[1]);
const port = searchParams.get('port');
const baud = searchParams.get('baud');
if (!port || !baud) return;
ws.send(`picocom ${port} -b ${baud}\r`);
};
const resizeScreen = () => {
fitAddon.fit();
sendSize();
};
window.addEventListener('resize', resizeScreen, false);
return () => {
terminal.dispose();
if (ws.readyState === 1) {
ws.close();
}
window.removeEventListener('resize', resizeScreen, false);
};
}, []);
return (
<>
<Head title={t('head.terminal')} />
{token === '' ? <Login setToken={setToken} /> : <Xterm token={token} setToken={setToken} />}
<div className="h-full w-full overflow-hidden">
<div id="terminal" className="h-full p-2"></div>
</div>
</>
);
};

View File

@@ -1,61 +0,0 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
type LoginProps = {
setToken: (token: string) => void;
};
export const Login = ({ setToken }: LoginProps) => {
const { t } = useTranslation();
function login(values: any) {
const username = values.username;
const password = encrypt(values.password);
setToken(`u=${username}&t=${password}`);
}
return (
<>
<Head title={t('head.login')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-neutral-100">SSH to NanoKVM</h2>
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ username: 'root' }}
onFinish={login}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full">
{t('auth.ok')}
</Button>
</Form.Item>
</Form>
</div>
</>
);
};

View File

@@ -1,80 +0,0 @@
import { useEffect } from 'react';
import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal as XtermTerminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { getBaseUrl } from '@/lib/service.ts';
type TerminalProps = {
token: string;
setToken: (token: string) => void;
};
export const Xterm = ({ token, setToken }: TerminalProps) => {
useEffect(() => {
const terminalEle = document.getElementById('terminal');
if (!terminalEle) return;
const terminal = new XtermTerminal();
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalEle);
fitAddon.fit();
const url = `${getBaseUrl('ws')}/api/vm/terminal?${token}`;
const webSocket = new WebSocket(url);
const sendSize = () => {
const windowSize = { high: terminal.rows, width: terminal.cols };
const blob = new Blob([JSON.stringify(windowSize)], { type: 'application/json' });
webSocket.send(blob);
};
const resizeScreen = () => {
fitAddon.fit();
sendSize();
};
window.addEventListener('resize', resizeScreen, false);
const attachAddon = new AttachAddon(webSocket);
terminal.loadAddon(attachAddon);
webSocket.onopen = () => {
sendSize();
setTimeout(runPicocom, 300);
};
webSocket.onerror = () => {
setToken('');
};
webSocket.onclose = () => {
setToken('');
};
const runPicocom = () => {
const urls = window.location.href.split('?');
if (urls.length < 2) return;
const searchParams = new URLSearchParams(urls[1]);
const port = searchParams.get('port');
const baud = searchParams.get('baud');
if (!port || !baud) return;
webSocket.send(`picocom ${port} -b ${baud}\r`);
};
return () => {
window.removeEventListener('resize', resizeScreen, false);
if (webSocket.readyState === 1) {
webSocket.close();
}
};
}, []);
return (
<div className="h-full w-full overflow-hidden">
<div id="terminal" className="h-full p-2"></div>
</div>
);
};