diff --git a/server/README.md b/server/README.md index 831bfdb..0dc544e 100644 --- a/server/README.md +++ b/server/README.md @@ -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`. \ No newline at end of file + 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. diff --git a/server/README_ZH.md b/server/README_ZH.md index 1050fa2..acfe6dd 100644 --- a/server/README_ZH.md +++ b/server/README_ZH.md @@ -42,9 +42,13 @@ logger: # 注意:生产环境中请勿使用 disable。 authentication: enable -# JWT 密钥 -# 如果不设置,则每次服务启动时使用随机生成的密钥。 -secretKey: "" +jwt: + # jwt 密钥。设置为空则使用随机生成的64位密钥 + secretKey: "" + # jwt token 过期时间(单位:秒),默认为2678400(31天) + 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` 重启服务。 \ No newline at end of file diff --git a/server/common/cgo.go b/server/common/cgo.go index f618eee..6289d30 100644 --- a/server/common/cgo.go +++ b/server/common/cgo.go @@ -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) diff --git a/server/config/config.go b/server/config/config.go index f1367a8..ab982b7 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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) -} diff --git a/server/config/default.go b/server/config/default.go index 9ec3075..d58ebe5 100644 --- a/server/config/default.go +++ b/server/config/default.go @@ -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() +} diff --git a/server/config/jwt.go b/server/config/jwt.go new file mode 100644 index 0000000..9edd85b --- /dev/null +++ b/server/config/jwt.go @@ -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) +} diff --git a/server/config/types.go b/server/config/types.go index 3ad492e..2f8f069 100644 --- a/server/config/types.go +++ b/server/config/types.go @@ -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"` diff --git a/server/dl_lib/libkvm.so b/server/dl_lib/libkvm.so index 68045f9..08184a0 100644 Binary files a/server/dl_lib/libkvm.so and b/server/dl_lib/libkvm.so differ diff --git a/server/dl_lib/libkvm_mmf.so b/server/dl_lib/libkvm_mmf.so new file mode 100644 index 0000000..0a4d93d Binary files /dev/null and b/server/dl_lib/libkvm_mmf.so differ diff --git a/server/dl_lib/libmaixcam_lib.so b/server/dl_lib/libmaixcam_lib.so deleted file mode 100644 index fa47a05..0000000 Binary files a/server/dl_lib/libmaixcam_lib.so and /dev/null differ diff --git a/server/go.mod b/server/go.mod index 7637c82..b123016 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 9f44074..271cc14 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/include/kvm_vision.h b/server/include/kvm_vision.h index 2fa2a25..f4b013c 100644 --- a/server/include/kvm_vision.h +++ b/server/include/kvm_vision.h @@ -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 diff --git a/server/main.go b/server/main.go index 4dbdee7..8db3e77 100644 --- a/server/main.go +++ b/server/main.go @@ -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) - }() -} diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go index 61d7616..8602881 100644 --- a/server/middleware/jwt.go +++ b/server/middleware/jwt.go @@ -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) diff --git a/server/proto/download.go b/server/proto/download.go index fa122ae..461fb9f 100644 --- a/server/proto/download.go +++ b/server/proto/download.go @@ -1,7 +1,7 @@ package proto type ImageEnabledRsp struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` } type StatusImageRsp struct { diff --git a/server/proto/vm.go b/server/proto/vm.go index 75c7a9c..7529a8e 100644 --- a/server/proto/vm.go +++ b/server/proto/vm.go @@ -79,3 +79,7 @@ type GetOLEDRsp struct { Exist bool `json:"exist"` Sleep int `json:"sleep"` } + +type GetSSHStateRsp struct { + Enabled bool `json:"enabled"` +} diff --git a/server/router/auth.go b/server/router/auth.go index 5115a92..aa237fa 100644 --- a/server/router/auth.go +++ b/server/router/auth.go @@ -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 } diff --git a/server/router/extensions.go b/server/router/extensions.go new file mode 100644 index 0000000..696a94d --- /dev/null +++ b/server/router/extensions.go @@ -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 +} diff --git a/server/router/network.go b/server/router/network.go index 906c2bb..08e4a6f 100644 --- a/server/router/network.go +++ b/server/router/network.go @@ -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 } diff --git a/server/router/router.go b/server/router/router.go index 2c0e918..8b6148a 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -38,4 +38,5 @@ func server(r *gin.Engine) { hidRouter(r) wsRouter(r) downloadRouter(r) + extensionsRouter(r) } diff --git a/server/router/vm.go b/server/router/vm.go index d8bcb94..1dced36 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -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 } diff --git a/server/service/application/application.go b/server/service/application/application.go deleted file mode 100644 index 445dd20..0000000 --- a/server/service/application/application.go +++ /dev/null @@ -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 -} diff --git a/server/service/application/service.go b/server/service/application/service.go index 62ba4f7..be29bd1 100644 --- a/server/service/application/service.go +++ b/server/service/application/service.go @@ -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{} diff --git a/server/service/application/update.go b/server/service/application/update.go new file mode 100644 index 0000000..6b53fc3 --- /dev/null +++ b/server/service/application/update.go @@ -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 +} diff --git a/server/utils/account.go b/server/service/auth/account.go similarity index 60% rename from server/utils/account.go rename to server/service/auth/account.go index 80d84f3..7a0ec7b 100644 --- a/server/utils/account.go +++ b/server/service/auth/account.go @@ -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), + } +} diff --git a/server/service/auth/login.go b/server/service/auth/login.go index 7814359..8e9ba28 100644 --- a/server/service/auth/login.go +++ b/server/service/auth/login.go @@ -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 -} diff --git a/server/service/auth/password.go b/server/service/auth/password.go index 98ff20b..20efd14 100644 --- a/server/service/auth/password.go +++ b/server/service/auth/password.go @@ -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 { diff --git a/server/service/network/tailscale/cli.go b/server/service/extensions/tailscale/cli.go similarity index 95% rename from server/service/network/tailscale/cli.go rename to server/service/extensions/tailscale/cli.go index 2b68e08..1fa4cc9 100644 --- a/server/service/network/tailscale/cli.go +++ b/server/service/extensions/tailscale/cli.go @@ -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() diff --git a/server/service/extensions/tailscale/install.go b/server/service/extensions/tailscale/install.go new file mode 100644 index 0000000..6ccebff --- /dev/null +++ b/server/service/extensions/tailscale/install.go @@ -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 +} diff --git a/server/service/extensions/tailscale/service.go b/server/service/extensions/tailscale/service.go new file mode 100644 index 0000000..2881666 --- /dev/null +++ b/server/service/extensions/tailscale/service.go @@ -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") +} diff --git a/server/service/network/service.go b/server/service/network/service.go index 1003f3e..6b02b6b 100644 --- a/server/service/network/service.go +++ b/server/service/network/service.go @@ -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) -} diff --git a/server/service/network/tailscale/account.go b/server/service/network/tailscale/account.go deleted file mode 100644 index 4a12f5c..0000000 --- a/server/service/network/tailscale/account.go +++ /dev/null @@ -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") -} diff --git a/server/service/network/tailscale/install.go b/server/service/network/tailscale/install.go deleted file mode 100644 index 4174b7b..0000000 --- a/server/service/network/tailscale/install.go +++ /dev/null @@ -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 -} diff --git a/server/service/network/tailscale/status.go b/server/service/network/tailscale/status.go deleted file mode 100644 index 7141042..0000000 --- a/server/service/network/tailscale/status.go +++ /dev/null @@ -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") -} diff --git a/server/service/vm/hdmi.go b/server/service/vm/hdmi.go index 4b14db3..40a8e2c 100644 --- a/server/service/vm/hdmi.go +++ b/server/service/vm/hdmi.go @@ -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") diff --git a/server/service/vm/ssh.go b/server/service/vm/ssh.go new file mode 100644 index 0000000..fb854d4 --- /dev/null +++ b/server/service/vm/ssh.go @@ -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 +} diff --git a/server/service/vm/terminal.go b/server/service/vm/terminal.go index 14775ed..0b6f08e 100644 --- a/server/service/vm/terminal.go +++ b/server/service/vm/terminal.go @@ -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() -} diff --git a/server/utils/encrypt.go b/server/utils/encrypt.go index 9b6af58..ad94d97 100644 --- a/server/utils/encrypt.go +++ b/server/utils/encrypt.go @@ -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) } diff --git a/server/utils/http.go b/server/utils/http.go index cb45891..8f77fc7 100644 --- a/server/utils/http.go +++ b/server/utils/http.go @@ -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") } diff --git a/server/utils/untar.go b/server/utils/untar.go new file mode 100644 index 0000000..26342c6 --- /dev/null +++ b/server/utils/untar.go @@ -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 +} diff --git a/web/README.md b/web/README.md index e2dab9f..4c385df 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/README_ZH.md b/web/README_ZH.md index f4c68c5..331ef60 100644 --- a/web/README_ZH.md +++ b/web/README_ZH.md @@ -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 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。 diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 0741cb6..3657ce4 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -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'); } diff --git a/web/src/api/extensions/tailscale.ts b/web/src/api/extensions/tailscale.ts new file mode 100644 index 0000000..556a0b9 --- /dev/null +++ b/web/src/api/extensions/tailscale.ts @@ -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'); +} diff --git a/web/src/api/network.ts b/web/src/api/network.ts index babfa5a..6c60474 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -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'); diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index 7e2f6d1..61b9060 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -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'); +} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index ce98f97..a0780d0 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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' } } diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 55b7e68..53377b9 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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: '取消' }, diff --git a/web/src/lib/encrypt.ts b/web/src/lib/encrypt.ts index 7b62f8c..912473e 100644 --- a/web/src/lib/encrypt.ts +++ b/web/src/lib/encrypt.ts @@ -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); -} diff --git a/web/src/pages/auth/login/index.tsx b/web/src/pages/auth/login/index.tsx index d27d561..91741f0 100644 --- a/web/src/pages/auth/login/index.tsx +++ b/web/src/pages/auth/login/index.tsx @@ -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 = () => {
-
-

{t('auth.login')}

+

{t('auth.login')}

{ @@ -21,12 +21,10 @@ export const DownloadImage = () => { const [diskEnabled, setDiskEnabled] = useState(false); const [popoverKey, setPopoverKey] = useState(0); - const inputRef = useRef(null); const intervalId = useRef(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 = (
- {t('download.download')} + {t('download.title')}
@@ -124,8 +126,17 @@ export const DownloadImage = () => { <>
{t('download.input')}
- -
@@ -152,10 +163,11 @@ export const DownloadImage = () => { content={content} placement="bottomLeft" trigger="click" + arrow={false} open={isPopoverOpen} onOpenChange={handleOpenChange} > -
+
diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index e681af3..fd8830f 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -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 = () => { {!menuDisabledItems.includes('image') && } + {!menuDisabledItems.includes('download') && } {!menuDisabledItems.includes('script') &&