mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
@@ -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.
|
||||
|
||||
@@ -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` 重启服务。
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
28
server/config/jwt.go
Normal 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)
|
||||
}
|
||||
@@ -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
BIN
server/dl_lib/libkvm_mmf.so
Normal file
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package proto
|
||||
|
||||
type ImageEnabledRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type StatusImageRsp struct {
|
||||
|
||||
@@ -79,3 +79,7 @@ type GetOLEDRsp struct {
|
||||
Exist bool `json:"exist"`
|
||||
Sleep int `json:"sleep"`
|
||||
}
|
||||
|
||||
type GetSSHStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
25
server/router/extensions.go
Normal file
25
server/router/extensions.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -38,4 +38,5 @@ func server(r *gin.Engine) {
|
||||
hidRouter(r)
|
||||
wsRouter(r)
|
||||
downloadRouter(r)
|
||||
extensionsRouter(r)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
222
server/service/application/update.go
Normal file
222
server/service/application/update.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
118
server/service/extensions/tailscale/install.go
Normal file
118
server/service/extensions/tailscale/install.go
Normal 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
|
||||
}
|
||||
235
server/service/extensions/tailscale/service.go
Normal file
235
server/service/extensions/tailscale/service.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
67
server/service/vm/ssh.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
82
server/utils/untar.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
51
web/src/api/extensions/tailscale.ts
Normal file
51
web/src/api/extensions/tailscale.ts
Normal 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');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '取消'
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} /> },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
64
web/src/pages/desktop/menu/settings/device/ssh.tsx
Normal file
64
web/src/pages/desktop/menu/settings/device/ssh.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
56
web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx
Normal file
56
web/src/pages/desktop/menu/settings/tailscale/uninstall.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user